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.
Files changed (55) hide show
  1. package/CLAUDE.md +3 -1
  2. package/admin/js/app.js +2 -2
  3. package/admin/js/lib/actions-arrange.js +1 -0
  4. package/admin/js/lib/api-endpoints-arrange.js +1 -0
  5. package/admin/js/lib/blocks-arrange.js +1 -0
  6. package/admin/js/lib/collections-arrange.js +1 -0
  7. package/admin/js/lib/components-arrange.js +1 -0
  8. package/admin/js/lib/context-menus-arrange.js +1 -0
  9. package/admin/js/lib/forms-arrange.js +1 -0
  10. package/admin/js/lib/media-arrange.js +1 -0
  11. package/admin/js/lib/menus-arrange.js +1 -0
  12. package/admin/js/lib/pages-arrange.js +1 -0
  13. package/admin/js/lib/sidebar-renderer.js +3 -3
  14. package/admin/js/lib/theme-settings-arrange.js +1 -0
  15. package/admin/js/lib/views-arrange.js +1 -0
  16. package/admin/js/templates/actions-list.html +73 -14
  17. package/admin/js/templates/api-endpoints.html +63 -10
  18. package/admin/js/templates/blocks.html +76 -15
  19. package/admin/js/templates/collections.html +108 -52
  20. package/admin/js/templates/components.html +82 -15
  21. package/admin/js/templates/context-menus.html +55 -13
  22. package/admin/js/templates/forms.html +67 -14
  23. package/admin/js/templates/media.html +99 -13
  24. package/admin/js/templates/menus.html +57 -11
  25. package/admin/js/templates/pages.html +78 -23
  26. package/admin/js/templates/theme.html +164 -132
  27. package/admin/js/templates/views-list.html +63 -9
  28. package/admin/js/views/actions-list.js +37 -1
  29. package/admin/js/views/api-endpoints.js +11 -7
  30. package/admin/js/views/blocks.js +34 -5
  31. package/admin/js/views/collections.js +36 -1
  32. package/admin/js/views/components.js +27 -5
  33. package/admin/js/views/context-menus.js +10 -5
  34. package/admin/js/views/forms.js +57 -1
  35. package/admin/js/views/media.js +40 -1
  36. package/admin/js/views/menus.js +11 -5
  37. package/admin/js/views/pages.js +28 -8
  38. package/admin/js/views/theme.js +55 -1
  39. package/admin/js/views/views-list.js +11 -1
  40. package/package.json +1 -1
  41. package/server/routes/api/actions.js +64 -2
  42. package/server/routes/api/api-endpoints.js +41 -0
  43. package/server/routes/api/blocks.js +170 -8
  44. package/server/routes/api/collections.js +53 -1
  45. package/server/routes/api/components.js +46 -0
  46. package/server/routes/api/context-menus.js +8 -0
  47. package/server/routes/api/forms.js +123 -10
  48. package/server/routes/api/media.js +56 -1
  49. package/server/routes/api/menus.js +53 -6
  50. package/server/routes/api/pages.js +46 -0
  51. package/server/routes/api/theme.js +55 -0
  52. package/server/routes/api/views.js +27 -3
  53. package/server/server.js +16 -1
  54. package/server/services/contextMenus.js +41 -0
  55. package/server/services/views.js +38 -0
@@ -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
- return all.filter(v => canSeeArtefact(request.user, v));
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
@@ -528,7 +528,22 @@ registerSidebarItem({item: {source: 'Built in', text: 'Contacts', url: '#/contac
528
528
  // Manage projects lives in the menu (the Projects folder, sidebar-migration.js);
529
529
  // registering it here only adds its badge - the renderer merges a registered
530
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'}});
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}});
532
547
 
533
548
  await registerPlugins(app);
534
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
+ }
@@ -475,3 +475,41 @@ export async function executeView(slug, {page = 1, limit = 25, user = null} = {}
475
475
  ? executeMongoView(view, { page, limit, user })
476
476
  : executeFileView(view, { page, limit, user });
477
477
  }
478
+
479
+ /**
480
+ * The sidebar badge on Views (its countUrl, GET /api/views/_count): how many
481
+ * views, red when one reads from a collection that no longer exists - such a
482
+ * view errors on every page that shows it. Plain text: the popover sets it
483
+ * with textContent.
484
+ *
485
+ * @param {object[]} views
486
+ * @param {Set<string>} collectionSlugs - the collections that exist
487
+ * @returns {{count: number, label: string, tone: string, details: object}}
488
+ */
489
+ export function viewsBadge(views = [], collectionSlugs = new Set()) {
490
+ const broken = views.filter(v => v.pipeline?.source && !collectionSlugs.has(v.pipeline.source));
491
+ const pub = views.filter(v => v.access?.public).length;
492
+ const n = views.length;
493
+ const ordered = [...broken, ...views.filter(v => !broken.includes(v))];
494
+ return {
495
+ count: n,
496
+ label: `${n} view${n === 1 ? '' : 's'}${broken.length ? `, ${broken.length} reading a missing collection` : ''}`,
497
+ tone: broken.length ? 'danger' : 'info',
498
+ details: {
499
+ title: 'Views',
500
+ rows: [
501
+ {label: 'Views', value: String(n)},
502
+ {label: 'Public', value: String(pub)},
503
+ {label: 'Missing source', value: String(broken.length), ...(broken.length && {tone: 'danger'})}
504
+ ],
505
+ items: ordered.slice(0, 8).map(v => ({
506
+ text: String(v.title || v.slug),
507
+ meta: broken.includes(v)
508
+ ? `source "${v.pipeline.source}" is gone`
509
+ : [v.pipeline?.source, v.display?.mode || 'table', v.access?.public ? 'public' : ''].filter(Boolean).join(' · '),
510
+ ...(broken.includes(v) && {tone: 'danger'})
511
+ })),
512
+ empty: 'No views yet.'
513
+ }
514
+ };
515
+ }