domma-cms 0.92.1 → 0.94.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 (177) hide show
  1. package/CLAUDE.md +5 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/action-editor-arrange.js +1 -1
  5. package/admin/js/lib/api-tokens-arrange.js +2 -2
  6. package/admin/js/lib/block-editor-arrange.js +1 -1
  7. package/admin/js/lib/blocks-arrange.js +1 -1
  8. package/admin/js/lib/collection-entries-arrange.js +1 -1
  9. package/admin/js/lib/components-arrange.js +1 -1
  10. package/admin/js/lib/dashboard-arrange.js +1 -1
  11. package/admin/js/lib/dates.js +1 -0
  12. package/admin/js/lib/forms-arrange.js +1 -1
  13. package/admin/js/lib/media-arrange.js +1 -1
  14. package/admin/js/lib/notifications-arrange.js +1 -1
  15. package/admin/js/lib/pages-arrange.js +1 -1
  16. package/admin/js/lib/related.js +1 -1
  17. package/admin/js/lib/timeline-builder.js +2 -2
  18. package/admin/js/templates/action-editor.html +6 -5
  19. package/admin/js/templates/actions-list.html +1 -1
  20. package/admin/js/templates/contacts.html +1 -1
  21. package/admin/js/templates/docs/api-actions.html +86 -60
  22. package/admin/js/templates/docs/api-authentication.html +159 -123
  23. package/admin/js/templates/docs/api-builder.html +197 -0
  24. package/admin/js/templates/docs/api-collections.html +199 -259
  25. package/admin/js/templates/docs/api-external.html +225 -0
  26. package/admin/js/templates/docs/api-forms.html +268 -0
  27. package/admin/js/templates/docs/api-layouts.html +70 -45
  28. package/admin/js/templates/docs/api-media.html +57 -80
  29. package/admin/js/templates/docs/api-navigation.html +66 -22
  30. package/admin/js/templates/docs/api-pages.html +109 -129
  31. package/admin/js/templates/docs/api-plugins.html +123 -61
  32. package/admin/js/templates/docs/api-scaffold.html +185 -0
  33. package/admin/js/templates/docs/api-settings.html +72 -64
  34. package/admin/js/templates/docs/api-users.html +74 -107
  35. package/admin/js/templates/docs/api-views.html +68 -54
  36. package/admin/js/templates/docs/components-howto.html +20 -17
  37. package/admin/js/templates/docs/components-reference.html +13 -16
  38. package/admin/js/templates/docs/components-rules.html +7 -6
  39. package/admin/js/templates/docs/components-walkthrough.html +19 -19
  40. package/admin/js/templates/docs/tutorial-crud.html +71 -40
  41. package/admin/js/templates/docs/tutorial-forms.html +51 -35
  42. package/admin/js/templates/docs/tutorial-plugin.html +132 -56
  43. package/admin/js/templates/docs/usage-actions.html +61 -15
  44. package/admin/js/templates/docs/usage-collections.html +108 -0
  45. package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
  46. package/admin/js/templates/docs/usage-dconfig.html +0 -3
  47. package/admin/js/templates/docs/usage-editions.html +213 -0
  48. package/admin/js/templates/docs/usage-media.html +22 -6
  49. package/admin/js/templates/docs/usage-navigation.html +74 -18
  50. package/admin/js/templates/docs/usage-pages.html +60 -20
  51. package/admin/js/templates/docs/usage-plugins.html +89 -17
  52. package/admin/js/templates/docs/usage-shortcodes.html +123 -70
  53. package/admin/js/templates/docs/usage-site-settings.html +50 -18
  54. package/admin/js/templates/docs/usage-tools.html +73 -0
  55. package/admin/js/templates/docs/usage-users-roles.html +99 -20
  56. package/admin/js/templates/docs/usage-views.html +36 -19
  57. package/admin/js/templates/documentation.html +153 -32
  58. package/admin/js/templates/page-editor.html +0 -5
  59. package/admin/js/templates/plugin-guide.html +15 -0
  60. package/admin/js/templates/plugin-guides.html +21 -0
  61. package/admin/js/templates/pro-docs.html +53 -234
  62. package/admin/js/templates/tutorials.html +5 -4
  63. package/admin/js/views/actions-list.js +3 -3
  64. package/admin/js/views/analytics.js +5 -5
  65. package/admin/js/views/api-endpoint-editor.js +2 -2
  66. package/admin/js/views/block-editor.js +4 -4
  67. package/admin/js/views/blocks.js +4 -4
  68. package/admin/js/views/collection-editor.js +4 -4
  69. package/admin/js/views/collection-entries.js +7 -7
  70. package/admin/js/views/component-editor.js +2 -2
  71. package/admin/js/views/contacts.js +22 -20
  72. package/admin/js/views/context-menu-editor.js +5 -5
  73. package/admin/js/views/doc-pages.js +1 -1
  74. package/admin/js/views/form-editor.js +4 -4
  75. package/admin/js/views/form-submissions.js +2 -2
  76. package/admin/js/views/index.js +1 -1
  77. package/admin/js/views/media.js +3 -3
  78. package/admin/js/views/menu-editor.js +13 -13
  79. package/admin/js/views/menu-locations.js +2 -2
  80. package/admin/js/views/my-profile.js +1 -1
  81. package/admin/js/views/page-editor.js +8 -8
  82. package/admin/js/views/plugin-guides.js +5 -0
  83. package/admin/js/views/project-detail.js +2 -2
  84. package/admin/js/views/project-settings.js +1 -1
  85. package/admin/js/views/role-editor.js +4 -4
  86. package/admin/js/views/search.js +2 -2
  87. package/admin/js/views/seo.js +17 -17
  88. package/admin/js/views/settings.js +3 -3
  89. package/admin/js/views/theme.js +3 -3
  90. package/admin/js/views/user-editor.js +1 -1
  91. package/admin/js/views/users.js +2 -2
  92. package/admin/js/views/view-editor.js +1 -1
  93. package/bin/cli.js +13 -13
  94. package/bin/lib/node-version.js +29 -0
  95. package/package.json +1 -1
  96. package/plugins/_lib/admin/mail/compose-window.js +3 -2
  97. package/plugins/_lib/admin/mail/reader-view.js +7 -6
  98. package/plugins/_lib/admin/mail/scheduling.js +4 -2
  99. package/plugins/_lib/admin/mail/templates.js +4 -4
  100. package/plugins/_lib/admin/ui/dates.js +85 -0
  101. package/plugins/blog/CLAUDE.md +31 -22
  102. package/plugins/blog/admin/views/blog.js +3 -2
  103. package/plugins/blog/admin/views/comments.js +2 -1
  104. package/plugins/blog/admin/views/post-editor.js +4 -4
  105. package/plugins/blog/blocks/blog-card-row.html +1 -1
  106. package/plugins/blog/blocks/blog-card.html +2 -2
  107. package/plugins/blog/blocks/blog-post-classic.html +2 -2
  108. package/plugins/blog/blocks/blog-post-essay.html +2 -2
  109. package/plugins/blog/blocks/blog-post-feature.html +2 -2
  110. package/plugins/blog/blocks/blog-post-minimal.html +2 -2
  111. package/plugins/blog/blocks/blog-post-sidebar.html +2 -2
  112. package/plugins/blog/blocks/blog-post-split.html +2 -2
  113. package/plugins/blog/docs/guide.md +205 -0
  114. package/plugins/blog/lib/layouts.js +3 -3
  115. package/plugins/blog/lib/page.js +2 -1
  116. package/plugins/blog/plugin.js +3 -3
  117. package/plugins/blog/plugin.json +4 -4
  118. package/plugins/blog/tests/layouts.test.js +6 -0
  119. package/plugins/feedback/CLAUDE.md +22 -3
  120. package/plugins/feedback/admin/lib/kit.js +6 -7
  121. package/plugins/feedback/admin/views/feedback.js +79 -10
  122. package/plugins/feedback/admin/views/send.js +28 -6
  123. package/plugins/feedback/docs/guide.md +95 -0
  124. package/plugins/feedback/lib/receiver.js +9 -2
  125. package/plugins/feedback/lib/sender.js +3 -2
  126. package/plugins/feedback/plugin.js +54 -6
  127. package/plugins/feedback/plugin.json +4 -4
  128. package/plugins/feedback/tests/api.test.js +74 -2
  129. package/plugins/free-tier.lock.json +49 -44
  130. package/plugins/mail-reader/CLAUDE.md +33 -18
  131. package/plugins/mail-reader/docs/guide.md +147 -0
  132. package/plugins/mail-reader/plugin.json +1 -1
  133. package/plugins/security/CLAUDE.md +4 -1
  134. package/plugins/security/admin/views/security.js +5 -5
  135. package/plugins/security/docs/guide.md +170 -0
  136. package/plugins/security/plugin.js +2 -1
  137. package/plugins/security/plugin.json +2 -1
  138. package/plugins/shopping-cart/CLAUDE.md +7 -1
  139. package/plugins/shopping-cart/admin/lib/kit.js +5 -2
  140. package/plugins/shopping-cart/admin/views/orders.js +4 -4
  141. package/plugins/shopping-cart/admin/views/overview.js +2 -2
  142. package/plugins/shopping-cart/docs/guide.md +191 -0
  143. package/plugins/shopping-cart/lib/render.js +2 -1
  144. package/plugins/shopping-cart/plugin.json +3 -3
  145. package/public/js/collection-browser.js +2 -2
  146. package/public/js/site.js +1 -1
  147. package/scripts/gen-instance-secret.js +3 -1
  148. package/scripts/setup.js +3 -1
  149. package/server/middleware/auth.js +2 -1
  150. package/server/routes/api/actions.js +47 -27
  151. package/server/routes/api/blocks.js +2 -1
  152. package/server/routes/api/collections.js +16 -52
  153. package/server/routes/api/contacts.js +66 -3
  154. package/server/routes/api/documentation.js +42 -0
  155. package/server/routes/api/notifications.js +3 -2
  156. package/server/routes/api/users.js +10 -6
  157. package/server/server.js +16 -1
  158. package/server/services/actions.js +110 -34
  159. package/server/services/adapterRegistry.js +169 -16
  160. package/server/services/adapters/FileAdapter.js +25 -0
  161. package/server/services/adapters/MongoAdapter.js +23 -0
  162. package/server/services/collections.js +104 -1
  163. package/server/services/connectionManager.js +12 -0
  164. package/server/services/dates.js +81 -0
  165. package/server/services/docs.js +13 -2
  166. package/server/services/markdown.js +75 -26
  167. package/server/services/notification-sources.js +6 -5
  168. package/server/services/passwordReset.js +2 -1
  169. package/server/services/permissionRegistry.js +3 -2
  170. package/server/services/pluginGuides.js +255 -0
  171. package/server/services/pluginInstaller.js +54 -13
  172. package/server/services/plugins.js +29 -1
  173. package/server/services/presetCollections.js +31 -5
  174. package/server/services/renderer.js +2 -2
  175. package/server/services/sidebarBadges.js +3 -1
  176. package/server/services/tools.js +4 -2
  177. package/server/templates/page.html +2 -2
