domma-cms 1.0.0 → 1.2.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.
@@ -38,6 +38,7 @@ import {registerThemeProvider} from './themeSettings.js';
38
38
  import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
39
39
  import {notifierFor} from './notify.js';
40
40
  import {disableInstantCalls, enableInstantCalls, grantInstantCallsOnce} from './instantCalls.js';
41
+ import {disableDashboardPro, enableDashboardPro, registerWidget, unregisterWidgetsFrom} from './widgets.js';
41
42
  import {createCollection, getCollection} from './collections.js';
42
43
  import * as defaultRolesService from './roles.js';
43
44
  import {getAdminScope, normaliseAdminScope} from './roles.js';
@@ -560,6 +561,10 @@ export async function registerPlugins(fastify) {
560
561
  // them on at boot. Bound to the plugin's name, tier and closedSource: only a paid plugin that
561
562
  // loads under a licence may.
562
563
  enableInstantCalls: () => enableInstantCalls({owner: manifest.name, tier: manifest.tier || 'free', closedSource: manifest.closedSource === true}),
564
+ // Dashboard Pro (services/widgets.js) - the widget board; the same contract as instant calls.
565
+ enableDashboardPro: () => enableDashboardPro({owner: manifest.name, tier: manifest.tier || 'free', closedSource: manifest.closedSource === true}),
566
+ // A widget from code (anything richer than a manifest's admin.widgets entry), owned by this plugin.
567
+ registerWidget: (def) => registerWidget({...def, owner: manifest.name, source: manifest.displayName || manifest.name}),
563
568
  // Who may read a contact that is not their own (Contacts Pro's sharing) - hooks.js 7b.
564
569
  registerContactAccess: (fn) => registerContactAccess(manifest.name, fn),
565
570
  // Direct messages between team members (Messages) - hooks.js 7c; a call's "Add people" uses it.
@@ -594,6 +599,14 @@ export async function registerPlugins(fastify) {
594
599
  }
595
600
  }
596
601
 
602
+ // Dashboard widgets the manifest offers (services/widgets.js) - drawn by the admin
603
+ // from their `kind` and `dataUrl`, so a Tool needs no code to have one.
604
+ for (const def of manifest.admin?.widgets || []) {
605
+ if (!registerWidget({...def, owner: manifest.name, source: manifest.displayName || manifest.name})) {
606
+ fastify.log.warn(`[plugins] widget "${def?.id}" for "${manifest.name}" skipped: not a valid widget`);
607
+ }
608
+ }
609
+
597
610
  // Ensure plugin collections exist (idempotent - skips if already present)
598
611
  await setupPlugin(manifest.name, {collections: {getCollection, createCollection}})
599
612
  .catch(err => fastify.log.warn(`[plugins] Collection setup for "${manifest.name}" failed: ${err.message}`));
