domma-cms 0.74.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.
- package/CLAUDE.md +3 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/actions-arrange.js +1 -0
- package/admin/js/lib/api-endpoints-arrange.js +1 -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/forms-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/pages-arrange.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/theme-settings-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/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/forms.html +67 -14
- package/admin/js/templates/media.html +99 -13
- package/admin/js/templates/menus.html +57 -11
- package/admin/js/templates/pages.html +78 -23
- package/admin/js/templates/theme.html +164 -132
- 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/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/forms.js +57 -1
- package/admin/js/views/media.js +40 -1
- package/admin/js/views/menus.js +11 -5
- package/admin/js/views/pages.js +28 -8
- package/admin/js/views/theme.js +55 -1
- package/admin/js/views/views-list.js +11 -1
- package/package.json +1 -1
- package/server/routes/api/actions.js +64 -2
- package/server/routes/api/api-endpoints.js +41 -0
- package/server/routes/api/blocks.js +170 -8
- package/server/routes/api/collections.js +53 -1
- package/server/routes/api/components.js +46 -0
- package/server/routes/api/context-menus.js +8 -0
- package/server/routes/api/forms.js +123 -10
- package/server/routes/api/media.js +56 -1
- package/server/routes/api/menus.js +53 -6
- package/server/routes/api/pages.js +46 -0
- package/server/routes/api/theme.js +55 -0
- package/server/routes/api/views.js +27 -3
- package/server/server.js +16 -1
- package/server/services/contextMenus.js +41 -0
- package/server/services/views.js +38 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
// -----------------------------------------------------------------------
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Menus API
|
|
3
3
|
*
|
|
4
4
|
* GET /api/menus - list (metadata only)
|
|
5
|
+
* GET /api/menus/_count - the sidebar badge (the underscore can never be a slug)
|
|
5
6
|
* GET /api/menus/:slug - full menu
|
|
6
7
|
* POST /api/menus - create
|
|
7
8
|
* PUT /api/menus/:slug - replace
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
createMenu,
|
|
22
23
|
deleteMenu,
|
|
23
24
|
duplicateMenu,
|
|
25
|
+
getLocationRegistry,
|
|
24
26
|
getLocations,
|
|
25
27
|
getMenu,
|
|
26
28
|
listMenus,
|
|
@@ -28,6 +30,43 @@ import {
|
|
|
28
30
|
} from '../../services/menus.js';
|
|
29
31
|
import * as cache from '../../services/cache/index.js';
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The sidebar badge on Menus (its countUrl): how many menus, which locations
|
|
35
|
+
* have none, and which menus are in no location. A count to glance at, not a
|
|
36
|
+
* warning. Plain text throughout - the popover sets it with textContent.
|
|
37
|
+
*
|
|
38
|
+
* "Not in a location" is not "unused": a `[menu]` shortcode can show any menu.
|
|
39
|
+
* The overlay slot is left out of "empty" - it is optional by design.
|
|
40
|
+
*
|
|
41
|
+
* @param {{menus: object[], locations: Record<string, string>, registry: object[]}} state
|
|
42
|
+
* @returns {{count: number, label: string, tone: string, details: object}}
|
|
43
|
+
*/
|
|
44
|
+
export function menusBadge({menus = [], locations = {}, registry = []} = {}) {
|
|
45
|
+
const mapped = new Set(Object.values(locations || {}).filter(Boolean));
|
|
46
|
+
const placed = (m) => mapped.has(m.slug) || Boolean(m.binding?.slot);
|
|
47
|
+
const unplaced = menus.filter(m => !placed(m));
|
|
48
|
+
const bindable = registry.filter(r => r.slot !== 'overlay');
|
|
49
|
+
const empty = bindable.filter(r => !locations?.[r.slot]);
|
|
50
|
+
const n = menus.length;
|
|
51
|
+
return {
|
|
52
|
+
count: n,
|
|
53
|
+
label: `${n} menu${n === 1 ? '' : 's'}${unplaced.length ? `, ${unplaced.length} not in a location` : ''}`,
|
|
54
|
+
tone: 'info',
|
|
55
|
+
details: {
|
|
56
|
+
title: 'Menus',
|
|
57
|
+
rows: [
|
|
58
|
+
{label: 'In a location', value: `${n - unplaced.length} of ${n}`},
|
|
59
|
+
{label: 'Locations filled', value: `${bindable.length - empty.length} of ${bindable.length}`}
|
|
60
|
+
],
|
|
61
|
+
items: [
|
|
62
|
+
...empty.map(r => ({text: r.label || r.slot, meta: 'location - no menu'})),
|
|
63
|
+
...unplaced.map(m => ({text: m.name || m.slug, meta: `${m.slug} · not in a location`}))
|
|
64
|
+
].slice(0, 8),
|
|
65
|
+
empty: 'Every location has a menu, and every menu is in one.'
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
31
70
|
/**
|
|
32
71
|
* Register the menus routes.
|
|
33
72
|
*
|
|
@@ -44,17 +83,25 @@ export async function menusRoutes(fastify, opts = {}) {
|
|
|
44
83
|
const canUpdate = {preHandler: [authenticate, requirePermission('menus', 'update')]};
|
|
45
84
|
const canDelete = {preHandler: [authenticate, requirePermission('menus', 'delete')]};
|
|
46
85
|
|
|
47
|
-
|
|
86
|
+
/** The menus this user may see - listMenus is metadata only, so each is read for its meta. */
|
|
87
|
+
async function visibleMenus(user) {
|
|
48
88
|
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
49
|
-
const all = await listMenus();
|
|
50
89
|
const filtered = [];
|
|
51
|
-
for (const m of
|
|
52
|
-
// listMenus returns metadata only; getMenu returns full record with meta
|
|
90
|
+
for (const m of await listMenus()) {
|
|
53
91
|
const full = await getMenu(m.slug);
|
|
54
|
-
if (canSeeArtefact(
|
|
92
|
+
if (canSeeArtefact(user, full)) filtered.push(m);
|
|
55
93
|
}
|
|
56
94
|
return filtered;
|
|
57
|
-
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fastify.get('/menus', canRead, async (request) => visibleMenus(request.user));
|
|
98
|
+
|
|
99
|
+
// Registered before /menus/:slug; `_` is outside the slug alphabet, so no menu can shadow it.
|
|
100
|
+
fastify.get('/menus/_count', canRead, async (request) => menusBadge({
|
|
101
|
+
menus: await visibleMenus(request.user),
|
|
102
|
+
locations: await getLocations(),
|
|
103
|
+
registry: getLocationRegistry()
|
|
104
|
+
}));
|
|
58
105
|
|
|
59
106
|
fastify.get('/menus/:slug', canRead, async (request, reply) => {
|
|
60
107
|
const menu = await getMenu(request.params.slug);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pages API
|
|
3
3
|
* GET /api/pages - list all pages
|
|
4
|
+
* GET /api/pages/_count - the sidebar badge on Pages (drafts)
|
|
4
5
|
* GET /api/pages/* - get single page by URL path
|
|
5
6
|
* POST /api/pages - create page
|
|
6
7
|
* PUT /api/pages/* - update page
|
|
@@ -23,6 +24,44 @@ import {
|
|
|
23
24
|
import {getVersionCount} from '../../services/versions.js';
|
|
24
25
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
25
26
|
import {getConfig, saveConfig} from '../../config.js';
|
|
27
|
+
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The sidebar badge on Pages (its countUrl): the drafts - unpublished work -
|
|
31
|
+
* with a popover of what is published, restricted and recently changed, and
|
|
32
|
+
* the drafts themselves, newest first. No drafts, no badge. Plain text
|
|
33
|
+
* throughout: the popover sets it with textContent.
|
|
34
|
+
*
|
|
35
|
+
* @param {object[]} pages - listPages()
|
|
36
|
+
* @param {number} [now]
|
|
37
|
+
*/
|
|
38
|
+
export function pagesBadge(pages, now = Date.now()) {
|
|
39
|
+
const list = pages || [];
|
|
40
|
+
const drafts = list.filter(p => p.status !== 'published')
|
|
41
|
+
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
|
|
42
|
+
const restricted = list.filter(p => p.visibility && p.visibility !== 'public').length;
|
|
43
|
+
const week = list.filter(p => p.updatedAt && now - new Date(p.updatedAt).getTime() < 7 * 86_400_000).length;
|
|
44
|
+
return {
|
|
45
|
+
// The number stays what the sidebar has always shown on Pages (all of them); drafts are the label.
|
|
46
|
+
count: list.length,
|
|
47
|
+
label: drafts.length ? `${plural(list.length, 'page')}, ${plural(drafts.length, 'draft')}` : `${plural(list.length, 'page')}, no drafts`,
|
|
48
|
+
tone: 'info',
|
|
49
|
+
details: {
|
|
50
|
+
title: 'Pages',
|
|
51
|
+
rows: [
|
|
52
|
+
{label: 'Published', value: String(list.length - drafts.length)},
|
|
53
|
+
{label: 'Drafts', value: String(drafts.length)},
|
|
54
|
+
{label: 'Not public', value: String(restricted)},
|
|
55
|
+
{label: 'Changed this week', value: String(week)}
|
|
56
|
+
],
|
|
57
|
+
items: drafts.slice(0, 6).map(p => ({
|
|
58
|
+
text: clip(p.title || p.urlPath),
|
|
59
|
+
meta: [p.urlPath, p.updatedAt ? ago(p.updatedAt, now) : ''].filter(Boolean).join(' · ')
|
|
60
|
+
})),
|
|
61
|
+
empty: 'No drafts - everything is published.'
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
26
65
|
|
|
27
66
|
export async function pagesRoutes(fastify) {
|
|
28
67
|
const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
|
|
@@ -116,6 +155,9 @@ export async function pagesRoutes(fastify) {
|
|
|
116
155
|
return { tags: [...tagSet].sort() };
|
|
117
156
|
});
|
|
118
157
|
|
|
158
|
+
// The sidebar badge. Static, so it is matched before the /pages/* wildcard below.
|
|
159
|
+
fastify.get('/pages/_count', canRead, async () => pagesBadge(await listPages()));
|
|
160
|
+
|
|
119
161
|
// List all pages
|
|
120
162
|
fastify.get('/pages', canRead, async (request, reply) => {
|
|
121
163
|
const pages = await listPages();
|
|
@@ -133,6 +175,10 @@ export async function pagesRoutes(fastify) {
|
|
|
133
175
|
tags: p.tags || [],
|
|
134
176
|
project: p.project ?? null,
|
|
135
177
|
resolvedProject: p.resolvedProject ?? null,
|
|
178
|
+
// The list's "managed by a plugin" chip and delete warning read these;
|
|
179
|
+
// they were dropped here, so neither ever showed.
|
|
180
|
+
plugin: p.plugin || null,
|
|
181
|
+
bundled: p.bundled === true,
|
|
136
182
|
updatedAt: p.updatedAt,
|
|
137
183
|
createdAt: p.createdAt,
|
|
138
184
|
versionCount: counts[i]
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* user - everyone's admin needs one)
|
|
11
11
|
* POST /api/theme/compile - compile a candidate config to CSS without saving,
|
|
12
12
|
* so the admin previews exactly what the page gets
|
|
13
|
+
* GET /api/theme/count - the sidebar badge on Theme (themeBadge below)
|
|
13
14
|
*
|
|
14
15
|
* Auth middlewares are injectable so tests can pass no-ops, the same DI pattern
|
|
15
16
|
* as menus.js and context-menus.js.
|
|
@@ -32,6 +33,57 @@ import {
|
|
|
32
33
|
validateThemeConfig
|
|
33
34
|
} from '../../services/themeSettings.js';
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The sidebar badge on Content › Theme. It REPLACES the core count the sidebar
|
|
38
|
+
* shows there (how many element overrides - a registered countUrl wins), so it
|
|
39
|
+
* keeps that number, and adds two things: a popover saying what is live where,
|
|
40
|
+
* and a red count when a theme in use no longer exists (a custom theme whose
|
|
41
|
+
* plugin went away - the page keeps its base, but the look it was given is
|
|
42
|
+
* gone). Plain text throughout.
|
|
43
|
+
*
|
|
44
|
+
* Mirrors themeProblems() in admin/js/lib/theme-settings-arrange.js.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} cfg
|
|
47
|
+
* @param {string[]} builtins - listThemeIds()
|
|
48
|
+
* @param {string[]} custom - ids of the custom themes plugins offer now
|
|
49
|
+
*/
|
|
50
|
+
/** Themes Domma knows without the stylesheet declaring them (resolveThemeClasses passes them through). */
|
|
51
|
+
export const DOMMA_OWN_THEMES = ['core-light', 'core-dark'];
|
|
52
|
+
|
|
53
|
+
export function themeBadge(cfg = {}, builtins = [], custom = []) {
|
|
54
|
+
// No catalogue (the stylesheet did not load) means nothing can be judged -
|
|
55
|
+
// the same rule as themeProblems() in admin/js/lib/theme-settings-arrange.js.
|
|
56
|
+
const known = builtins.length ? new Set([...builtins, ...custom, ...DOMMA_OWN_THEMES]) : null;
|
|
57
|
+
const auto = cfg.autoTheme?.enabled ? cfg.autoTheme : null;
|
|
58
|
+
const problems = [];
|
|
59
|
+
const check = (where, id) => { if (known && id && !known.has(id)) problems.push({where, id}); };
|
|
60
|
+
if (auto) { check('Day theme', auto.dayTheme); check('Night theme', auto.nightTheme); }
|
|
61
|
+
else check('Site theme', cfg.theme);
|
|
62
|
+
check('Admin theme', cfg.adminTheme);
|
|
63
|
+
const overrides = Array.isArray(cfg.overrides) ? cfg.overrides : [];
|
|
64
|
+
overrides.forEach((o, i) => check(`Override "${o.label || o.scope || i + 1}"`, o.theme));
|
|
65
|
+
const on = overrides.filter(o => o.enabled !== false).length;
|
|
66
|
+
const n = problems.length;
|
|
67
|
+
const plural = (k, one) => `${k} ${one}${k === 1 ? '' : 's'}`;
|
|
68
|
+
return {
|
|
69
|
+
count: n || overrides.length,
|
|
70
|
+
label: n ? `${plural(n, 'theme')} in use no longer available` : plural(overrides.length, 'element override'),
|
|
71
|
+
tone: n ? 'danger' : 'info',
|
|
72
|
+
details: {
|
|
73
|
+
title: 'Theme',
|
|
74
|
+
rows: [
|
|
75
|
+
auto
|
|
76
|
+
? {label: 'Site (day / night)', value: `${auto.dayTheme} / ${auto.nightTheme}`}
|
|
77
|
+
: {label: 'Site theme', value: String(cfg.theme || '-')},
|
|
78
|
+
{label: 'Admin theme', value: String(cfg.adminTheme || '-')},
|
|
79
|
+
{label: 'Element overrides', value: `${on} on${overrides.length - on ? `, ${overrides.length - on} off` : ''}`}
|
|
80
|
+
],
|
|
81
|
+
items: problems.slice(0, 8).map(p => ({text: `${p.where}: ${p.id}`, meta: 'no longer available - showing its base', tone: 'danger'})),
|
|
82
|
+
empty: 'Every theme in use is available.'
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
35
87
|
export async function themeRoutes(fastify, opts = {}) {
|
|
36
88
|
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
37
89
|
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
@@ -41,6 +93,9 @@ export async function themeRoutes(fastify, opts = {}) {
|
|
|
41
93
|
|
|
42
94
|
fastify.get('/theme', canRead, async () => loadThemeConfig());
|
|
43
95
|
|
|
96
|
+
fastify.get('/theme/count', canRead, async () =>
|
|
97
|
+
themeBadge(await loadThemeConfig(), listThemeIds(), listCustomThemes().map(t => t.id)));
|
|
98
|
+
|
|
44
99
|
// The editor's theme picker and token picker build themselves from this
|
|
45
100
|
// rather than from a hardcoded list that would drift from the CSS.
|
|
46
101
|
fastify.get('/theme/catalog', canRead, async () => ({
|