@@ -1,259 +1,78 @@
1
1
  <div class="view-header">
2
2
  <h1><span data-icon="zap"></span> Pro Documentation</h1>
3
+ <a href="#/documentation" class="btn btn-ghost btn-sm"><span data-icon="arrow-left"></span> All usage topics</a>
3
4
  </div>
4
5
 
5
6
  <div class="tabs" id="pro-docs-tabs">
6
7
  <div class="tab-list">
7
- <button class="tab-item active">Views</button>
8
+ <button class="tab-item active">What needs Pro</button>
9
+ <button class="tab-item">Views</button>
8
10
  <button class="tab-item">Actions</button>
9
- <button class="tab-item">CTA Shortcode</button>
10
11
  </div>
12
+
11
13
  <div class="tab-content">
12
14
 
13
- <!-- ── Views tab ─────────────────────────────────────── -->
15
+ <!-- What needs Pro -->
14
16
  <div class="tab-panel active">
15
-
16
- <div class="card card-collapsible mb-4">
17
- <div class="card-header" role="button" tabindex="0">
18
- <div class="card-header-content"><h2><span data-icon="eye"></span> What is a View?</h2></div>
19
- <span class="card-collapse-icon" data-icon="chevron-down"></span>
20
- </div>
21
- <div class="card-body docs-body">
22
- <p>A <strong>View</strong> is a saved MongoDB aggregation pipeline that runs against a collection and returns shaped results. Views are created under <strong>Data → Views</strong> and exposed via a REST endpoint that respects role-level and row-level access.</p>
23
- <p>Views require a MongoDB connection configured in <code>config/connections.json</code>.</p>
24
- </div>
25
- </div>
26
-
27
- <div class="card card-collapsible mb-4">
28
- <div class="card-header" role="button" tabindex="0">
29
- <div class="card-header-content"><h2><span data-icon="layers"></span> Allowed Pipeline Stages</h2></div>
30
- <span class="card-collapse-icon" data-icon="chevron-down"></span>
31
- </div>
17
+ <div class="card mb-4">
32
18
  <div class="card-body docs-body">
33
- <table class="table table-sm">
34
- <thead><tr><th>Stage</th><th>Purpose</th></tr></thead>
35
- <tbody>
36
- <tr><td><code>$match</code></td><td>Filter documents by field values</td></tr>
37
- <tr><td><code>$sort</code></td><td>Order results</td></tr>
38
- <tr><td><code>$project</code></td><td>Include, exclude, or rename fields</td></tr>
39
- <tr><td><code>$lookup</code></td><td>Join from another collection</td></tr>
40
- <tr><td><code>$unwind</code></td><td>Flatten an array field</td></tr>
41
- <tr><td><code>$addFields</code></td><td>Compute new fields</td></tr>
42
- <tr><td><code>$group</code></td><td>Aggregate (count, sum, etc.)</td></tr>
43
- <tr><td><code>$limit</code></td><td>Cap results</td></tr>
44
- <tr><td><code>$skip</code></td><td>Skip N results</td></tr>
45
- <tr><td><code>$count</code></td><td>Return a document count</td></tr>
46
- </tbody>
47
- </table>
48
- <p class="text-muted" style="margin-top:.75rem;">Blocked: <code>$out</code>, <code>$merge</code>, <code>$function</code>, <code>$accumulator</code>, <code>$graphLookup</code></p>
49
- <p>Collections are prefixed <code>cms_</code> in MongoDB - use this prefix in <code>$lookup</code> <code>from</code> values.</p>
19
+ <p>Pro, for the data features, means one thing: a working <strong>MongoDB connection</strong>
20
+ (the <strong>Database connections</strong> tab on Data &gt; Collections, stored in <code>config/connections.json</code>). With one, a site gets:</p>
21
+ <ul>
22
+ <li><strong>Actions</strong> - workflow buttons, transitions and <code>[cta]</code>. Without MongoDB,
23
+ Data &gt; Actions says so and nothing can be run.</li>
24
+ <li><strong>MongoDB storage</strong> for any collection, chosen on its Storage tab. Moving a collection
25
+ either way keeps every entry's id.</li>
26
+ <li><strong>Notes and Todo on MongoDB</strong>, as their collections declare. A site whose files
27
+ still hold notes or to-dos stays on files until the Storage move settles it.</li>
28
+ </ul>
29
+ <p>Everything else works without it: collections on files, <strong>Views</strong> (a built-in
30
+ evaluator runs the same pipeline stages), forms, <code>[collection]</code> and <code>[view]</code>
31
+ displays, the Collection Browser, the API and API Builder.</p>
32
+ <p>Pro plugins (SEO Pro, Analytics Pro, Security Pro, Blog Pro and others) are a separate matter: they come
33
+ from the Marketplace with a licence. See <a href="#/docs/usage/editions">Editions &amp; Licences</a> and
34
+ <a href="#/docs/plugins">Plugin guides</a>.</p>
50
35
  </div>
51
36
  </div>
37
+ </div>
52
38
 
