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.
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/app.js +1 -1
- package/admin/js/lib/context-menu-editor-arrange.js +1 -1
- package/admin/js/lib/dashboard-board-arrange.js +1 -0
- package/admin/js/lib/sidebar-frequent.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/site-settings-arrange.js +1 -1
- package/admin/js/lib/widget-render.js +1 -0
- package/admin/js/templates/context-menu-editor.html +5 -0
- package/admin/js/templates/my-dashboard.html +3 -0
- package/admin/js/templates/settings.html +15 -0
- package/admin/js/views/context-menu-editor.js +10 -5
- package/admin/js/views/dashboard-board.js +30 -0
- package/admin/js/views/dashboard.js +2 -2
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/my-dashboard.js +1 -0
- package/package.json +2 -2
- package/public/js/context-menus.js +1 -1
- package/public/js/site.js +1 -1
- package/server/routes/api/dashboard.js +85 -0
- package/server/routes/api/settings.js +10 -1
- package/server/routes/api/widgets.js +106 -0
- package/server/server.js +2 -0
- package/server/services/contextMenus.js +6 -0
- package/server/services/markdown.js +30 -10
- package/server/services/plugins.js +15 -0
- package/server/services/renderer.js +6 -4
- package/server/services/siteGitignore.js +2 -0
- package/server/services/widgets.js +269 -0
- package/server/templates/page.html +1 -1
|
@@ -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
|
-
|
|
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=
|
|
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=
|
|
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-
|
|
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
|
|