domma-cms 0.73.0 → 0.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -1
- package/admin/js/api.js +1 -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/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/projects-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/projects.html +69 -10
- 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/index.js +1 -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/projects.js +58 -3
- 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/projects.js +16 -2
- package/server/routes/api/theme.js +55 -0
- package/server/routes/api/views.js +27 -3
- package/server/server.js +19 -0
- package/server/services/contextMenus.js +41 -0
- package/server/services/projects.js +86 -26
- package/server/services/views.js +38 -0
- package/admin/js/templates/project-editor.html +0 -45
- package/admin/js/views/project-editor.js +0 -1
|
@@ -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]
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Projects API
|
|
3
3
|
*
|
|
4
|
-
* GET /api/projects - list visible projects
|
|
4
|
+
* GET /api/projects - list visible projects (?counts=1 adds per-type artefact counts)
|
|
5
|
+
* GET /api/projects/_count - the sidebar badge (the underscore can never be a slug)
|
|
5
6
|
* GET /api/projects/:slug - full project
|
|
6
7
|
* POST /api/projects - create
|
|
7
8
|
* PUT /api/projects/:slug - edit (slug immutable)
|
|
@@ -19,9 +20,12 @@ import {
|
|
|
19
20
|
CORE_PROJECT_SLUG,
|
|
20
21
|
createProject,
|
|
21
22
|
deleteProject,
|
|
23
|
+
emptyGroups,
|
|
24
|
+
getArtefactsByProject,
|
|
22
25
|
getArtefactsForProject,
|
|
23
26
|
getProject,
|
|
24
27
|
listProjectsForUser,
|
|
28
|
+
projectsBadge,
|
|
25
29
|
untagAllForProject,
|
|
26
30
|
updateProject
|
|
27
31
|
} from '../../services/projects.js';
|
|
@@ -44,9 +48,19 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
44
48
|
const canDelete = {preHandler: [authenticate, requirePermission('projects', 'delete')]};
|
|
45
49
|
|
|
46
50
|
fastify.get('/projects', canRead, async (request) => {
|
|
47
|
-
|
|
51
|
+
const list = await listProjectsForUser(request.user);
|
|
52
|
+
if (request.query?.counts !== '1') return list;
|
|
53
|
+
// One request for the Manage projects screen and the sidebar, and one pass over the artefacts.
|
|
54
|
+
const groups = await getArtefactsByProject().catch(() => ({}));
|
|
55
|
+
return list.map((p) => ({
|
|
56
|
+
...p,
|
|
57
|
+
counts: Object.fromEntries(Object.entries(groups[p.slug] || emptyGroups()).map(([k, v]) => [k, v.length]))
|
|
58
|
+
}));
|
|
48
59
|
});
|
|
49
60
|
|
|
61
|
+
// Registered before /projects/:slug; `_` is outside the slug alphabet, so no project can shadow it.
|
|
62
|
+
fastify.get('/projects/_count', canRead, async (request) => projectsBadge(await listProjectsForUser(request.user)));
|
|
63
|
+
|
|
50
64
|
fastify.get('/projects/:slug', canRead, async (request, reply) => {
|
|
51
65
|
const project = await getProject(request.params.slug);
|
|
52
66
|
if (!project) return reply.status(404).send({error: 'Project not found'});
|
|
@@ -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 () => ({
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Views API
|
|
3
3
|
*
|
|
4
4
|
* Admin endpoints (authenticated + views permission):
|
|
5
|
-
* GET /views - List all view configs
|
|
5
|
+
* GET /views - List all view configs (?sources=1 adds `sourceInfo` per view)
|
|
6
|
+
* GET /views/_count - The sidebar badge (the underscore is outside the slug alphabet)
|
|
6
7
|
* POST /views - Create view config
|
|
7
8
|
* GET /views/:slug - Get view config
|
|
8
9
|
* PUT /views/:slug - Update view config
|
|
@@ -13,11 +14,17 @@
|
|
|
13
14
|
* Public endpoint (role-checked per view access config):
|
|
14
15
|
* GET /views/:slug/public - Execute view publicly
|
|
15
16
|
*/
|
|
16
|
-
import {createView, deleteView, executeView, getView, listViews, updateView} from '../../services/views.js';
|
|
17
|
+
import {createView, deleteView, executeView, getView, listViews, updateView, viewsBadge} from '../../services/views.js';
|
|
17
18
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
18
19
|
import {getRoleLevel} from '../../services/roles.js';
|
|
19
20
|
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
20
21
|
|
|
22
|
+
/** Every collection by slug - a missing one is a view whose source was deleted. */
|
|
23
|
+
async function collectionsBySlug() {
|
|
24
|
+
const {listCollections} = await import('../../services/collections.js');
|
|
25
|
+
return new Map((await listCollections().catch(() => [])).map(c => [c.slug, c]));
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
export async function viewsRoutes(fastify) {
|
|
22
29
|
const canRead = {preHandler: [authenticate, requirePermission('views', 'read')]};
|
|
23
30
|
const canCreate = {preHandler: [authenticate, requirePermission('views', 'create')]};
|
|
@@ -33,7 +40,18 @@ export async function viewsRoutes(fastify) {
|
|
|
33
40
|
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
34
41
|
const all = await listViews();
|
|
35
42
|
// listViews returns full records with meta inline - filter directly
|
|
36
|
-
|
|
43
|
+
const visible = all.filter(v => canSeeArtefact(request.user, v));
|
|
44
|
+
if (request.query?.sources !== '1') return visible;
|
|
45
|
+
// The Views screen: what each view reads from - whether that collection still
|
|
46
|
+
// exists, its title and its storage (MongoDB runs native aggregation, file
|
|
47
|
+
// collections the JS pipeline). A views user need not hold collections.read.
|
|
48
|
+
const bySlug = await collectionsBySlug();
|
|
49
|
+
return visible.map(v => {
|
|
50
|
+
const c = bySlug.get(v.pipeline?.source);
|
|
51
|
+
return {...v, sourceInfo: c
|
|
52
|
+
? {exists: true, title: c.title || c.slug, adapter: c.storage?.adapter || 'file'}
|
|
53
|
+
: {exists: false}};
|
|
54
|
+
});
|
|
37
55
|
} catch (err) {
|
|
38
56
|
return reply.status(503).send({ error: err.message });
|
|
39
57
|
}
|
|
@@ -49,6 +67,12 @@ export async function viewsRoutes(fastify) {
|
|
|
49
67
|
}
|
|
50
68
|
});
|
|
51
69
|
|
|
70
|
+
fastify.get('/views/_count', canRead, async (request) => {
|
|
71
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
72
|
+
const visible = (await listViews()).filter(v => canSeeArtefact(request.user, v));
|
|
73
|
+
return viewsBadge(visible, new Set((await collectionsBySlug()).keys()));
|
|
74
|
+
});
|
|
75
|
+
|
|
52
76
|
// Static sub-routes must be declared before the parameterised :slug route
|
|
53
77
|
// so Fastify's radix router gives them priority.
|
|
54
78
|
fastify.get('/views/collection/:collectionSlug', canRead, async (request, reply) => {
|
package/server/server.js
CHANGED
|
@@ -525,6 +525,25 @@ registerSidebarItem({item: {source: 'Built in', text: 'Notes', url: '#/notes', i
|
|
|
525
525
|
registerSidebarItem({item: {source: 'Built in', text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'}});
|
|
526
526
|
registerSidebarItem({item: {source: 'Built in', text: 'Analytics', url: '#/analytics', icon: 'chart-bar', permission: 'analytics', countUrl: '/api/analytics/active-count'}});
|
|
527
527
|
registerSidebarItem({item: {source: 'Built in', text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'}});
|
|
528
|
+
// Manage projects lives in the menu (the Projects folder, sidebar-migration.js);
|
|
529
|
+
// registering it here only adds its badge - the renderer merges a registered
|
|
530
|
+
// item into the menu node with the same url rather than adding a second one.
|
|
531
|
+
registerSidebarItem({folder: 'projects', item: {source: 'Built in', text: 'Manage projects', url: '#/projects', icon: 'folder', permission: 'projects', countUrl: '/api/projects/_count', inventory: true}});
|
|
532
|
+
// Content and Data: the same - each merges into its menu entry and brings its badge
|
|
533
|
+
// (which keeps the number the sidebar always showed there; the popover says more).
|
|
534
|
+
// `inventory`: a how-many count, left out of a closed folder's total (sidebar-renderer).
|
|
535
|
+
registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Pages', url: '#/pages', icon: 'file-text', permission: 'pages', countUrl: '/api/pages/_count', inventory: true}});
|
|
536
|
+
registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Media', url: '#/media', icon: 'image', permission: 'media', countUrl: '/api/media/_count', inventory: true}});
|
|
537
|
+
registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Menus', url: '#/menus', icon: 'menu', permission: 'menus', countUrl: '/api/menus/_count', inventory: true}});
|
|
538
|
+
registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Context Menus', url: '#/context-menus', icon: 'menu', permission: 'context-menus', countUrl: '/api/context-menus/_count', inventory: true}});
|
|
539
|
+
registerSidebarItem({folder: 'content', item: {source: 'Built in', text: 'Theme', url: '#/theme', icon: 'droplet', permission: 'theme', countUrl: '/api/theme/count', inventory: true}});
|
|
540
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Collections', url: '#/collections', icon: 'database', permission: 'collections', countUrl: '/api/collections/_count', inventory: true}});
|
|
541
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Forms', url: '#/forms', icon: 'layout', permission: 'collections', countUrl: '/api/forms/_count', inventory: true}});
|
|
542
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Views', url: '#/views', icon: 'eye', permission: 'views', countUrl: '/api/views/_count', inventory: true}});
|
|
543
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Actions', url: '#/actions', icon: 'zap', permission: 'actions', countUrl: '/api/actions/_count', inventory: true}});
|
|
544
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'API Builder', url: '#/api-endpoints', icon: 'code', permission: 'api-endpoints', countUrl: '/api/api-endpoints/_count', inventory: true}});
|
|
545
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Blocks', url: '#/blocks', icon: 'box', permission: 'pages', countUrl: '/api/blocks/_count', inventory: true}});
|
|
546
|
+
registerSidebarItem({folder: 'data', item: {source: 'Built in', text: 'Components', url: '#/components', icon: 'component', permission: 'components', countUrl: '/api/components/_count', inventory: true}});
|
|
528
547
|
|
|
529
548
|
await registerPlugins(app);
|
|
530
549
|
|
|
@@ -475,3 +475,44 @@ export function toClientPayload(menu) {
|
|
|
475
475
|
items: menu.items || []
|
|
476
476
|
};
|
|
477
477
|
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The sidebar badge on Context Menus (its countUrl, GET /api/context-menus/_count):
|
|
481
|
+
* how many menus there are, with the ones switched off and the ones whose
|
|
482
|
+
* binding matches nothing called out - an enabled menu that never appears is
|
|
483
|
+
* the one thing here worth a second look. Plain text throughout: the popover
|
|
484
|
+
* sets it with textContent.
|
|
485
|
+
*
|
|
486
|
+
* @param {object[]} menus - as listContextMenus returns them
|
|
487
|
+
* @returns {{count: number, label: string, tone: string, details: object}}
|
|
488
|
+
*/
|
|
489
|
+
export function contextMenusBadge(menus = []) {
|
|
490
|
+
const list = [...menus].sort((a, b) => String(a.name || a.slug).localeCompare(String(b.name || b.slug)));
|
|
491
|
+
const off = list.filter(m => m.enabled === false);
|
|
492
|
+
const never = list.filter(m => m.enabled !== false && m.binding
|
|
493
|
+
&& !(m.binding.projects || []).length && !(m.binding.pages || []).length);
|
|
494
|
+
const siteWide = list.filter(m => m.enabled !== false && !m.binding).length;
|
|
495
|
+
const n = list.length;
|
|
496
|
+
const bits = [off.length && `${off.length} off`, never.length && `${never.length} never matching`].filter(Boolean);
|
|
497
|
+
return {
|
|
498
|
+
count: n,
|
|
499
|
+
label: `${n} context menu${n === 1 ? '' : 's'}${bits.length ? `, ${bits.join(', ')}` : ''}`,
|
|
500
|
+
tone: never.length ? 'attention' : 'info',
|
|
501
|
+
details: {
|
|
502
|
+
title: 'Context Menus',
|
|
503
|
+
rows: [
|
|
504
|
+
{label: 'On', value: String(n - off.length)},
|
|
505
|
+
{label: 'Off', value: String(off.length)},
|
|
506
|
+
{label: 'Site-wide', value: String(siteWide)},
|
|
507
|
+
{label: 'Never matching', value: String(never.length), ...(never.length && {tone: 'danger'})}
|
|
508
|
+
],
|
|
509
|
+
items: list.slice(0, 8).map(m => ({
|
|
510
|
+
text: String(m.name || m.slug),
|
|
511
|
+
meta: [m.selector || 'no target', `${m.itemsCount ?? 0} item${m.itemsCount === 1 ? '' : 's'}`,
|
|
512
|
+
m.enabled === false ? 'off' : '', never.includes(m) ? 'never matches' : ''].filter(Boolean).join(' · '),
|
|
513
|
+
...(never.includes(m) && {tone: 'danger'})
|
|
514
|
+
})),
|
|
515
|
+
empty: 'No context menus yet.'
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
@@ -108,8 +108,16 @@ export async function getProject(slug) {
|
|
|
108
108
|
* @returns {Promise<object>} The persisted project record.
|
|
109
109
|
* @throws {Error} If validation fails or the slug already exists.
|
|
110
110
|
*/
|
|
111
|
+
/**
|
|
112
|
+
* Slugs a NEW project cannot take: `#/projects/new` and `#/projects/edit/…`
|
|
113
|
+
* are admin addresses, so a project called either could never be opened.
|
|
114
|
+
* (Checked on create only - an existing record keeps working.)
|
|
115
|
+
*/
|
|
116
|
+
export const RESERVED_PROJECT_SLUGS = ['new', 'edit'];
|
|
117
|
+
|
|
111
118
|
export async function createProject(input) {
|
|
112
119
|
const errors = validateProject(input);
|
|
120
|
+
if (RESERVED_PROJECT_SLUGS.includes(input?.slug)) errors.push(`"${input.slug}" is reserved - the admin uses it in its own addresses`);
|
|
113
121
|
if (errors.length) throw new Error('Validation failed: ' + errors.join('; '));
|
|
114
122
|
if (await getProject(input.slug)) {
|
|
115
123
|
throw new Error(`Project "${input.slug}" already exists`);
|
|
@@ -397,23 +405,28 @@ export async function getProjectForPage(urlPath, explicitProject) {
|
|
|
397
405
|
return best ?? coreFallback;
|
|
398
406
|
}
|
|
399
407
|
|
|
408
|
+
/** The per-type buckets every project's artefacts are grouped into. */
|
|
409
|
+
export function emptyGroups() {
|
|
410
|
+
return {
|
|
411
|
+
pages: [], collections: [], forms: [], actions: [],
|
|
412
|
+
menus: [], blocks: [], components: [], views: [], roles: [], users: [], apis: []
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
400
416
|
/**
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
417
|
+
* Every artefact, grouped by the project it resolves to - ONE pass over each
|
|
418
|
+
* artefact type, however many projects there are. The Manage projects list
|
|
419
|
+
* (GET /api/projects?counts=1) needs every project's counts, and asking
|
|
420
|
+
* getArtefactsForProject once per project re-read every artefact N times.
|
|
404
421
|
*
|
|
405
|
-
* Membership is by resolution, not tag equality:
|
|
406
|
-
* `meta.project` tag
|
|
407
|
-
* `getArtefactsForProject('core')` enumerates everything unclaimed.
|
|
422
|
+
* Membership is by resolution, not tag equality: an artefact without a
|
|
423
|
+
* `meta.project` tag lands under core.
|
|
408
424
|
*
|
|
409
|
-
* @
|
|
410
|
-
* @returns {Promise<{pages:object[], collections:object[], forms:object[], actions:object[], menus:object[], blocks:object[], components:object[], views:object[], roles:object[], users:object[], apis:object[]}>}
|
|
425
|
+
* @returns {Promise<Record<string, ReturnType<typeof emptyGroups>>>} keyed by project slug; a project with nothing is absent
|
|
411
426
|
*/
|
|
412
|
-
export async function
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
menus: [], blocks: [], components: [], views: [], roles: [], users: [], apis: []
|
|
416
|
-
};
|
|
427
|
+
export async function getArtefactsByProject() {
|
|
428
|
+
const groups = {};
|
|
429
|
+
const out = (slug) => (groups[slug] ??= emptyGroups());
|
|
417
430
|
|
|
418
431
|
// The three loops below re-read each artefact by slug; guard PER ITEM so a
|
|
419
432
|
// single unreadable artefact (e.g. a form whose internal slug doesn't match
|
|
@@ -423,7 +436,7 @@ export async function getArtefactsForProject(projectSlug) {
|
|
|
423
436
|
for (const m of await listMenus()) {
|
|
424
437
|
try {
|
|
425
438
|
const full = await getMenu(m.slug);
|
|
426
|
-
|
|
439
|
+
out(resolveArtefactProject(full)).menus.push(m);
|
|
427
440
|
} catch { /* skip broken menu */ }
|
|
428
441
|
}
|
|
429
442
|
} catch { /* service unavailable */ }
|
|
@@ -433,7 +446,7 @@ export async function getArtefactsForProject(projectSlug) {
|
|
|
433
446
|
for (const f of await listForms()) {
|
|
434
447
|
try {
|
|
435
448
|
const full = await readForm(f.slug);
|
|
436
|
-
|
|
449
|
+
out(resolveArtefactProject(full)).forms.push(f);
|
|
437
450
|
} catch { /* skip broken form */ }
|
|
438
451
|
}
|
|
439
452
|
} catch { /* skip */ }
|
|
@@ -443,7 +456,7 @@ export async function getArtefactsForProject(projectSlug) {
|
|
|
443
456
|
for (const c of await listCollections()) {
|
|
444
457
|
try {
|
|
445
458
|
const full = await getCollection(c.slug);
|
|
446
|
-
|
|
459
|
+
out(resolveArtefactProject(full)).collections.push(c);
|
|
447
460
|
} catch { /* skip broken collection */ }
|
|
448
461
|
}
|
|
449
462
|
} catch { /* skip */ }
|
|
@@ -451,7 +464,7 @@ export async function getArtefactsForProject(projectSlug) {
|
|
|
451
464
|
try {
|
|
452
465
|
const {listActions} = await import('./actions.js');
|
|
453
466
|
for (const a of await listActions()) {
|
|
454
|
-
|
|
467
|
+
out(resolveArtefactProject(a)).actions.push(a);
|
|
455
468
|
}
|
|
456
469
|
} catch { /* skip */ }
|
|
457
470
|
|
|
@@ -460,7 +473,7 @@ export async function getArtefactsForProject(projectSlug) {
|
|
|
460
473
|
// per-block read is needed. (Blocks are keyed by `name`, not `slug`.)
|
|
461
474
|
const {listBlocks} = await import('./blocks.js');
|
|
462
475
|
for (const b of await listBlocks()) {
|
|
463
|
-
|
|
476
|
+
out(resolveArtefactProject(b)).blocks.push(b);
|
|
464
477
|
}
|
|
465
478
|
} catch { /* skip */ }
|
|
466
479
|
|
|
@@ -469,28 +482,28 @@ export async function getArtefactsForProject(projectSlug) {
|
|
|
469
482
|
// per-component read is needed. (Components are keyed by `name`.)
|
|
470
483
|
const {listComponents} = await import('./components.js');
|
|
471
484
|
for (const c of await listComponents()) {
|
|
472
|
-
|
|
485
|
+
out(resolveArtefactProject(c)).components.push(c);
|
|
473
486
|
}
|
|
474
487
|
} catch { /* skip */ }
|
|
475
488
|
|
|
476
489
|
try {
|
|
477
490
|
const {listViews} = await import('./views.js');
|
|
478
491
|
for (const v of await listViews()) {
|
|
479
|
-
|
|
492
|
+
out(resolveArtefactProject(v)).views.push(v);
|
|
480
493
|
}
|
|
481
494
|
} catch { /* skip */ }
|
|
482
495
|
|
|
483
496
|
try {
|
|
484
497
|
const {getRoleMap} = await import('./roles.js');
|
|
485
498
|
for (const role of getRoleMap().values()) {
|
|
486
|
-
|
|
499
|
+
out(resolveArtefactProject(role)).roles.push(role);
|
|
487
500
|
}
|
|
488
501
|
} catch { /* skip */ }
|
|
489
502
|
|
|
490
503
|
try {
|
|
491
504
|
const {listUsers} = await import('./users.js');
|
|
492
505
|
for (const u of await listUsers()) {
|
|
493
|
-
|
|
506
|
+
out(resolveArtefactProject(u)).users.push(u);
|
|
494
507
|
}
|
|
495
508
|
} catch { /* skip */ }
|
|
496
509
|
|
|
@@ -500,7 +513,7 @@ export async function getArtefactsForProject(projectSlug) {
|
|
|
500
513
|
const url = p.url || p.urlPath || '/';
|
|
501
514
|
const explicit = (p.project === undefined) ? undefined : p.project;
|
|
502
515
|
const pageProject = await getProjectForPage(url, explicit);
|
|
503
|
-
|
|
516
|
+
out(pageProject).pages.push(p);
|
|
504
517
|
}
|
|
505
518
|
} catch { /* skip */ }
|
|
506
519
|
|
|
@@ -509,13 +522,27 @@ export async function getArtefactsForProject(projectSlug) {
|
|
|
509
522
|
// field - no untagged-→core fallback applies, so match directly).
|
|
510
523
|
const {entries} = await listEntries('api-endpoints', {limit: 0});
|
|
511
524
|
for (const e of entries) {
|
|
512
|
-
if (e.data?.project
|
|
513
|
-
out.apis.push({id: e.id, name: e.data.name, path: e.data.path, collection: e.data.collection});
|
|
525
|
+
if (e.data?.project) {
|
|
526
|
+
out(e.data.project).apis.push({id: e.id, name: e.data.name, path: e.data.path, collection: e.data.collection});
|
|
514
527
|
}
|
|
515
528
|
}
|
|
516
529
|
} catch { /* skip */ }
|
|
517
530
|
|
|
518
|
-
return
|
|
531
|
+
return groups;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Enumerate every artefact belonging to the given project. Returns a flat
|
|
536
|
+
* grouping by type - each value is an array of artefact summary objects.
|
|
537
|
+
* Used by the project detail page (counts + per-type tabs).
|
|
538
|
+
*
|
|
539
|
+
* `getArtefactsForProject('core')` enumerates everything unclaimed.
|
|
540
|
+
*
|
|
541
|
+
* @param {string} projectSlug
|
|
542
|
+
* @returns {Promise<ReturnType<typeof emptyGroups>>}
|
|
543
|
+
*/
|
|
544
|
+
export async function getArtefactsForProject(projectSlug) {
|
|
545
|
+
return (await getArtefactsByProject())[projectSlug] ?? emptyGroups();
|
|
519
546
|
}
|
|
520
547
|
|
|
521
548
|
/**
|
|
@@ -644,3 +671,36 @@ export async function untagAllForProject(projectSlug) {
|
|
|
644
671
|
|
|
645
672
|
return counts;
|
|
646
673
|
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The sidebar badge on Manage projects (its countUrl, GET /api/projects/_count):
|
|
677
|
+
* how many projects this user sees - a count to glance at, not a warning -
|
|
678
|
+
* with the disabled ones called out. Plain text throughout: the
|
|
679
|
+
* popover sets it with textContent.
|
|
680
|
+
*
|
|
681
|
+
* @param {object[]} projects - as listProjectsForUser returns them
|
|
682
|
+
* @returns {{count: number, label: string, tone: string, details: object}}
|
|
683
|
+
*/
|
|
684
|
+
export function projectsBadge(projects = []) {
|
|
685
|
+
const list = [...projects].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)
|
|
686
|
+
|| String(a.name || a.slug).localeCompare(String(b.name || b.slug)));
|
|
687
|
+
const off = list.filter(p => p.enabled === false);
|
|
688
|
+
const n = list.length;
|
|
689
|
+
return {
|
|
690
|
+
count: n,
|
|
691
|
+
label: `${n} project${n === 1 ? '' : 's'}${off.length ? `, ${off.length} disabled` : ''}`,
|
|
692
|
+
tone: 'info',
|
|
693
|
+
details: {
|
|
694
|
+
title: 'Projects',
|
|
695
|
+
rows: [
|
|
696
|
+
{label: 'Enabled', value: String(n - off.length)},
|
|
697
|
+
{label: 'Disabled', value: String(off.length)}
|
|
698
|
+
],
|
|
699
|
+
items: list.slice(0, 8).map(p => ({
|
|
700
|
+
text: String(p.name || p.slug),
|
|
701
|
+
meta: [p.slug, p.rootUrl, p.enabled === false ? 'disabled' : '', p.protected ? 'built in' : ''].filter(Boolean).join(' · ')
|
|
702
|
+
})),
|
|
703
|
+
empty: 'No projects yet.'
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
}
|