53
- <div class="card card-collapsible mb-4">
54
- <div class="card-header" role="button" tabindex="0">
55
- <div class="card-header-content"><h2><span data-icon="code"></span> Example Pipeline</h2></div>
56
- <span class="card-collapse-icon" data-icon="chevron-down"></span>
57
- </div>
58
- <div class="card-body docs-body">
59
- <p>Match active entries and count related submissions:</p>
60
- <pre class="code-block"><code>[
61
- { "$match": { "data.isActive": true } },
62
- {
63
- "$lookup": {
64
- "from": "cms_feedback",
65
- "localField": "_id",
66
- "foreignField": "data.userId",
67
- "as": "submissions"
68
- }
69
- },
70
- { "$addFields": { "submissionCount": { "$size": "$submissions" } } },
71
- { "$project": { "data.name": 1, "data.email": 1, "submissionCount": 1 } },
72
- { "$sort": { "submissionCount": -1 } }
73
- ]</code></pre>
74
- </div>
75
- </div>
76
-
77
- <div class="card card-collapsible mb-4">
78
- <div class="card-header" role="button" tabindex="0">
79
- <div class="card-header-content"><h2><span data-icon="lock"></span> Access Control</h2></div>
80
- <span class="card-collapse-icon" data-icon="chevron-down"></span>
81
- </div>
82
- <div class="card-body docs-body">
83
- <table class="table table-sm">
84
- <thead><tr><th>Setting</th><th>Description</th></tr></thead>
85
- <tbody>
86
- <tr><td>Allowed Roles</td><td>Which roles can call <code>GET /api/views/:slug/public</code></td></tr>
87
- <tr><td>Public</td><td>No authentication required when checked</td></tr>
88
- <tr><td>Row-Level - Owner</td><td>User sees only entries they created</td></tr>
89
- <tr><td>Row-Level - Field Match</td><td>A specified entry field must match a user property</td></tr>
90
- </tbody>
91
- </table>
92
- </div>
93
- </div>
94
-
95
- </div><!-- /Views tab -->
96
-
97
- <!-- ── Actions tab ───────────────────────────────────── -->
39
+ <!-- Views -->
98
40
  <div class="tab-panel">
99
-
100
- <div class="card card-collapsible mb-4">
101
- <div class="card-header" role="button" tabindex="0">
102
- <div class="card-header-content"><h2><span data-icon="zap"></span> What is an Action?</h2></div>
103
- <span class="card-collapse-icon" data-icon="chevron-down"></span>
104
- </div>
105
- <div class="card-body docs-body">
106
- <p>An <strong>Action</strong> is a named sequence of steps that runs against a single collection entry. Actions are defined under <strong>Data → Actions</strong> and can be triggered from the entry list or via the <code>[cta]</code> shortcode on any public page.</p>
107
- <p>Actions require a MongoDB connection configured in <code>config/connections.json</code>.</p>
108
- <p>Steps run in order. If a step fails, execution stops and a partial result is returned with a <code>stepsCompleted</code> count.</p>
109
- </div>
110
- </div>
111
-
112
- <div class="card card-collapsible mb-4">
113
- <div class="card-header" role="button" tabindex="0">
114
- <div class="card-header-content"><h2><span data-icon="list"></span> Step Types</h2></div>
115
- <span class="card-collapse-icon" data-icon="chevron-down"></span>
116
- </div>
41
+ <div class="card mb-4">
117
42
  <div class="card-body docs-body">
118
- <table class="table table-sm">
119
- <thead><tr><th>Type</th><th>What it does</th></tr></thead>
120
- <tbody>
121
- <tr><td><code>updateField</code></td><td>Sets a field on the entry to a new value</td></tr>
122
- <tr><td><code>deleteEntry</code></td><td>Permanently deletes the target entry</td></tr>
123
- <tr><td><code>moveToCollection</code></td><td>Moves the entry to a different collection</td></tr>
124
- <tr><td><code>webhook</code></td><td>POSTs entry data to an external URL</td></tr>
125
- <tr><td><code>email</code></td><td>Sends an email via the configured SMTP server</td></tr>
126
- </tbody>
127
- </table>
43
+ <p>A <strong>View</strong> is a saved pipeline over a collection (<code>$match</code>,
44
+ <code>$lookup</code>, <code>$sort</code>, <code>$project</code>, <code>$unwind</code>,
45
+ <code>$addFields</code>, <code>$group</code>, <code>$count</code>, <code>$skip</code>,
46
+ <code>$limit</code>). Views do <strong>not</strong> need MongoDB: a MongoDB collection runs a native
47
+ aggregation, any other runs the built-in evaluator.</p>
48
+ <p>Who may read a view is set on its Access tab: public, any listed role and everyone more senior, or -
49
+ with no roles - the admin tier. A row-level rule (Owner or Field match) shows each user only their
50
+ results.</p>
51
+ <p>Full guide: <a href="#/docs/usage/views">Views</a> and the <a href="#/docs/api/views">Views API</a>.</p>
128
52
  </div>
129
53
  </div>
54
+ </div>
130
55
 
131
- <div class="card card-collapsible mb-4">
132
- <div class="card-header" role="button" tabindex="0">
133
- <div class="card-header-content"><h2><span data-icon="tag"></span> Template Variables</h2></div>
134
- <span class="card-collapse-icon" data-icon="chevron-down"></span>
135
- </div>
136
- <div class="card-body docs-body">
137
- <p>All step field values support <code>&#123;&#123;...&#125;&#125;</code> interpolation:</p>
138
- <table class="table table-sm">
139
- <thead><tr><th>Variable</th><th>Value</th></tr></thead>
140
- <tbody>
141
- <tr><td><code>&#123;&#123;entry.data.fieldName&#125;&#125;</code></td><td>Any field from the target entry</td></tr>
142
- <tr><td><code>&#123;&#123;entry.id&#125;&#125;</code></td><td>The entry's UUID</td></tr>
143
- <tr><td><code>&#123;&#123;now&#125;&#125;</code></td><td>Current ISO 8601 timestamp</td></tr>
144
- <tr><td><code>&#123;&#123;user.name&#125;&#125;</code></td><td>Name of the triggering user</td></tr>
145
- <tr><td><code>&#123;&#123;user.email&#125;&#125;</code></td><td>Email of the triggering user</td></tr>
146
- <tr><td><code>&#123;&#123;user.id&#125;&#125;</code></td><td>UUID of the triggering user</td></tr>
147
- <tr><td><code>&#123;&#123;env.CMS_PUBLIC_*&#125;&#125;</code></td><td>Public environment variables</td></tr>
148
- </tbody>
149
- </table>
150
- </div>
151
- </div>
152
-
153
- <div class="card card-collapsible mb-4">
154
- <div class="card-header" role="button" tabindex="0">
155
- <div class="card-header-content"><h2><span data-icon="code"></span> Step Examples</h2></div>
156
- <span class="card-collapse-icon" data-icon="chevron-down"></span>
157
- </div>
158
- <div class="card-body docs-body">
159
- <p><strong>updateField</strong> - mark an entry as approved:</p>
160
- <pre class="code-block"><code>{ "field": "status", "value": "approved" }</code></pre>
161
- <p><strong>webhook</strong> - notify an external service:</p>
162
- <pre class="code-block"><code>{
163
- "url": "https://hooks.example.com/notify",
164
- "headers": { "X-Secret": "{{env.CMS_PUBLIC_HOOK_SECRET}}" }
165
- }</code></pre>
166
- <p><strong>email</strong> - send confirmation to applicant:</p>
167
- <pre class="code-block"><code>{
168
- "to": "{{entry.data.email}}",
169
- "subject": "Your application was approved",
170
- "body": "Hi {{entry.data.name}}, your application has been approved."
171
- }</code></pre>
172
- </div>
173
- </div>
174
-
175
- </div><!-- /Actions tab -->
176
-
177
- <!-- ── CTA Shortcode tab ──────────────────────────────── -->
56
+ <!-- Actions -->
178
57
  <div class="tab-panel">