@@ -1123,6 +1136,8 @@ export async function runLifecycleHook(name, hook, fastify) {
1123
1136
  // A Tool with calls switched off stops keeping instant calls on - at once, not at the next restart.
1124
1137
  if (hook === 'onDisable') {
1125
1138
  disableInstantCalls(name);
1139
+ disableDashboardPro(name);
1140
+ unregisterWidgetsFrom(name);
1126
1141
  unregisterContactAccess(name);
1127
1142
  unregisterDirectMessenger(name);
1128
1143
  }
@@ -16,6 +16,7 @@ import {buildCustomThemeStyleTag, buildOverrideStyleTag, getThemeTokenMap, listT
16
16
  import {getSearchSettings} from './search.js';
17
17
  import {isToolEnabled} from './tools.js';
18
18
  import {fmtDateTime} from './dates.js';
19
+ import {cssClasses} from '../../public/js/css-classes.mjs';
19
20
 
20
21
  const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
21
22
  const CUSTOM_CSS_PATH = new URL('../../content/custom.css', import.meta.url).pathname;
@@ -285,7 +286,8 @@ export async function renderPage(page, opts = {}) {
285
286
  const navbarClass = (navigation.orientation === 'vertical' && navigation.position !== 'floating')
286
287
  ? `dm-nav-vertical dm-nav-vertical-${navigation.side === 'right' ? 'right' : 'left'}`
287
288
  : '';
288
- const layoutBodyClass = ['dm-layout-' + layoutWidth, preset.class || '', verticalNavClass].filter(Boolean).join(' ');
289
+ // Site Settings > Appearance > Body CSS classes - the designer's own, on every page.
290
+ const layoutBodyClass = ['dm-layout-' + layoutWidth, preset.class || '', verticalNavClass, ...cssClasses(site.bodyClass)].filter(Boolean).join(' ');
289
291
  const pageBodyStyleParts = [];
290
292
  const BG_COLOR_SAFE = /^[a-zA-Z0-9#(),.\s%/-]+$/;
291
293
  if (preset.bgColor && BG_COLOR_SAFE.test(preset.bgColor)) pageBodyStyleParts.push(`background-color:${preset.bgColor}`);
@@ -365,7 +367,7 @@ export async function renderPage(page, opts = {}) {
365
367
  ? `window.__CMS_CTXMENUS__ = ${JSON.stringify(ctxMenus).replace(/<\/script>/gi, '<\\/script>')};`
366
368
  : '';
367
369
  const ctxMenusModule = ctxMenus.length
368
- ? '<script src="/public/js/context-menus.js?v=20260918-ctx" type="module"></script>'
370
+ ? '<script src="/public/js/context-menus.js?v=20261009-classes" type="module"></script>'
369
371
  : '';
370
372
 
371
373
  const vars = {
@@ -1079,7 +1081,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
1079
1081
  ? `window.__CMS_CTXMENUS__ = ${JSON.stringify(ctxMenus).replace(/<\/script>/gi, '<\\/script>')};`
1080
1082
  : '';
1081
1083
  const ctxMenusModule = ctxMenus.length
1082
- ? '<script src="/public/js/context-menus.js?v=20260918-ctx" type="module"></script>'
1084
+ ? '<script src="/public/js/context-menus.js?v=20261009-classes" type="module"></script>'
1083
1085
  : '';
1084
1086
 
1085
1087
  const vars = {
@@ -1098,7 +1100,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
1098
1100
  layout: 'default',
1099
1101
  layoutBodyClass: ['dm-layout-normal', navigation.orientation === 'vertical'
1100
1102
  ? `dm-vnav dm-vnav-${navigation.side === 'right' ? 'right' : 'left'}`
1101
- : ''].filter(Boolean).join(' '),
1103
+ : '', ...cssClasses(site.bodyClass)].filter(Boolean).join(' '),
1102
1104
  pageBodyStyle: '',
1103
1105
  showNavbar: true,
1104
1106
  showFooter: true,
@@ -317,6 +317,8 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
317
317
  '/content/.migrations.json',
318
318
  '/content/instances_log.json',
319
319
  '/content/preview-links.json',
320
+ // Each admin's own Dashboard Pro layout (keyed by user id); the site default beside it is tracked.
321
+ '/content/dashboard/layouts/',
320
322
  '/config/connections.json*',
321
323
  '/config/keys/',
322
324
  '/plugins/*/data/*.jsonl',
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Dashboard widgets - the parts a Tool offers for the Dashboard, and the Pro
3
+ * switch for the board that arranges them.
4
+ *
5
+ * A widget is a small, self-describing card. It never ships code: it names a
6
+ * `kind` the admin knows how to draw and a `dataUrl` that answers in that
7
+ * kind's shape, so a Tool adds a widget with a few lines of manifest and
8
+ * every widget looks like part of the same admin.
9
+ *
10
+ * kind dataUrl answers drawn as
11
+ * ------ ----------------------------------------------- -------------------------
12
+ * count {count, label, tone, details} (a sidebar badge) big number + its details list
13
+ * stat {value, label, sub, tone} big value + a line under it
14
+ * list {items: [{text, meta, href, value, tone}], empty} rows that open on click
15
+ * chart {labels: [], values: [], label} a line chart
16
+ *
17
+ * Three sources, merged by getWidgets():
18
+ *
19
+ * 1. Core's own (CORE_WIDGETS) - traffic, needs attention, recent activity,
20
+ * health, visitors now - read from /api/dashboard/widget/<name>.
21
+ * 2. Every sidebar entry with a `countUrl` - a Tool that has a badge has a
22
+ * widget, for free, in the badge's own shape (`count`). This is how
23
+ * Email, Booking, Support and the rest arrive on day one.
24
+ * 3. A plugin's `admin.widgets[]` in plugin.json (bridged in plugins.js),
25
+ * or registerWidget() from code - for anything richer than a badge.
26
+ *
27
+ * PRO ONLY: the board (add, arrange, resize, a layout per user) exists only
28
+ * while a paid, closed-source Tool has called `hooks.enableDashboardPro()` -
29
+ * the same contract as instant calls (services/instantCalls.js). Without it
30
+ * the widget and layout routes answer 404 and `#/` is the fixed Dashboard.
31
+ *
32
+ * @module services/widgets
33
+ */
34
+
35
+ import {getRegisteredSidebarItems, registerSidebarItem} from './hooks.js';
36
+
37
+ /** Widths on the board's four-column grid. */
38
+ export const WIDGET_SIZES = ['s', 'm', 'l', 'xl'];
39
+ export const WIDGET_KINDS = ['count', 'stat', 'list', 'chart'];
40
+
41
+ const ID_RE = /^[a-z0-9][a-z0-9:._/-]{0,119}$/;
42
+ const DATA_URL_RE = /^\/api\/[A-Za-z0-9/_.:?=&%-]+$/;
43
+
44
+ /** Core's own widgets - the fixed Dashboard's parts, one card each. */
45
+ export const CORE_WIDGETS = [
46
+ {id: 'core:traffic', title: 'Traffic', description: 'Page views over the last 30 days.', icon: 'activity',
47
+ kind: 'chart', size: 'xl', dataUrl: '/api/dashboard/widget/traffic', href: '#/analytics', admin: true, refresh: 300},
48
+ {id: 'core:visitors', title: 'Visitors now', description: 'People on the site in the last five minutes, and page views today.',
49
+ icon: 'users', kind: 'stat', size: 's', dataUrl: '/api/dashboard/widget/visitors', href: '#/analytics', admin: true, refresh: 30},
50
+ {id: 'core:attention', title: 'Needs attention', description: 'Anything late, failing or waiting on you, across every Tool you can open.',
51
+ icon: 'alert-circle', kind: 'list', size: 'm', dataUrl: '/api/dashboard/widget/attention', refresh: 60},
52
+ {id: 'core:activity', title: 'Recent activity', description: 'What changed on the site lately, and who changed it.',
53
+ icon: 'clock', kind: 'list', size: 'm', dataUrl: '/api/dashboard/widget/activity', admin: true, refresh: 120},
54
+ {id: 'core:health', title: 'Site health', description: 'The health checks - storage, email, backups and the rest.',
55
+ icon: 'heart', kind: 'list', size: 'm', dataUrl: '/api/dashboard/widget/health', admin: true, refresh: 300},
56
+ {id: 'core:top-pages', title: 'Top pages', description: 'The most visited pages today.',
57
+ icon: 'trending-up', kind: 'list', size: 'm', dataUrl: '/api/dashboard/widget/top-pages', href: '#/analytics', admin: true, refresh: 300}
58
+ ];
59
+
60
+ /** What a new board starts with when the site has set no default. */
61
+ export const DEFAULT_LAYOUT = [
62
+ {id: 'core:attention', size: 'm'},
63
+ {id: 'core:visitors', size: 's'},
64
+ {id: 'core:traffic', size: 'xl'},
65
+ {id: 'core:activity', size: 'm'},
66
+ {id: 'core:top-pages', size: 'm'}
67
+ ];
68
+
69
+ const _registered = [];
70
+
71
+ /**
72
+ * Normalise a widget definition; null when it cannot be drawn.
73
+ *
74
+ * @param {object} def
75
+ * @param {string} [source] - who offers it ('Built in', a plugin's name)
76
+ * @returns {object|null}
77
+ */
78
+ export function normaliseWidget(def, source = '') {
79
+ if (!def || typeof def !== 'object') return null;
80
+ const id = String(def.id || '').trim();
81
+ if (!ID_RE.test(id)) return null;
82
+ if (!WIDGET_KINDS.includes(def.kind)) return null;
83
+ if (typeof def.dataUrl !== 'string' || !DATA_URL_RE.test(def.dataUrl)) return null;
84
+ const href = typeof def.href === 'string' && /^#\//.test(def.href) ? def.href : '';
85
+ const refresh = Number.isFinite(Number(def.refresh)) ? Math.min(3600, Math.max(15, Number(def.refresh))) : 60;
86
+ return {
87
+ id,
88
+ title: String(def.title || id).slice(0, 60),
89
+ description: String(def.description || '').slice(0, 200),
90
+ icon: typeof def.icon === 'string' && /^[a-z0-9-]{1,40}$/.test(def.icon) ? def.icon : 'grid',
91
+ kind: def.kind,
92
+ size: WIDGET_SIZES.includes(def.size) ? def.size : 'm',
93
+ dataUrl: def.dataUrl,
94
+ refresh,
95
+ ...(href && {href}),
96
+ ...(typeof def.permission === 'string' && def.permission && {permission: def.permission}),
97
+ ...(def.admin === true && {admin: true}),
98
+ // Which plugin offers it, so disabling that plugin takes it away (unregisterWidgetsFrom).
99
+ ...(typeof def.owner === 'string' && def.owner && {owner: def.owner}),
100
+ source: String(def.source || source || '').slice(0, 60)
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Offer a widget (core, or a plugin from code - a manifest's `admin.widgets`
106
+ * goes through here too). A second widget with the same id replaces the first.
107
+ *
108
+ * @param {object} def
109
+ * @param {string} [source]
110
+ * @returns {boolean} whether it was accepted
111
+ */
112
+ export function registerWidget(def, source = '') {
113
+ const w = normaliseWidget(def, source);
114
+ if (!w) return false;
115
+ const at = _registered.findIndex(x => x.id === w.id);
116
+ if (at >= 0) _registered[at] = w; else _registered.push(w);
117
+ return true;
118
+ }
119
+
120
+ /** Take a plugin's widgets away (it was disabled). */
121
+ export function unregisterWidgetsFrom(owner) {
122
+ for (let i = _registered.length - 1; i >= 0; i--) {
123
+ if (_registered[i].owner === owner) _registered.splice(i, 1);
124
+ }
125
+ }
126
+
127
+ /** A sidebar entry with a badge, as a widget in the badge's own shape. */
128
+ export function widgetFromSidebarItem(entry) {
129
+ const item = entry?.item || entry;
130
+ if (!item || typeof item.countUrl !== 'string' || !item.url) return null;
131
+ return normaliseWidget({
132
+ id: `count:${item.url.replace(/^#\//, '') || 'home'}`,
133
+ title: item.text,
134
+ description: item.inventory ? `How many there are in ${item.text}.` : `What ${item.text} is waiting on, as its sidebar badge says.`,
135
+ icon: item.icon,
136
+ kind: 'count',
137
+ size: 's',
138
+ dataUrl: item.countUrl,
139
+ href: item.url,
140
+ permission: item.permission,
141
+ refresh: 60
142
+ }, item.source || '');
143
+ }
144
+
145
+ /**
146
+ * Every widget there is: core's, one per sidebar badge, then the registered
147
+ * ones (which win on a clash of id).
148
+ *
149
+ * @param {{sidebarItems?: object[]}} [opts] - injectable for tests
150
+ * @returns {object[]}
151
+ */
152
+ export function getWidgets({sidebarItems = getRegisteredSidebarItems()} = {}) {
153
+ const byId = new Map();
154
+ for (const w of CORE_WIDGETS) byId.set(w.id, normaliseWidget(w, 'Built in'));
155
+ for (const entry of sidebarItems) {
156
+ const w = widgetFromSidebarItem(entry);
157
+ if (w) byId.set(w.id, w);
158
+ }
159
+ for (const w of _registered) byId.set(w.id, w);
160
+ return [...byId.values()].filter(Boolean);
161
+ }
162
+
163
+ /** canAny() from the admin, server side: the resource or any of its actions. */
164
+ export function canAny(permissions, resource) {
165
+ if (!resource) return true;
166
+ if (!Array.isArray(permissions) || !permissions.length) return false;
167
+ if (permissions.includes(resource)) return true;
168
+ return ['read', 'create', 'update', 'delete'].some(a => permissions.includes(`${resource}.${a}`));
169
+ }
170
+
171
+ /**
172
+ * The widgets this user may put on their board: each one's permission, and
173
+ * `admin` widgets for admins only (they read the admin-only summary).
174
+ *
175
+ * @param {object[]} widgets
176
+ * @param {{permissions: string[], isAdmin: boolean}} who
177
+ */
178
+ export function widgetsFor(widgets, {permissions = [], isAdmin = false} = {}) {
179
+ return widgets.filter(w => (!w.admin || isAdmin) && canAny(permissions, w.permission));
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Layouts
184
+ // ---------------------------------------------------------------------------
185
+
186
+ export const MAX_WIDGETS = 40;
187
+
188
+ /**
189
+ * A stored or submitted layout, cleaned: known sizes, no duplicates, capped.
190
+ * Ids are kept even when no such widget exists right now - a Tool switched off
191
+ * for a moment should not cost someone their arrangement - and dropped at draw.
192
+ *
193
+ * @param {*} input - {widgets: [{id, size}]} or the array itself
194
+ * @returns {{version: number, widgets: Array<{id: string, size: string}>}}
195
+ */
196
+ export function normaliseLayout(input) {
197
+ const list = Array.isArray(input) ? input : (Array.isArray(input?.widgets) ? input.widgets : []);
198
+ const seen = new Set();
199
+ const widgets = [];
200
+ for (const w of list) {
201
+ const id = String(w?.id || '').trim();
202
+ if (!ID_RE.test(id) || seen.has(id)) continue;
203
+ seen.add(id);
204
+ // `collapsed`: folded down to its title bar on this person's board.
205
+ widgets.push({id, size: WIDGET_SIZES.includes(w.size) ? w.size : 'm', ...(w.collapsed === true && {collapsed: true})});
206
+ if (widgets.length >= MAX_WIDGETS) break;
207
+ }
208
+ return {version: 1, widgets};
209
+ }
210
+
211
+ /** What is wrong with a submitted layout, as messages ([] when it is fine). */
212
+ export function layoutProblems(input) {
213
+ const list = Array.isArray(input) ? input : input?.widgets;
214
+ if (!Array.isArray(list)) return ['widgets must be an array'];
215
+ if (list.length > MAX_WIDGETS) return [`at most ${MAX_WIDGETS} widgets`];
216
+ const out = [];
217
+ list.forEach((w, i) => {
218
+ if (!ID_RE.test(String(w?.id || ''))) out.push(`widgets[${i}].id is not a widget id`);
219
+ if (w?.size != null && !WIDGET_SIZES.includes(w.size)) out.push(`widgets[${i}].size must be one of ${WIDGET_SIZES.join(', ')}`);
220
+ });
221
+ return out;
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Switched on by a Pro Tool
226
+ // ---------------------------------------------------------------------------
227
+
228
+ const owners = new Set();
229
+ let sidebarRegistered = false;
230
+
231
+ /**
232
+ * A Pro Tool switches the board on (plugins.js hands it to each plugin as
233
+ * `hooks.enableDashboardPro`, bound to the plugin's name, tier and closedSource).
234
+ * Only a paid, closed-source plugin - one that loads only under a licence - may.
235
+ *
236
+ * @param {{owner: string, tier: string, closedSource: boolean}} who
237
+ * @returns {boolean}
238
+ */
239
+ export function enableDashboardPro({owner, tier, closedSource} = {}) {
240
+ if (typeof owner !== 'string' || !owner.trim()) return false;
241
+ if (tier !== 'paid' || closedSource !== true) return false;
242
+ // The board's own address in the sidebar (Overview), once: `#/` can belong to
243
+ // another Tool (the fleet manager's Dashboard), and My Dashboard still reaches it.
244
+ if (!sidebarRegistered) {
245
+ sidebarRegistered = true;
246
+ try {
247
+ registerSidebarItem({folder: 'overview', item: {source: 'Dashboard Pro', text: 'My Dashboard', url: '#/my-dashboard', icon: 'layout'}});
248
+ } catch { /* a sidebar without folders still has the route */ }
249
+ }
250
+ owners.add(owner.trim());
251
+ return true;
252
+ }
253
+
254
+ /** The Tool that kept it on was disabled; the last one gone switches the board off. */
255
+ export function disableDashboardPro(owner) {
256
+ owners.delete(String(owner || '').trim());
257
+ return owners.size > 0;
258
+ }
259
+
260
+ /** Whether this site has Dashboard Pro right now. */
261
+ export function isDashboardPro() {
262
+ return owners.size > 0;
263
+ }
264
+
265
+ /** Tests only: forget every owner and registration. */
266
+ export function _resetWidgetsForTests() {
267
+ owners.clear();
268
+ _registered.length = 0;
269
+ }
@@ -137,7 +137,7 @@
137
137
  </script>
138
138
 
139
139
  <!-- Site initialisation -->
140
- <script src="/public/js/site.js?v=20261009-hover-darken" type="module"></script>
140
+ <script src="/public/js/site.js?v=20261009-more-classes" type="module"></script>
141
141
  <script src="/public/js/collection-context.js?v=20260927-ctx" type="module"></script>
142
142
  {{ctxMenusModule}}
143
143