179
-
180
- <div class="card card-collapsible mb-4">
181
- <div class="card-header" role="button" tabindex="0">
182
- <div class="card-header-content"><h2><span data-icon="mouse-pointer"></span> What is the CTA shortcode?</h2></div>
183
- <span class="card-collapse-icon" data-icon="chevron-down"></span>
184
- </div>
185
- <div class="card-body docs-body">
186
- <p>The <code>[cta]</code> shortcode places an action-trigger button in any public page. Clicking it calls <code>POST /api/actions/:slug/public</code> with the entry ID, using the logged-in user's JWT.</p>
187
- <p>If the user is not logged in, a warning toast is shown instead.</p>
188
- </div>
189
- </div>
190
-
191
- <div class="card card-collapsible mb-4">
192
- <div class="card-header" role="button" tabindex="0">
193
- <div class="card-header-content"><h2><span data-icon="code"></span> Syntax &amp; Attributes</h2></div>
194
- <span class="card-collapse-icon" data-icon="chevron-down"></span>
195
- </div>
196
- <div class="card-body docs-body">
197
- <p><strong>Wrapping form:</strong></p>
198
- <pre class="code-block"><code>[cta action="slug" entry="entry-id" icon="check" confirm="Are you sure?"]Button label[/cta]</code></pre>
199
- <p><strong>Self-closing form:</strong></p>
200
- <pre class="code-block"><code>[cta action="slug" entry="entry-id" label="Button label" /]</code></pre>
201
- <table class="table table-sm" style="margin-top:1rem;">
202
- <thead><tr><th>Attribute</th><th>Required</th><th>Default</th><th>Description</th></tr></thead>
203
- <tbody>
204
- <tr><td><code>action</code></td><td>Yes</td><td>-</td><td>Action slug</td></tr>
205
- <tr><td><code>entry</code></td><td>Yes</td><td>-</td><td>Entry UUID to act on</td></tr>
206
- <tr><td><code>label</code></td><td>No</td><td><code>"Run"</code></td><td>Button text (self-closing only)</td></tr>
207
- <tr><td><code>style</code></td><td>No</td><td><code>"primary"</code></td><td>primary · secondary · ghost · danger</td></tr>
208
- <tr><td><code>icon</code></td><td>No</td><td>-</td><td>Domma icon name</td></tr>
209
- <tr><td><code>size</code></td><td>No</td><td>-</td><td>sm · md · lg</td></tr>
210
- <tr><td><code>confirm</code></td><td>No</td><td>-</td><td>Confirmation prompt before executing</td></tr>
211
- </tbody>
212
- </table>
213
- </div>
214
- </div>
215
-
216
- <div class="card card-collapsible mb-4">
217
- <div class="card-header" role="button" tabindex="0">
218
- <div class="card-header-content"><h2><span data-icon="grid"></span> Collection Integration</h2></div>
219
- <span class="card-collapse-icon" data-icon="chevron-down"></span>
220
- </div>
221
- <div class="card-body docs-body">
222
- <p>Add per-entry buttons to any <code>[collection]</code> shortcode:</p>
223
- <pre class="code-block"><code>[collection slug="applications" display="cards" title-field="name"
224
- cta="approve-application"
225
- cta-label="Approve"
226
- cta-icon="check"
227
- cta-style="primary"
228
- cta-confirm="Approve this application?" /]</code></pre>
229
- <table class="table table-sm" style="margin-top:1rem;">
230
- <thead><tr><th>Attribute</th><th>Default</th><th>Description</th></tr></thead>
231
- <tbody>
232
- <tr><td><code>cta</code></td><td>-</td><td>Action slug - enables per-entry buttons</td></tr>
233
- <tr><td><code>cta-label</code></td><td><code>"Run"</code></td><td>Button label</td></tr>
234
- <tr><td><code>cta-icon</code></td><td>-</td><td>Domma icon name</td></tr>
235
- <tr><td><code>cta-style</code></td><td><code>"primary"</code></td><td>Button variant</td></tr>
236
- <tr><td><code>cta-confirm</code></td><td>-</td><td>Confirmation prompt</td></tr>
237
- </tbody>
238
- </table>
239
- <p>All three display modes support CTA buttons: <strong>cards</strong> (card footer), <strong>list</strong> (inline), <strong>table</strong> (dedicated column).</p>
240
- </div>
241
- </div>
242
-
243
- <div class="card card-collapsible mb-4">
244
- <div class="card-header" role="button" tabindex="0">
245
- <div class="card-header-content"><h2><span data-icon="book-open"></span> Full Reference</h2></div>
246
- <span class="card-collapse-icon" data-icon="chevron-down"></span>
247
- </div>
58
+ <div class="card mb-4">
248
59
  <div class="card-body docs-body">
249
- <p>See the public documentation for live examples and the complete attribute reference:</p>
250
- <a href="/resources/pro/cta" target="_blank" class="btn btn-ghost btn-sm">
251
- <span data-icon="external-link"></span> CTA Shortcode Reference
252
- </a>
60
+ <p>An <strong>Action</strong> is a named sequence of steps (update a field, delete, move to or create in
61
+ another collection, webhook, email, notify) run against one entry. Actions need MongoDB.</p>
62
+ <ul>
63
+ <li>Any role the action lists is enough to run it, and everyone more senior. No roles means admins
64
+ only; a role the site does not have admits only the level-0 role.</li>
65
+ <li>A transition makes the action a workflow step, offered only in the states it starts from. An
66
+ interactive <code>[collection ... transitions]</code> shows the buttons that apply to each row,
67
+ including on a <code>scope="mine"</code> block. A move that no longer applies is refused with 409.</li>
68
+ <li>Steps are not rolled back if a later one fails - put deletes and emails last.</li>
69
+ </ul>
70
+ <p>Full guides: <a href="#/docs/usage/actions">Actions</a>,
71
+ <a href="#/docs/usage/cta-shortcode">CTA Shortcode</a> and the
72
+ <a href="#/docs/api/actions">Actions API</a>.</p>
253
73
  </div>
254
74
  </div>
75
+ </div>
255
76
 
256
- </div><!-- /CTA tab -->
257
-
258
- </div><!-- .tab-content -->
259
- </div><!-- .tabs -->
77
+ </div>
78
+ </div>
@@ -28,8 +28,8 @@
28
28
  <div class="card-body">
29
29
  <h3 style="margin-top:0;"><span data-icon="package"></span> Writing a Plugin</h3>
30
30
  <p class="text-muted" style="margin-bottom:0;">
31
- Extend the CMS with your own server routes, admin pages and page-injection snippets.
32
- The three required files, the Fastify entry point, and how to register and test.
31
+ Extend the CMS with your own server routes, admin pages and page-injection snippets:
32
+ the manifest, the Fastify entry point, switching it on and testing it.
33
33
  </p>
34
34
  </div>
35
35
  </a>
@@ -50,8 +50,9 @@
50
50
 
51
51
  <p class="text-muted" style="font-size:.9rem;">
52
52
  Looking for reusable custom elements? See <a href="#/docs/components">Documentation → Components</a>.
53
- For task-by-task usage, see <a href="#/documentation">Usage</a>; for the REST surface, see
54
- <a href="#/api-reference">API Reference</a>.
53
+ For task-by-task usage, see <a href="#/documentation">Documentation</a>; for the REST surface, see
54
+ <a href="#/api-reference">API Reference</a>; for installed Marketplace plugins, see
55
+ <a href="#/docs/plugins">Plugin guides</a>.
55
56
  </p>
56
57
 
57
58
  </div>
@@ -11,7 +11,7 @@ import{apiRequest as $}from"../api.js";import{addBannerAction as ge}from"../lib/
11
11
  <span class="acx-chip" title="Project" data-bind-hidden="!project">{{project}}</span>
12
12
  </span>
13
13
  <button type="button" class="acx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
14
- </div>`,C=u=>String(u??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Ce(u){const l=document.createElement("template");return l.innerHTML=u.trim(),l.content.firstElementChild}const o=be(ee);export const actionsListView={templateUrl:"/admin/js/templates/actions-list.html",async onMount(u){o.reset();const l=u.get(0).querySelector(".acx")||u.get(0),f=()=>ee.test(location.hash)&&l.isConnected,v=we(),i=M.observable([]),q=M.observable(0),U=M.observable(!0),L=M.observable(""),h=M.observable(!1),H=M.observable(""),b=M.observable(""),y=M.observable([]);o.add(M.effect(()=>{y.value=Ee(i.value,{query:H.value,collection:b.value}).map(xe)}));const P=M.computed(()=>Me(i.value)),ae=M.computed(()=>{const e=y.value.length,t=i.value.length;return e===t?`${t} action${t===1?"":"s"}`:`${e} of ${t}`}),ne=M.computed(()=>i.value.length?"No actions match.":"No actions yet - New action in the banner makes one."),se=M.computed(()=>$e(q.value,i.value.length,v)),w=e=>i.peek().find(t=>t.slug===e);async function A(){try{const e=await $("/actions/_count",{method:"GET"}).catch(()=>null);if(!f())return;if(e?.available===!1){h.value=!0;return}const t=await $("/actions",{method:"GET"});if(!f())return;const a=Array.isArray(t)?t:[];q.value=a.length,i.value=v?ye(a,v):a,h.value=!1,L.value=""}catch(e){if(!f())return;Se(e)?h.value=!0:L.value=`Could not load the actions: ${e.message||e}`}finally{U.value=!1}}const D=e=>{location.hash=`#/actions/edit/${encodeURIComponent(e)}`},oe=()=>{location.hash=v?`#/actions/new?project=${encodeURIComponent(v)}`:"#/actions/new"};async function le(e){if(await E.confirm(`Delete the action "${C(e.title||e.slug)}"? Its button disappears from the "${C(e.collection)}" entries. This cannot be undone.`))try{await $(`/actions/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast(`Deleted "${e.title||e.slug}".`,{type:"success"}),Z(te),await A()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function ie(e){try{await navigator.clipboard.writeText(e.slug),E.toast(`Copied "${e.slug}".`,{type:"success"})}catch{E.toast("The browser would not copy it - the slug is in the row.",{type:"warning"})}}let d=null;const O=new Set;function B(e,t=null){const a=w(e);if(!a){E.toast(`There is no action "${e}" here.`,{type:"warning"});return}const R={title:t?.title??`${a.title||a.slug} copy`,slug:t?.slug??""},g={title:M.observable(R.title),slug:M.observable(R.slug)},_=()=>({title:g.title.peek(),slug:g.slug.peek()}),de=JSON.stringify(R),J=()=>JSON.stringify(_())!==de||!!t,k=M.observable(!1),p=M.computed(()=>ke(a,{title:g.title.value,slug:g.slug.value},i.value.map(n=>n.slug))),W={...g,saving:k,titleErr:M.computed(()=>p.value.errors.title||""),slugErr:M.computed(()=>p.value.errors.slug||""),slugHint:M.computed(()=>p.value.errors.slug?"":`Will be created as "${p.value.payload.slug}".`),cannot:M.computed(()=>k.value||Object.keys(p.value.errors).length>0)},s=E.slideover({title:`Duplicate ${C(a.title||a.slug)}`,size:"sm",position:"right"}),m=Ce(`
14
+ </div>`,C=u=>String(u??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Ce(u){const l=document.createElement("template");return l.innerHTML=u.trim(),l.content.firstElementChild}const o=be(ee);export const actionsListView={templateUrl:"/admin/js/templates/actions-list.html",async onMount(u){o.reset();const l=u.get(0).querySelector(".acx")||u.get(0),f=()=>ee.test(location.hash)&&l.isConnected,v=we(),c=M.observable([]),q=M.observable(0),P=M.observable(!0),A=M.observable(""),h=M.observable(!1),U=M.observable(""),b=M.observable(""),y=M.observable([]);o.add(M.effect(()=>{y.value=Ee(c.value,{query:U.value,collection:b.value}).map(xe)}));const H=M.computed(()=>Me(c.value)),ae=M.computed(()=>{const e=y.value.length,t=c.value.length;return e===t?`${t} action${t===1?"":"s"}`:`${e} of ${t}`}),ne=M.computed(()=>c.value.length?"No actions match.":"No actions yet - New action in the banner makes one."),se=M.computed(()=>$e(q.value,c.value.length,v)),w=e=>c.peek().find(t=>t.slug===e);async function L(){try{const e=await $("/actions/_count",{method:"GET"}).catch(()=>null);if(!f())return;if(e?.available===!1){h.value=!0;return}const t=await $("/actions",{method:"GET"});if(!f())return;const a=Array.isArray(t)?t:[];q.value=a.length,c.value=v?ye(a,v):a,h.value=!1,A.value=""}catch(e){if(!f())return;Se(e)?h.value=!0:A.value=`Could not load the actions: ${e.message||e}`}finally{P.value=!1}}const D=e=>{location.hash=`#/actions/edit/${encodeURIComponent(e)}`},oe=()=>{location.hash=v?`#/actions/new?project=${encodeURIComponent(v)}`:"#/actions/new"};async function le(e){if(await E.confirm(`Delete the action "${C(e.title||e.slug)}"? Its button disappears from the "${C(e.collection)}" entries. This cannot be undone.`))try{await $(`/actions/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast(`Deleted "${e.title||e.slug}".`,{type:"success"}),Z(te),await L()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function ce(e){try{await navigator.clipboard.writeText(e.slug),E.toast(`Copied "${e.slug}".`,{type:"success"})}catch{E.toast("The browser would not copy it - the slug is in the row.",{type:"warning"})}}let d=null;const O=new Set;function B(e,t=null){const a=w(e);if(!a){E.toast(`There is no action "${e}" here.`,{type:"warning"});return}const R={title:t?.title??`${a.title||a.slug} copy`,slug:t?.slug??""},g={title:M.observable(R.title),slug:M.observable(R.slug)},_=()=>({title:g.title.peek(),slug:g.slug.peek()}),de=JSON.stringify(R),J=()=>JSON.stringify(_())!==de||!!t,k=M.observable(!1),p=M.computed(()=>ke(a,{title:g.title.value,slug:g.slug.value},c.value.map(n=>n.slug))),W={...g,saving:k,titleErr:M.computed(()=>p.value.errors.title||""),slugErr:M.computed(()=>p.value.errors.slug||""),slugHint:M.computed(()=>p.value.errors.slug?"":`Will be created as "${p.value.payload.slug}".`),cannot:M.computed(()=>k.value||Object.keys(p.value.errors).length>0)},s=E.slideover({title:`Duplicate ${C(a.title||a.slug)}`,size:"sm",position:"right"}),m=Ce(`
15
15
  <div class="acx-form">
16
16
  <p class="acx-note">The copy runs on the same collection, with the same button, steps, roles and
17
17
  transition. Edit it afterwards to make it different.</p>
@@ -22,7 +22,7 @@ import{apiRequest as $}from"../api.js";import{addBannerAction as ge}from"../lib/
22
22
  </div>
23
23
  <div>
24
24
  <label class="form-label" for="acx-f-slug">Slug
25
- <span data-help="The action's id - in its address and in the [transitions] shortcode. Leave it empty to make one from the title."></span></label>
25
+ <span data-help="The action's id - in its address and in API calls. Leave it empty to make one from the title."></span></label>
26
26
  <input id="acx-f-slug" type="text" class="form-input font-mono" maxlength="100" placeholder="from the title" data-model="slug.value">
27
27
  <small class="acx-err" data-bind-hidden="!slugErr.value" data-bind-text="slugErr.value"></small>
28
28
  <small class="acx-hint" data-bind-hidden="!slugHint.value" data-bind-text="slugHint.value"></small>
@@ -34,4 +34,4 @@ import{apiRequest as $}from"../api.js";import{addBannerAction as ge}from"../lib/
34
34
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
35
35
  <span data-icon="copy" data-icon-size="13"></span> Duplicate</button>
36
36
  </div>
37
- </div>`);let j=!1;async function G(){if(!W.cannot.peek()){k.value=!0;try{const n=await $("/actions",{method:"POST",body:JSON.stringify(p.peek().payload)});E.toast(`Made "${n?.title||p.peek().payload.title}".`,{type:"success"}),j=!0,s.close(),Z(te),f()&&await A()}catch(n){E.toast(`Not duplicated: ${n.message||n}`,{type:"error"})}finally{k.value=!1}}}s.element.appendChild(m);const pe=Q(m),fe=M.applyBindings(W,m,{methods:{save:G,cancel:()=>s.close()}});m.addEventListener("keydown",n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),G())});const V={source:a.slug,dirty:J,values:_};d=V,O.add(s),he(s,()=>{fe.dispose(),pe(),O.delete(s),d===V&&(d=null)});const me=s.close.bind(s);let N=!1;s.close=async n=>{if(!j&&n!==!0&&J()){if(N)return;N=!0;const ve=await E.confirm("Discard the changes you have not saved?");if(N=!1,!ve)return}return j=!0,me()},I.scan(m),s.open();const Y=m.querySelector("#acx-f-title");Y?.focus(),Y?.select()}function ce(e){const t=w(e);return t?[{header:t.title||t.slug},{label:"Edit",icon:"edit",action:()=>D(t.slug)},{label:"Duplicate\u2026",icon:"copy",action:()=>B(t.slug)},{type:"divider"},{label:`Open ${t.collection||"collection"} entries`,icon:"database",disabled:!t.collection,action:()=>{location.hash=`#/collections/${encodeURIComponent(t.collection)}/entries`}},{label:"Copy slug",icon:"clipboard",action:()=>ie(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>le(t)}]:[]}const c=l.querySelector(".acx-list");c.innerHTML=Te;const re=M.applyBindings({loading:U,loadError:L,needsMongo:h,query:H,collection:b,collections:P,rows:y,countText:ae,emptyText:ne,notice:se},l);o.add(()=>re.dispose()),o.add(Q(l,{position:"bottom"}));const x=l.querySelector(".acx-filter");o.add(M.effect(()=>{const e=P.value,t=b.peek();x.replaceChildren(new Option("All collections",""),...e.map(a=>new Option(a,a))),x.value=e.includes(t)?t:"",x.value!==t&&(b.value=x.value)})),o.add(M.effect(()=>{y.value,requestAnimationFrame(()=>{c.isConnected&&I.scan(c)})}));const ue=E.contextMenu(c,{cursor:"glyph",match:".acx-row",inherit:!1,density:"compact",items:e=>ce(e.dataset.slug)});o.add(()=>ue?.destroy?.());const F=e=>{const t=e.target.closest(".acx-row");if(!t)return;const a=e.target.closest(".acx-kebab");if(a){X(a);return}D(t.dataset.slug)},K=e=>{const t=e.target.closest(".acx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),X(t)):e.key==="Enter"&&e.target===t&&D(t.dataset.slug))};c.addEventListener("click",F),c.addEventListener("keydown",K),o.add(()=>{c.removeEventListener("click",F),c.removeEventListener("keydown",K)}),ge(u,{id:"actions-new",icon:"plus",title:"New action",onClick:oe});const z=e=>{d?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",z),o.add(()=>window.removeEventListener("beforeunload",z)),o.add(()=>{if(d?.dirty())try{S.set(T,{source:d.source,values:d.values(),at:new Date().toISOString()})}catch{}[...O].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await A(),!f())return;let r=null;try{r=S.get(T)}catch{}if(r?.values&&r.source){try{S.remove?S.remove(T):S.set(T,null)}catch{}w(r.source)&&await E.confirm(`You left a copy of "${C(w(r.source).title||r.source)}" unfinished. Pick up where you left off?`)&&f()&&B(r.source,r.values)}}};
37
+ </div>`);let j=!1;async function G(){if(!W.cannot.peek()){k.value=!0;try{const n=await $("/actions",{method:"POST",body:JSON.stringify(p.peek().payload)});E.toast(`Made "${n?.title||p.peek().payload.title}".`,{type:"success"}),j=!0,s.close(),Z(te),f()&&await L()}catch(n){E.toast(`Not duplicated: ${n.message||n}`,{type:"error"})}finally{k.value=!1}}}s.element.appendChild(m);const pe=Q(m),fe=M.applyBindings(W,m,{methods:{save:G,cancel:()=>s.close()}});m.addEventListener("keydown",n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),G())});const V={source:a.slug,dirty:J,values:_};d=V,O.add(s),he(s,()=>{fe.dispose(),pe(),O.delete(s),d===V&&(d=null)});const me=s.close.bind(s);let N=!1;s.close=async n=>{if(!j&&n!==!0&&J()){if(N)return;N=!0;const ve=await E.confirm("Discard the changes you have not saved?");if(N=!1,!ve)return}return j=!0,me()},I.scan(m),s.open();const Y=m.querySelector("#acx-f-title");Y?.focus(),Y?.select()}function ie(e){const t=w(e);return t?[{header:t.title||t.slug},{label:"Edit",icon:"edit",action:()=>D(t.slug)},{label:"Duplicate\u2026",icon:"copy",action:()=>B(t.slug)},{type:"divider"},{label:`Open ${t.collection||"collection"} entries`,icon:"database",disabled:!t.collection,action:()=>{location.hash=`#/collections/${encodeURIComponent(t.collection)}/entries`}},{label:"Copy slug",icon:"clipboard",action:()=>ce(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>le(t)}]:[]}const i=l.querySelector(".acx-list");i.innerHTML=Te;const re=M.applyBindings({loading:P,loadError:A,needsMongo:h,query:U,collection:b,collections:H,rows:y,countText:ae,emptyText:ne,notice:se},l);o.add(()=>re.dispose()),o.add(Q(l,{position:"bottom"}));const x=l.querySelector(".acx-filter");o.add(M.effect(()=>{const e=H.value,t=b.peek();x.replaceChildren(new Option("All collections",""),...e.map(a=>new Option(a,a))),x.value=e.includes(t)?t:"",x.value!==t&&(b.value=x.value)})),o.add(M.effect(()=>{y.value,requestAnimationFrame(()=>{i.isConnected&&I.scan(i)})}));const ue=E.contextMenu(i,{cursor:"glyph",match:".acx-row",inherit:!1,density:"compact",items:e=>ie(e.dataset.slug)});o.add(()=>ue?.destroy?.());const F=e=>{const t=e.target.closest(".acx-row");if(!t)return;const a=e.target.closest(".acx-kebab");if(a){X(a);return}D(t.dataset.slug)},K=e=>{const t=e.target.closest(".acx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),X(t)):e.key==="Enter"&&e.target===t&&D(t.dataset.slug))};i.addEventListener("click",F),i.addEventListener("keydown",K),o.add(()=>{i.removeEventListener("click",F),i.removeEventListener("keydown",K)}),ge(u,{id:"actions-new",icon:"plus",title:"New action",onClick:oe});const z=e=>{d?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",z),o.add(()=>window.removeEventListener("beforeunload",z)),o.add(()=>{if(d?.dirty())try{S.set(T,{source:d.source,values:d.values(),at:new Date().toISOString()})}catch{}[...O].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await L(),!f())return;let r=null;try{r=S.get(T)}catch{}if(r?.values&&r.source){try{S.remove?S.remove(T):S.set(T,null)}catch{}w(r.source)&&await E.confirm(`You left a copy of "${C(w(r.source).title||r.source)}" unfinished. Pick up where you left off?`)&&f()&&B(r.source,r.values)}}};
@@ -1,4 +1,4 @@
1
- import{apiRequest as v,getToken as Z,refreshAccessToken as Q}from"../api.js";import{addBannerAction as X}from"../lib/plugin-chrome.js";import{helpPopovers as V}from"../lib/help-popover.js";import{onceClosed as ee,openMenuAt as te,settingsStore as ae,sidebarCount as se,viewLifetime as ne}from"../lib/tool-kit.js";import{DEFAULT_SETTINGS as oe,journeyRows as le,kpis as ie,LIMITS as re,normaliseSettings as O,RANGE_LABEL as B,RANGES as ce,seriesRange as de,topRows as pe}from"../lib/analytics-shape.js";const ue="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js",me=3e4,q=ae("analytics.settings",O),ve=`
1
+ import{apiRequest as v,getToken as K,refreshAccessToken as Z}from"../api.js";import{addBannerAction as Q}from"../lib/plugin-chrome.js";import{helpPopovers as V}from"../lib/help-popover.js";import{onceClosed as X,openMenuAt as ee,settingsStore as te,sidebarCount as ae,viewLifetime as se}from"../lib/tool-kit.js";import{DEFAULT_SETTINGS as ne,journeyRows as oe,kpis as le,LIMITS as ie,normaliseSettings as O,RANGE_LABEL as D,RANGES as re,seriesRange as ce,topRows as de}from"../lib/analytics-shape.js";import{fmtDate as pe}from"../lib/dates.js";const ue="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js",me=3e4,B=te("analytics.settings",O),ve=`
2
2
  <div class="an-row" data-url="{{url}}">
3
3
  <span class="an-bar-fill" style="width: {{bar}}"></span>
4
4
  <span class="an-rank">{{rank}}</span>
@@ -18,12 +18,12 @@ import{apiRequest as v,getToken as Z,refreshAccessToken as Q}from"../api.js";imp
18
18
  <span class="an-num" title="Views per hour today, against the usual {{baseline}}">{{hits}}/h</span>
19
19
  <span class="an-share">\xD7{{ratio}}</span>
20
20
  <button type="button" class="an-kebab" aria-label="Page actions" title="Actions">\u22EE</button>
21
- </div>`;function he(){return window.Chart?Promise.resolve():new Promise((r,s)=>{const t=document.createElement("script");t.src=ue,t.async=!0,t.onload=()=>r(),t.onerror=()=>s(new Error("chart.js load failed")),document.head.appendChild(t)})}function fe(r){const s=document.createElement("template");return s.innerHTML=r.trim(),s.content.firstElementChild}async function ye(r){const s=b=>fetch("/api/analytics/export.csv?range="+encodeURIComponent(r),{headers:b?{Authorization:"Bearer "+b}:{}});let t=await s(Z());if(t.status===401&&(t=await s(await Q())),!t.ok)throw new Error("export failed");const c=await t.blob(),d=(t.headers.get("Content-Disposition")||"").match(/filename="?([^"]+)"?/),u=URL.createObjectURL(c),i=document.createElement("a");i.href=u,i.download=d?d[1]:"analytics.csv",document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(u)}const l=ne(/^#\/analytics\b/);export const analyticsView={templateUrl:"/admin/js/templates/analytics.html",async onMount(r){l.reset();const s=r.get(0),t=M.observable(q.read()),c=M.observable(t.peek().range),d=M.observable("pages"),u=M.observable(""),i=M.observable(null),b=M.observable([]),y=M.observable(null),S=M.observable([]),g=M.observable(null),x=M.observable([]),j=M.observable("");l.add(M.effect(()=>{const e=i.value,{rows:a,matched:n}=pe(e?.top,{filter:u.value,limit:t.value.limit,total:e?.total});x.value=a,j.value=e?e.top.length?n?n>a.length?`Showing ${a.length} of ${n} pages - the cog changes how many.`:"":"No pages match that filter.":"No page views recorded in this range yet.":"Loading\u2026"}));const P=M.observable([]),U=M.observable([]),$=M.observable([]);l.add(M.effect(()=>{const e=le(y.value);P.value=e.entry,U.value=e.exit,$.value=e.paths}));const G={range:c,section:d,filter:u,spikes:S,pageRows:x,pagesNote:j,entryRows:P,exitRows:U,pathRows:$,kpi:M.computed(()=>ie(i.value,y.value)),isAllTime:M.computed(()=>c.value==="all"),sessionsCount:M.computed(()=>y.value?.totalSessions??0),liveText:M.computed(()=>g.value===null?"Active now: -":`${g.value} active now`),liveClass:M.computed(()=>g.value?"is-on":""),liveTitle:M.computed(()=>t.value.live?"Refreshes every 30 seconds":"Live refresh is off - see settings")};l.add(M.effect(()=>q.write(t.value)));const W=(e,a)=>{t.value=O({...t.peek(),[e]:a})};let k=0;async function w(){const e=c.peek(),a=++k;try{const[n,p,m,f]=await Promise.all([v("/analytics/stats?range="+encodeURIComponent(e)),v("/analytics/series?range="+encodeURIComponent(de(e))),v("/analytics/journeys?range="+encodeURIComponent(e)),v("/analytics/spikes")]);if(a!==k)return;i.value=n,b.value=p?.series??[],y.value=m,S.value=(f?.items??[]).map(o=>({...o,key:o.url}))}catch{a===k&&E.toast("Could not load analytics.",{type:"error"})}}async function C(){try{g.value=(await v("/analytics/realtime"))?.activeSessions??0,se("#/analytics")}catch{}}let _=!0;l.add(M.effect(()=>{if(c.value,_){_=!1;return}w()}));let L=null;l.add(M.effect(()=>{clearInterval(L),L=t.value.live?setInterval(()=>{document.hidden||C()},me):null})),l.add(()=>clearInterval(L));const H=s.querySelector(".an-chart");let R=null;l.add(()=>{R?.destroy(),R=null}),l.add(M.effect(()=>{const e=b.value,a=t.value.chart;if(!window.Chart||!H.isConnected)return;const n=getComputedStyle(document.documentElement),p=n.getPropertyValue("--dm-primary").trim()||"rgb(99, 102, 241)",m=n.getPropertyValue("--dm-text-muted").trim()||"#888",f=`color-mix(in srgb, ${n.getPropertyValue("--dm-border").trim()||"#888"} 45%, transparent)`;R?.destroy(),R=new window.Chart(H,{type:a,data:{labels:e.map(o=>D(o.date).format("D MMM")),datasets:[{label:"Page views",data:e.map(o=>o.hits),borderColor:p,backgroundColor:a==="bar"?p:`color-mix(in srgb, ${p} 18%, transparent)`,fill:a==="line",tension:.3,borderWidth:2,pointRadius:e.length>40?0:2,borderRadius:3}]},options:{responsive:!0,maintainAspectRatio:!1,animation:!1,plugins:{legend:{display:!1}},scales:{y:{beginAtZero:!0,ticks:{precision:0,color:m},grid:{color:f}},x:{ticks:{maxRotation:0,autoSkip:!0,maxTicksLimit:10,color:m},grid:{display:!1}}}}})}));function J(){const e=E.slideover({title:"Analytics settings",size:"sm",position:"right"}),a=fe(`
21
+ </div>`;function he(){return window.Chart?Promise.resolve():new Promise((r,s)=>{const t=document.createElement("script");t.src=ue,t.async=!0,t.onload=()=>r(),t.onerror=()=>s(new Error("chart.js load failed")),document.head.appendChild(t)})}function fe(r){const s=document.createElement("template");return s.innerHTML=r.trim(),s.content.firstElementChild}async function ye(r){const s=b=>fetch("/api/analytics/export.csv?range="+encodeURIComponent(r),{headers:b?{Authorization:"Bearer "+b}:{}});let t=await s(K());if(t.status===401&&(t=await s(await Z())),!t.ok)throw new Error("export failed");const c=await t.blob(),d=(t.headers.get("Content-Disposition")||"").match(/filename="?([^"]+)"?/),u=URL.createObjectURL(c),i=document.createElement("a");i.href=u,i.download=d?d[1]:"analytics.csv",document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(u)}const l=se(/^#\/analytics\b/);export const analyticsView={templateUrl:"/admin/js/templates/analytics.html",async onMount(r){l.reset();const s=r.get(0),t=M.observable(B.read()),c=M.observable(t.peek().range),d=M.observable("pages"),u=M.observable(""),i=M.observable(null),b=M.observable([]),y=M.observable(null),S=M.observable([]),g=M.observable(null),x=M.observable([]),j=M.observable("");l.add(M.effect(()=>{const e=i.value,{rows:a,matched:n}=de(e?.top,{filter:u.value,limit:t.value.limit,total:e?.total});x.value=a,j.value=e?e.top.length?n?n>a.length?`Showing ${a.length} of ${n} pages - the cog changes how many.`:"":"No pages match that filter.":"No page views recorded in this range yet.":"Loading\u2026"}));const P=M.observable([]),U=M.observable([]),$=M.observable([]);l.add(M.effect(()=>{const e=oe(y.value);P.value=e.entry,U.value=e.exit,$.value=e.paths}));const q={range:c,section:d,filter:u,spikes:S,pageRows:x,pagesNote:j,entryRows:P,exitRows:U,pathRows:$,kpi:M.computed(()=>le(i.value,y.value)),isAllTime:M.computed(()=>c.value==="all"),sessionsCount:M.computed(()=>y.value?.totalSessions??0),liveText:M.computed(()=>g.value===null?"Active now: -":`${g.value} active now`),liveClass:M.computed(()=>g.value?"is-on":""),liveTitle:M.computed(()=>t.value.live?"Refreshes every 30 seconds":"Live refresh is off - see settings")};l.add(M.effect(()=>B.write(t.value)));const G=(e,a)=>{t.value=O({...t.peek(),[e]:a})};let k=0;async function w(){const e=c.peek(),a=++k;try{const[n,p,m,f]=await Promise.all([v("/analytics/stats?range="+encodeURIComponent(e)),v("/analytics/series?range="+encodeURIComponent(ce(e))),v("/analytics/journeys?range="+encodeURIComponent(e)),v("/analytics/spikes")]);if(a!==k)return;i.value=n,b.value=p?.series??[],y.value=m,S.value=(f?.items??[]).map(o=>({...o,key:o.url}))}catch{a===k&&E.toast("Could not load analytics.",{type:"error"})}}async function C(){try{g.value=(await v("/analytics/realtime"))?.activeSessions??0,ae("#/analytics")}catch{}}let _=!0;l.add(M.effect(()=>{if(c.value,_){_=!1;return}w()}));let L=null;l.add(M.effect(()=>{clearInterval(L),L=t.value.live?setInterval(()=>{document.hidden||C()},me):null})),l.add(()=>clearInterval(L));const H=s.querySelector(".an-chart");let R=null;l.add(()=>{R?.destroy(),R=null}),l.add(M.effect(()=>{const e=b.value,a=t.value.chart;if(!window.Chart||!H.isConnected)return;const n=getComputedStyle(document.documentElement),p=n.getPropertyValue("--dm-primary").trim()||"rgb(99, 102, 241)",m=n.getPropertyValue("--dm-text-muted").trim()||"#888",f=`color-mix(in srgb, ${n.getPropertyValue("--dm-border").trim()||"#888"} 45%, transparent)`;R?.destroy(),R=new window.Chart(H,{type:a,data:{labels:e.map(o=>pe(o.date).slice(0,5)),datasets:[{label:"Page views",data:e.map(o=>o.hits),borderColor:p,backgroundColor:a==="bar"?p:`color-mix(in srgb, ${p} 18%, transparent)`,fill:a==="line",tension:.3,borderWidth:2,pointRadius:e.length>40?0:2,borderRadius:3}]},options:{responsive:!0,maintainAspectRatio:!1,animation:!1,plugins:{legend:{display:!1}},scales:{y:{beginAtZero:!0,ticks:{precision:0,color:m},grid:{color:f}},x:{ticks:{maxRotation:0,autoSkip:!0,maxTicksLimit:10,color:m},grid:{display:!1}}}}})}));function W(){const e=E.slideover({title:"Analytics settings",size:"sm",position:"right"}),a=fe(`
22
22
  <div class="an-panel-form">
23
23
  <div>
24
24
  <label class="form-label" for="an-set-range">Open on</label>
25
25
  <select id="an-set-range" class="form-input" data-model="range.value">
26
- ${ce.map(o=>`<option value="${o}">${B[o]}</option>`).join("")}
26
+ ${re.map(o=>`<option value="${o}">${D[o]}</option>`).join("")}
27
27
  </select>
28
28
  </div>
29
29
  <div>
@@ -31,7 +31,7 @@ import{apiRequest as v,getToken as Z,refreshAccessToken as Q}from"../api.js";imp
31
31
  <span data-help="How many pages the Top pages list shows. The filter still searches all of them, and the CSV export always has every page."></span>
32
32
  </label>
33
33
  <select id="an-set-limit" class="form-input" data-model="limit.value">
34
- ${re.map(o=>`<option value="${o}">${o||"All"}</option>`).join("")}
34
+ ${ie.map(o=>`<option value="${o}">${o||"All"}</option>`).join("")}
35
35
  </select>
36
36
  </div>
37
37
  <div>
@@ -59,4 +59,4 @@ import{apiRequest as v,getToken as Z,refreshAccessToken as Q}from"../api.js";imp
59
59
  <span class="spacer"></span>
60
60
  <button type="button" class="btn btn-sm btn-primary" data-on-click="done">Done</button>
61
61
  </div>
62
- </div>`),n=(o,K=T=>T)=>M.computed({read:()=>t.value[o],write:T=>W(o,K(T))}),p={range:n("range"),limit:n("limit",Number),chart:n("chart"),live:n("live"),confirmReset:n("confirmReset")};e.element.appendChild(a);const m=V(a),f=M.applyBindings(p,a,{methods:{done:()=>e.close(),reset:()=>{t.value={...oe}}}});ee(e,()=>{f.dispose(),m()}),e.open()}X(r,{id:"analytics-settings",icon:"settings",title:"Analytics settings",onClick:J});const h=[...s.querySelectorAll(".an-list")];h[0].innerHTML=ve,h[1].innerHTML=A,h[2].innerHTML=A,h[3].innerHTML=A,h[4].innerHTML=be,l.add(V(s,{position:"bottom"}));const z=M.applyBindings(G,s,{methods:{refresh:()=>{w(),C()},showPages:()=>{d.value="pages"},showJourneys:()=>{d.value="journeys"},showSpikes:()=>{d.value="spikes"}}});l.add(()=>z.dispose());const N=e=>new URL(e,location.origin).href,Y=E.contextMenu(s,{cursor:"glyph",match:".an-row",inherit:!1,density:"compact",items:e=>{const a=e.dataset.url;return[{label:"Open on the site",icon:"external-link",action:()=>window.open(N(a),"_blank","noopener")},{label:"Copy address",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(N(a)),E.toast("Address copied",{type:"success"})}catch{E.toast("The browser would not allow copying",{type:"error"})}}},{label:"Show only this page",icon:"filter",action:()=>{d.value="pages",u.value=a}}]}});l.add(()=>Y?.destroy?.());const F=E.contextMenu(s.querySelector(".an-bar-end"),{cursor:"glyph",match:".an-more",inherit:!1,density:"compact",items:()=>[{label:`Export ${B[c.peek()].toLowerCase()} as CSV`,icon:"download",action:async()=>{try{await ye(c.peek())}catch{E.toast("Export failed.",{type:"error"})}}},{type:"divider"},{label:"Reset all statistics\u2026",icon:"trash",danger:!0,action:async()=>{if(!(t.peek().confirmReset&&!await E.confirm("Delete every analytics counter for this site? This cannot be undone.")))try{await v("/analytics/stats",{method:"DELETE"}),E.toast("Analytics reset.",{type:"success"}),w()}catch{E.toast("Reset failed.",{type:"error"})}}}]});l.add(()=>F?.destroy?.()),s.addEventListener("click",e=>{const a=e.target.closest(".an-kebab, .an-more");a&&te(a)}),I.scan(s);try{await he()}catch{E.toast("The chart library could not be loaded; figures still work.",{type:"warning"})}await Promise.all([w(),C()])}};
62
+ </div>`),n=(o,F=T=>T)=>M.computed({read:()=>t.value[o],write:T=>G(o,F(T))}),p={range:n("range"),limit:n("limit",Number),chart:n("chart"),live:n("live"),confirmReset:n("confirmReset")};e.element.appendChild(a);const m=V(a),f=M.applyBindings(p,a,{methods:{done:()=>e.close(),reset:()=>{t.value={...ne}}}});X(e,()=>{f.dispose(),m()}),e.open()}Q(r,{id:"analytics-settings",icon:"settings",title:"Analytics settings",onClick:W});const h=[...s.querySelectorAll(".an-list")];h[0].innerHTML=ve,h[1].innerHTML=A,h[2].innerHTML=A,h[3].innerHTML=A,h[4].innerHTML=be,l.add(V(s,{position:"bottom"}));const J=M.applyBindings(q,s,{methods:{refresh:()=>{w(),C()},showPages:()=>{d.value="pages"},showJourneys:()=>{d.value="journeys"},showSpikes:()=>{d.value="spikes"}}});l.add(()=>J.dispose());const N=e=>new URL(e,location.origin).href,z=E.contextMenu(s,{cursor:"glyph",match:".an-row",inherit:!1,density:"compact",items:e=>{const a=e.dataset.url;return[{label:"Open on the site",icon:"external-link",action:()=>window.open(N(a),"_blank","noopener")},{label:"Copy address",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(N(a)),E.toast("Address copied",{type:"success"})}catch{E.toast("The browser would not allow copying",{type:"error"})}}},{label:"Show only this page",icon:"filter",action:()=>{d.value="pages",u.value=a}}]}});l.add(()=>z?.destroy?.());const Y=E.contextMenu(s.querySelector(".an-bar-end"),{cursor:"glyph",match:".an-more",inherit:!1,density:"compact",items:()=>[{label:`Export ${D[c.peek()].toLowerCase()} as CSV`,icon:"download",action:async()=>{try{await ye(c.peek())}catch{E.toast("Export failed.",{type:"error"})}}},{type:"divider"},{label:"Reset all statistics\u2026",icon:"trash",danger:!0,action:async()=>{if(!(t.peek().confirmReset&&!await E.confirm("Delete every analytics counter for this site? This cannot be undone.")))try{await v("/analytics/stats",{method:"DELETE"}),E.toast("Analytics reset.",{type:"success"}),w()}catch{E.toast("Reset failed.",{type:"error"})}}}]});l.add(()=>Y?.destroy?.()),s.addEventListener("click",e=>{const a=e.target.closest(".an-kebab, .an-more");a&&ee(a)}),I.scan(s);try{await he()}catch{E.toast("The chart library could not be loaded; figures still work.",{type:"warning"})}await Promise.all([w(),C()])}};