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
@@ -15,6 +15,7 @@ function slugify(s) {
15
15
  const BASE = '/api/plugins/blog';
16
16
 
17
17
  // The loader's cache-busting token, passed on so helpers are refetched with this view.
18
+ import {fmtDateTime, fmtTime} from '/plugins/_lib/admin/ui/dates.js';
18
19
  const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
19
20
 
20
21
  /** One layout choice - the body of the picker's data-each (see mountLayoutCard). */
@@ -155,7 +156,6 @@ async function mountLayoutCard(card, {current, collect}) {
155
156
  return {value: () => layout.peek()};
156
157
  }
157
158
 
158
- const STAMP_FORMAT = 'D MMM YYYY, HH:mm';
159
159
  const STATUS_CLASS = {published: 'badge-success', scheduled: 'badge-warning'};
160
160
 
161
161
  /**
@@ -177,7 +177,7 @@ function mountStatusCard(card) {
177
177
  const now = M.observable(Date.now());
178
178
  const tick = setInterval(() => { now.value = Date.now(); }, 60000);
179
179
 
180
- const when = (iso) => (iso && !Number.isNaN(Date.parse(iso)) ? D(iso).format(STAMP_FORMAT) : '');
180
+ const when = (iso) => (iso && !Number.isNaN(Date.parse(iso)) ? fmtDateTime(iso) : '');
181
181
  const ago = (iso, at) => (iso && !Number.isNaN(Date.parse(iso)) ? `(${D(iso).fromNow(at)})` : '');
182
182
 
183
183
  const data = {
@@ -535,7 +535,7 @@ export const postEditorView = {
535
535
  saveStateEl.classList.toggle('is-dirty', dirty);
536
536
  saveStateEl.textContent = dirty
537
537
  ? 'Unsaved changes'
538
- : savedAt ? `Saved ${savedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : '';
538
+ : savedAt ? `Saved ${fmtTime(savedAt)}` : '';
539
539
  }
540
540
  function setStatus(next) {
541
541
  status = next || 'draft';
@@ -674,7 +674,7 @@ export const postEditorView = {
674
674
  stamps.set(post);
675
675
  setStatus('scheduled');
676
676
  if (scheduleEl) scheduleEl.classList.add('blog-hidden');
677
- E.toast(`Scheduled for ${new Date(publishedAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}.`, { type: 'success' });
677
+ E.toast(`Scheduled for ${fmtDateTime(publishedAt)}.`, { type: 'success' });
678
678
  } catch (err) {
679
679
  E.toast(err?.message ?? 'Failed to schedule post.', { type: 'error' });
680
680
  }
@@ -4,7 +4,7 @@
4
4
  -->
5
5
  <a class="br-media {{imageClass}}" href="{{url}}" tabindex="-1" aria-hidden="true">{{{image}}}</a>
6
6
  <div class="br-body">
7
- <p class="br-meta"><time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
7
+ <p class="br-meta"><time datetime="{{publishedAt}}">{{publishedAt:DD/MM/YYYY}}</time> · {{readingTime}}</p>
8
8
  <h2 class="br-title"><a href="{{url}}">{{title}}</a></h2>
9
9
  <p class="br-excerpt">{{excerpt}}</p>
10
10
  <div class="br-foot"><span class="br-cats">{{{categories}}}</span><a class="br-author" href="{{authorUrl}}">{{authorName}}</a></div>
@@ -3,7 +3,7 @@
3
3
  Edit it here to restyle every card; copy it as blog-card-<name> to add a display.
4
4
 
5
5
  {{title}} {{url}} {{excerpt}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
6
- {{publishedAt:D MMM YYYY}} any date format
6
+ {{publishedAt:DD/MM/YYYY}} any date format
7
7
  {{imageUrl}} {{imageClass}} (has-image | no-image) {{category}} (the first one)
8
8
  {{{image}}} {{{categories}}} {{{tags}}} ready-made markup
9
9
  -->
@@ -12,5 +12,5 @@
12
12
  <div class="bc-kicker">{{{categories}}}</div>
13
13
  <h2 class="bc-title"><a href="{{url}}">{{title}}</a></h2>
14
14
  <p class="bc-excerpt">{{excerpt}}</p>
15
- <p class="bc-meta"><a href="{{authorUrl}}">{{authorName}}</a> · <time datetime="{{publishedAt}}">{{publishedAt:D MMM YYYY}}</time> · {{readingTime}}</p>
15
+ <p class="bc-meta"><a href="{{authorUrl}}">{{authorName}}</a> · <time datetime="{{publishedAt}}">{{publishedAt:DD/MM/YYYY}}</time> · {{readingTime}}</p>
16
16
  </div>
@@ -4,7 +4,7 @@
4
4
  to add a layout authors can choose.
5
5
 
6
6
  {{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
7
- {{publishedAt:D MMMM YYYY}} any date format
7
+ {{publishedAt:DD/MM/YYYY}} any date format
8
8
  {{imageUrl}} {{imageClass}} (has-image | no-image)
9
9
  Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
10
10
  {{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
@@ -14,7 +14,7 @@
14
14
  <div class="pc-kicker">{{{categories}}}</div>
15
15
  <h1 class="pc-title">{{title}}</h1>
16
16
  <p class="pc-standfirst">{{excerpt}}</p>
17
- <p class="pc-meta">By <a href="{{authorUrl}}">{{authorName}}</a> · <time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
17
+ <p class="pc-meta">By <a href="{{authorUrl}}">{{authorName}}</a> · <time datetime="{{publishedAt}}">{{publishedAt:DD/MM/YYYY}}</time> · {{readingTime}}</p>
18
18
  </header>
19
19
  <figure class="pc-figure {{imageClass}}">{{{image}}}</figure>
20
20
  <div class="pc-body blog-content">{{{content}}}</div>
@@ -4,7 +4,7 @@
4
4
  sit down with.
5
5
 
6
6
  {{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
7
- {{publishedAt:D MMMM YYYY}} any date format
7
+ {{publishedAt:DD/MM/YYYY}} any date format
8
8
  {{imageUrl}} {{imageClass}} (has-image | no-image)
9
9
  Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
10
10
  {{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
@@ -14,7 +14,7 @@
14
14
  <div class="pe-kicker">{{{categories}}}</div>
15
15
  <h1 class="pe-title">{{title}}</h1>
16
16
  <p class="pe-standfirst">{{excerpt}}</p>
17
- <p class="pe-byline">By <a href="{{authorUrl}}">{{authorName}}</a> <span aria-hidden="true">/</span> <time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> <span aria-hidden="true">/</span> {{readingTime}}</p>
17
+ <p class="pe-byline">By <a href="{{authorUrl}}">{{authorName}}</a> <span aria-hidden="true">/</span> <time datetime="{{publishedAt}}">{{publishedAt:DD/MM/YYYY}}</time> <span aria-hidden="true">/</span> {{readingTime}}</p>
18
18
  </header>
19
19
  <figure class="pe-figure {{imageClass}}">{{{image}}}</figure>
20
20
  <div class="pe-body blog-content">{{{content}}}</div>
@@ -3,7 +3,7 @@
3
3
  Without a featured image the hero falls back to the theme's primary colour.
4
4
 
5
5
  {{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
6
- {{publishedAt:D MMMM YYYY}} any date format
6
+ {{publishedAt:DD/MM/YYYY}} any date format
7
7
  {{imageUrl}} {{imageClass}} (has-image | no-image)
8
8
  Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
9
9
  {{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
@@ -14,7 +14,7 @@
14
14
  <div class="pf-kicker">{{{categories}}}</div>
15
15
  <h1 class="pf-title">{{title}}</h1>
16
16
  <p class="pf-standfirst">{{excerpt}}</p>
17
- <p class="pf-meta"><a href="{{authorUrl}}">{{authorName}}</a> · <time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
17
+ <p class="pf-meta"><a href="{{authorUrl}}">{{authorName}}</a> · <time datetime="{{publishedAt}}">{{publishedAt:DD/MM/YYYY}}</time> · {{readingTime}}</p>
18
18
  </div>
19
19
  </header>
20
20
  <div class="pf-wrap">
@@ -2,14 +2,14 @@
2
2
  blog-post-minimal - just the words: a narrow reading column, no image.
3
3
 
4
4
  {{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
5
- {{publishedAt:D MMMM YYYY}} any date format
5
+ {{publishedAt:DD/MM/YYYY}} any date format
6
6
  {{imageUrl}} {{imageClass}} (has-image | no-image)
7
7
  Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
8
8
  {{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
9
9
  -->
10
10
  <article class="pm">
11
11
  <header class="pm-head">
12
- <p class="pm-meta"><time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
12
+ <p class="pm-meta"><time datetime="{{publishedAt}}">{{publishedAt:DD/MM/YYYY}}</time> · {{readingTime}}</p>
13
13
  <h1 class="pm-title">{{title}}</h1>
14
14
  <p class="pm-by">by <a href="{{authorUrl}}">{{authorName}}</a></p>
15
15
  </header>
@@ -2,7 +2,7 @@
2
2
  blog-post-sidebar - the story beside a sticky contents list, details and share links.
3
3
 
4
4
  {{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
5
- {{publishedAt:D MMMM YYYY}} any date format
5
+ {{publishedAt:DD/MM/YYYY}} any date format
6
6
  {{imageUrl}} {{imageClass}} (has-image | no-image)
7
7
  Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
8
8
  {{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
@@ -25,7 +25,7 @@
25
25
  <p class="ps-label">Written by</p>
26
26
  <p class="ps-author"><a href="{{authorUrl}}">{{authorName}}</a></p>
27
27
  <p class="ps-label">Published</p>
28
- <p><time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
28
+ <p><time datetime="{{publishedAt}}">{{publishedAt:DD/MM/YYYY}}</time> · {{readingTime}}</p>
29
29
  <div class="ps-tags">{{{tags}}}</div>
30
30
  {{{share}}}
31
31
  </div>
@@ -4,7 +4,7 @@
4
4
  panel in the theme's primary colour.
5
5
 
6
6
  {{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
7
- {{publishedAt:D MMMM YYYY}} any date format
7
+ {{publishedAt:DD/MM/YYYY}} any date format
8
8
  {{imageUrl}} {{imageClass}} (has-image | no-image)
9
9
  Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
10
10
  {{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
@@ -16,7 +16,7 @@
16
16
  <div class="pt-kicker">{{{categories}}}</div>
17
17
  <h1 class="pt-title">{{title}}</h1>
18
18
  <p class="pt-standfirst">{{excerpt}}</p>
19
- <p class="pt-meta"><a href="{{authorUrl}}">{{authorName}}</a><br><time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
19
+ <p class="pt-meta"><a href="{{authorUrl}}">{{authorName}}</a><br><time datetime="{{publishedAt}}">{{publishedAt:DD/MM/YYYY}}</time> · {{readingTime}}</p>
20
20
  </div>
21
21
  </header>
22
22
  <div class="pt-wrap">
@@ -0,0 +1,205 @@
1
+ Blog adds a public blog to your site: posts with categories and tags, scheduled publishing, optional comments, an RSS feed and a choice of looks. Writers can draft posts; editors publish them.
2
+
3
+ ## What it does
4
+
5
+ - Posts written in Markdown, with an excerpt, featured image, categories, tags and search-engine fields.
6
+ - Drafts, publishing now, and scheduling for a later date and time.
7
+ - Categories, each with its own page on the site.
8
+ - Optional reader comments, held for approval if you want.
9
+ - Six post layouts and four listing displays, all editable as blocks.
10
+ - An RSS feed, and pages for each category, tag and author.
11
+ - Shortcodes to show recent posts, a grid, a featured post or the category list on any page.
12
+ - A **Blog** link added to your navbar when the plugin is turned on.
13
+
14
+ ## Getting started
15
+
16
+ 1. Open [Blog](#/plugins/blog) from the admin sidebar.
17
+ 2. Optional: click the three-dot button beside **New post** and choose **Add sample posts** to see how the blog looks before you write anything (admins only). Remove them later from the same menu.
18
+ 3. Click **New post**.
19
+ 4. Enter a **Title** and write the **Content**.
20
+ 5. Add an **Excerpt**, **Categories**, **Tags** and a **Featured Image** if you like.
21
+ 6. Click **Publish** to put it live now, or **Schedule** to pick a date and time. **Save** keeps it as a draft.
22
+ 7. Visit `/blog` on your site to see it.
23
+
24
+ ## Screens
25
+
26
+ The sidebar **Blog** item opens **Posts**, **Categories** and **Comments**. Every Blog screen has a **Blog settings** cog in the banner.
27
+
28
+ ### Posts
29
+
30
+ - Figure chips at the top show **All**, **Published**, **Drafts** and **Scheduled**. Click one to show only those.
31
+ - Search matches titles, excerpts and slugs. Filter by category, and sort by **Newest first**, **Oldest first** or **Title, A to Z**. **Clear filters** resets them.
32
+ - Right-click a post (or use its three-dot button) for **Edit**, **View on site**, **Copy link**, **Publish now**, **Unpublish**, **Duplicate as draft** and **Delete**.
33
+ - The three-dot button beside **New post** has **View the blog**, **Edit layouts in Blocks**, and **Add sample posts** or **Remove sample posts** (admins only). Removing samples removes only the sample posts, and sample categories nothing else uses.
34
+
35
+ Writers without the Publish permission see only their own posts.
36
+
37
+ ### Post editor
38
+
39
+ | Field | What it does |
40
+ |---|---|
41
+ | Title | Required. |
42
+ | Slug | The last part of the post's address, for example `my-first-post`. With **Auto** ticked it follows the title. Changing it on a published post breaks links to the old address. |
43
+ | Excerpt | A sentence or two shown in listings. Also used as the search-engine description when **SEO Description** is empty. |
44
+ | Content | Markdown. Every shortcode the site supports works here too. |
45
+ | SEO Title / SEO Description | What search engines and link previews show. Leave empty to use the title and excerpt. |
46
+ | Post layout | **Site default** (follows Blog settings) or a layout of its own. |
47
+ | Categories | Type to find one, or click the box to see them all. A post can be in several. |
48
+ | Tags | Type a tag and press Enter or a comma. Tags already used on other posts are suggested. Each tag gets its own page. |
49
+ | Featured Image | Shown at the top of the post and in listings. **Pick Image** opens the Media library; **Clear** removes it. |
50
+
51
+ The buttons:
52
+
53
+ - **Save** (or Ctrl+S) stores your changes and keeps the post as it is. A draft stays a draft; a live post stays live and updates.
54
+ - **Publish** puts it on the site now.
55
+ - **Schedule** picks a date and time. The post goes live by itself when that time passes.
56
+ - **Move to drafts** takes a live or scheduled post off the site.
57
+ - **Preview** shows the post as readers will see it, in any layout, before you save.
58
+
59
+ The **Status** card shows the status, the scheduled or published date, when it was last saved and when it was created.
60
+
61
+ ### Categories
62
+
63
+ Lists every category with its post count. Click **New category** and enter a **Name**, **Slug** and optional **Description**. Each category has a page at `/blog/category/<slug>`.
64
+
65
+ Right-click a category (or use its three-dot button) for **Edit**, **Show its posts**, **View on site** and **Delete**.
66
+
67
+ ### Comments
68
+
69
+ - Filter by **Pending**, **Approved**, **Rejected**, **Spam** or **All**, and search.
70
+ - **Approve all pending** approves every waiting comment at once.
71
+ - Click a comment to read all of it. Right-click it (or use its three-dot button) for **Approve**, **Back to pending**, **Reject**, **Mark as spam**, **Open the post**, **Reply by email** and **Delete**.
72
+
73
+ Only approved comments appear on the site. If comments are switched off, the screen says so and links to **Blog settings**.
74
+
75
+ ## The public blog
76
+
77
+ These pages are served under the blog's base path (default `/blog`):
78
+
79
+ | Address | What it shows |
80
+ |---|---|
81
+ | `/blog` | The newest posts, with paging (`/blog/page/2` and so on). |
82
+ | `/blog/<slug>` | One post. |
83
+ | `/blog/category/<slug>` | Posts in a category. |
84
+ | `/blog/tag/<tag>` | Posts with a tag. |
85
+ | `/blog/author/<id>` | Posts by one author. |
86
+ | `/blog/feed.xml` | The RSS feed. |
87
+
88
+ Depending on the layout, post pages show reading time, a contents list, share links, newer and older post links, related posts and, when switched on, comments with a comment form (name, email and comment; the email is never shown).
89
+
90
+ Listings can offer readers a search box, category filters, sorting, a display switch and a right-click menu on each post (more in this category, more with this tag, more by this author), all without reloading. Blogs with more than 300 posts page normally instead.
91
+
92
+ ## Settings
93
+
94
+ Click the **Blog settings** cog in the banner. Only admins can save changes.
95
+
96
+ | Setting | What it does |
97
+ |---|---|
98
+ | Base path | The address every blog page lives under. `/blog` gives `/blog/my-post`. Applies as soon as you save. The navbar link Blog added moves with it; links typed into pages do not. |
99
+ | Posts per page | How many posts a listing page shows. Default 10. |
100
+ | RSS feed length | How many of the newest posts the feed carries. Default 20. |
101
+ | Comments | Readers can leave a comment under each post. Off by default. Turning it off hides the form; existing comments stay. |
102
+ | Hold comments for approval | New comments wait under Comments > Pending until approved. On by default. |
103
+ | Post layout | How a post looks unless it picks its own. |
104
+ | Listing display | How the blog index and the category, tag and author pages show posts. |
105
+ | Readers can switch display | Shows a Cards / List / Magazine / Compact switch above listings. A reader's choice is remembered in their browser. |
106
+ | Search and filter in the page | Adds the search box, category pills, sorting and right-click menu to listings. |
107
+ | Related posts | How many posts sharing a category or tag are suggested under each post. 0 turns them off. Default 3. |
108
+
109
+ ### Post layouts
110
+
111
+ | Layout | Look |
112
+ |---|---|
113
+ | Classic | Title, image, then the story in one centred column. The default. |
114
+ | Feature | A full-width hero image with the title over it. |
115
+ | Minimal | Just the words: a narrow reading column, no image. |
116
+ | Sidebar | The story beside a contents list, details and share links. |
117
+ | Split | The picture on one half, the headline on the other. |
118
+ | Essay | A long read set like print: serif type, a drop cap and pull quotes. |
119
+
120
+ ### Listing displays
121
+
122
+ | Display | Look |
123
+ |---|---|
124
+ | Cards | A grid of picture cards. The default. |
125
+ | List | One post per row, picture beside the text. |
126
+ | Magazine | The newest post large across the top, the rest as cards. |
127
+ | Compact | Titles and dates only. |
128
+
129
+ Every layout and display is a block you can edit in **Blocks**: `blog-post-...` for post layouts and `blog-card`, `blog-card-row` and `blog-card-compact` for listings. Copy one under a new name starting `blog-post-` or `blog-card-` (for example `blog-post-long-read`) and it appears as a new choice in the settings and the post editor.
130
+
131
+ ## Shortcodes
132
+
133
+ Use these on any page.
134
+
135
+ A list of recent post titles and dates:
136
+
137
+ ```text
138
+ [blog-list count="5" category="news" /]
139
+ ```
140
+
141
+ A grid of post cards with pictures:
142
+
143
+ ```text
144
+ [blog-grid count="6" cols="3" category="news" /]
145
+ ```
146
+
147
+ One post as a large card (leave out `slug` for the newest post):
148
+
149
+ ```text
150
+ [blog-featured slug="my-first-post" /]
151
+ ```
152
+
153
+ A linked list of categories, optionally with post counts:
154
+
155
+ ```text
156
+ [blog-categories show-counts="true" /]
157
+ ```
158
+
159
+ | Attribute | Used by | What it does |
160
+ |---|---|---|
161
+ | `count` (or `limit`) | blog-list, blog-grid | How many posts. Defaults: 5 for the list, 6 for the grid. |
162
+ | `category` | blog-list, blog-grid | Only posts in this category (its slug). |
163
+ | `cols` | blog-grid | Columns, 1 to 4. Default 3. |
164
+ | `slug` | blog-featured | The post to show. |
165
+ | `show-counts` | blog-categories | `true` shows each category's post count. |
166
+
167
+ ## Permissions and roles
168
+
169
+ Permissions appear in **System > Roles** under the **Blog** group:
170
+
171
+ | Permission | Actions |
172
+ |---|---|
173
+ | Blog posts (`blog-posts`) | View, Write, Edit, Delete, Publish |
174
+ | Blog categories (`blog-categories`) | View, Create, Edit, Delete |
175
+ | Blog comments (`blog-comments`) | View (includes the commenter's email), Moderate, Delete |
176
+
177
+ All three are granted to the Admin role. **Publish** also lets a user see and edit everyone's posts; without it, a writer sees and edits only their own.
178
+
179
+ Blog adds two roles:
180
+
181
+ - **Blog Author** - writes posts and edits their own, and can pick categories. Cannot publish, schedule, delete or moderate comments.
182
+ - **Blog Editor** - everything: publish, schedule, delete, categories and comments.
183
+
184
+ Users need the Blog posts View permission to see Blog in the sidebar. Adding and removing sample posts, and saving settings, are for admins only.
185
+
186
+ ## Sidebar badge and notifications
187
+
188
+ - The **Blog** sidebar item shows how many comments are waiting for approval. Hover it for waiting, published, drafts, scheduled (with the next date) and the latest waiting comments.
189
+ - A comment held for approval raises a notification, which goes to Super Admins unless you change who receives it. It clears when the comment is moderated or deleted.
190
+
191
+ ## Search engines
192
+
193
+ Blog adds the blog index and every public post to your site's sitemap, and gives each post page its proper address for search engines. Use the SEO fields in the editor to set what search results and link previews show.
194
+
195
+ ## Tips
196
+
197
+ - Use **Duplicate as draft** to start a new post from an old one.
198
+ - Keep categories few and broad; use tags for the detail.
199
+ - Changing the default **Post layout** changes every post that has not chosen its own.
200
+
201
+ ## Limitations
202
+
203
+ - Content is written in Markdown. There is no visual editor.
204
+ - There is one blog per site. Several blogs, more layouts, themes, author pages and media embeds are part of Blog Pro.
205
+ - Comment spam protection is a hidden form field. Keep **Hold comments for approval** on if spam is a problem.
@@ -142,7 +142,7 @@ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday',
142
142
 
143
143
  /**
144
144
  * Moment-style date formatting, the same tokens block templates use elsewhere
145
- * (`{{publishedAt:D MMM YYYY}}`). UTC, so a page renders the same wherever the
145
+ * (`{{publishedAt:DD/MM/YYYY}}`). UTC, so a page renders the same wherever the
146
146
  * server is.
147
147
  *
148
148
  * @returns {string|null} null when the value is not a date
@@ -165,7 +165,7 @@ export function formatDate(value, fmt) {
165
165
 
166
166
  /**
167
167
  * Fill a block template. `{{{key}}}` is raw (for the fragments this plugin
168
- * builds and escapes itself), `{{key}}` is escaped, `{{key:D MMM YYYY}}`
168
+ * builds and escapes itself), `{{key}}` is escaped, `{{key:DD/MM/YYYY}}`
169
169
  * formats a date. HTML comments go - a block's header documents its
170
170
  * placeholders, and a listing of fifty cards need not repeat that fifty times.
171
171
  *
@@ -270,7 +270,7 @@ export function cardVars(post, {base, catMap = {}, authorName = ''}) {
270
270
  url,
271
271
  excerpt: post.excerpt ?? '',
272
272
  publishedAt: post.publishedAt ?? '',
273
- date: formatDate(post.publishedAt, 'D MMM YYYY') ?? '',
273
+ date: formatDate(post.publishedAt, 'DD/MM/YYYY') ?? '',
274
274
  authorName,
275
275
  authorUrl: post.authorId ? `${base}/author/${encodeURIComponent(post.authorId)}` : base,
276
276
  readingTime: readingTime(post.content),
@@ -10,6 +10,7 @@ import {parseMarkdown} from '../../../server/services/markdown.js';
10
10
  import {getUserById} from '../../../server/services/users.js';
11
11
  import {getPluginStates} from '../../../server/services/plugins.js';
12
12
  import defaultConfig from '../config.js';
13
+ import {fmtDate} from '../../_lib/admin/ui/dates.js';
13
14
  import {buildToc, byNewest, cardVars, cleanBasePath, DEFAULT_POST_LAYOUT, DISPLAY_DEFAULTS, escapeHtml, interpolate, pickPostLayout, relatedPosts, safeImageUrl} from './layouts.js';
14
15
  import {catalogue, loadBlock, renderCards, styleTag} from './render.js';
15
16
 
@@ -145,7 +146,7 @@ async function commentsHtml(post) {
145
146
  } catch { /* non-fatal */ }
146
147
 
147
148
  const list = approved.map((c) => `<li class="blog-comment">
148
- <p class="blog-comment-head"><strong>${escapeHtml(c.authorName)}</strong> <time datetime="${escapeHtml(c.createdAt)}">${escapeHtml(new Date(c.createdAt).toLocaleDateString('en-GB', {day: 'numeric', month: 'long', year: 'numeric'}))}</time></p>
149
+ <p class="blog-comment-head"><strong>${escapeHtml(c.authorName)}</strong> <time datetime="${escapeHtml(c.createdAt)}">${escapeHtml(fmtDate(c.createdAt))}</time></p>
149
150
  <p class="blog-comment-body">${escapeHtml(c.body)}</p>
150
151
  </li>`).join('\n');
151
152
 
@@ -11,6 +11,7 @@ import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
11
11
  import {commentKey, commentNotice} from './lib/notices.js';
12
12
  import {authorPatch, permissionChecker} from './lib/access.js';
13
13
  import {getPermissionsFor, invalidate as invalidateRoles} from '../../server/services/roles.js';
14
+ import {fmtDate} from '../_lib/admin/ui/dates.js';
14
15
 
15
16
  const POSTS_SLUG = 'blog-posts';
16
17
  const CATEGORIES_SLUG = 'blog-categories';
@@ -127,8 +128,7 @@ function escapeHtml(str) {
127
128
  }
128
129
 
129
130
  function formatDate(iso) {
130
- if (!iso) return '';
131
- return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
131
+ return iso ? escapeHtml(fmtDate(iso)) : '';
132
132
  }
133
133
 
134
134
  /** As lib/page.js isVisible: Blog Pro's other blogs stay out of this one. */
@@ -677,7 +677,7 @@ export default async function blogPlugin(fastify, options) {
677
677
  { label: 'Waiting for approval', value: String(n) },
678
678
  { label: 'Published', value: String(count('published')) },
679
679
  { label: 'Drafts', value: String(count('draft')) },
680
- { label: 'Scheduled', value: next ? `${count('scheduled')} · next ${String(next.publishedAt).slice(0, 10)}` : String(count('scheduled')) }
680
+ { label: 'Scheduled', value: next ? `${count('scheduled')} · next ${fmtDate(next.publishedAt)}` : String(count('scheduled')) }
681
681
  ],
682
682
  // Plain text: the popover sets it with textContent, so a comment is never markup.
683
683
  items: pending.slice(0, 4).map(c => ({
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "blog",
3
3
  "displayName": "Blog",
4
- "version": "1.9.0",
4
+ "version": "1.9.3",
5
5
  "tier": "free",
6
6
  "description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
7
7
  "author": "Domma CMS",
@@ -78,11 +78,11 @@
78
78
  ],
79
79
  "views": {
80
80
  "plugin-blog-posts": {
81
- "entry": "blog/admin/views/blog.js?v=9",
81
+ "entry": "blog/admin/views/blog.js?v=10",
82
82
  "exportName": "blogView"
83
83
  },
84
84
  "plugin-blog-post-editor": {
85
- "entry": "blog/admin/views/post-editor.js?v=9",
85
+ "entry": "blog/admin/views/post-editor.js?v=10",
86
86
  "exportName": "postEditorView"
87
87
  },
88
88
  "plugin-blog-categories": {
@@ -90,7 +90,7 @@
90
90
  "exportName": "categoriesView"
91
91
  },
92
92
  "plugin-blog-comments": {
93
- "entry": "blog/admin/views/comments.js?v=9",
93
+ "entry": "blog/admin/views/comments.js?v=10",
94
94
  "exportName": "commentsView"
95
95
  },
96
96
  "plugin-blog-settings": {
@@ -124,3 +124,9 @@ test('a post Blog Pro filed under another blog is not shown by this one', async
124
124
  assert.equal(isVisible({status: 'published', blogId: 'main'}), true);
125
125
  assert.equal(isVisible({status: 'published', blogId: 'travel'}), false);
126
126
  });
127
+
128
+ test('a card shows its date as dd/mm/yyyy; the machine value stays ISO', () => {
129
+ const v = cardVars({slug: 'x', publishedAt: '2026-09-07T10:00:00Z'}, {base: '/blog'});
130
+ assert.equal(v.date, '07/09/2026');
131
+ assert.equal(v.publishedAt, '2026-09-07T10:00:00Z');
132
+ });
@@ -11,6 +11,10 @@ ONE plugin, TWO sides, chosen at registration by where it runs:
11
11
  set by site-manager's processManager). The screen is "Send feedback" plus this site's reports.
12
12
  - **receiver** - the manager (dcm). Detected by `plugins/site-manager/services/fleetTokens.js` existing
13
13
  beside this plugin (`lib/receiver.js isManager()`). The screen is the inbox of every site's reports.
14
+ Since 1.1.0 the manager's **level-0 role** (whatever it is called - `getEffectiveLevel(user) === 0`, never a
15
+ role name) also gets a **My feedback** tab: `send.js` again, with `ctx.mode.local`, filing its own reports
16
+ straight into the same collection (a local write, no HTTP to itself) as site `_manager`, shown everywhere
17
+ as "Manager (this server)". From there it is an ordinary report: status, reply, notes, Waypoint.
14
18
 
15
19
  A site the manager did not start (a standalone install) has nowhere to send to: the screen says so,
16
20
  `POST /send` answers 409.
@@ -23,7 +27,9 @@ A site the manager did not start (a standalone install) has nowhere to send to:
23
27
  - `lib/sender.js` - the outbox (`data/outbox.json`), `managerApi` (fetch, injectable), `flush`, `mergeReports`
24
28
  - `lib/receiver.js` - the `feedback-reports` collection, token -> slug, site names, patches with history
25
29
  - `collections/feedback-reports.json` - schema, created by `ensureCollection()` on the manager only
26
- - `admin/views/feedback.js` - the shell: asks `/mode`, loads `send.js` or `inbox.js`
30
+ - `admin/views/feedback.js` - the shell: asks `/mode`, loads `send.js` or `inbox.js`; on the manager with
31
+ `canSend` it shows Inbox / My feedback tabs, one mounted at a time (each with its own cleanups).
32
+ `#/plugins/feedback/mine` opens My feedback; the last tab is remembered (`feedback.tab`)
27
33
  - `admin/lib/kit.js`, `admin/templates/feedback.html`, `admin/css/index.css`
28
34
  - `tests/shape.test.js` (pure), `tests/api.test.js` (a sender app wired into a receiver app via inject)
29
35
 
@@ -46,14 +52,25 @@ Receiver (inbox routes: permission `feedback` action `manage`):
46
52
  |---|---|---|
47
53
  | POST | `/fleet/submit` | a site's report. **No session**: `Authorization: Bearer <fleet token>` names the site. bodyLimit 32 KB, per-site throttle |
48
54
  | GET | `/fleet/mine` | that site's reports, `senderView()` fields only |
49
- | GET | `/mode` | `{mode:'receiver', waypoint}` |
50
- | GET | `/count` | badge: status `new` (danger tone if any is a blocker) |
55
+ | GET | `/mode` | `{mode:'receiver', waypoint, canSend}` - `canSend` = the user is level 0 |
56
+ | GET | `/count` | badge: status `new` (danger tone if any is a blocker); level 0 also gets a details row of unread replies to its own reports |
51
57
  | GET | `/reports` · `/reports/:id` | the inbox; reading one freshens its Waypoint status |
52
58
  | PUT | `/reports/:id` | `{status?, reply?, notes?}` |
53
59
  | DELETE | `/reports/:id` | gone for the site too |
54
60
  | GET | `/waypoint` | `{available, projects}` |
55
61
  | POST | `/reports/:id/promote` | `{projectId, type, priority}` -> a Waypoint issue; 503 without Waypoint, 409 if already linked |
56
62
 
63
+ The manager's own feedback (permission `feedback` action `send` AND level 0, else 403) - the same paths
64
+ and answers as a site's, so `send.js` runs unchanged:
65
+
66
+ | Method | Path | Purpose |
67
+ |---|---|---|
68
+ | POST | `/send` | `rx.receive('_manager', payload, 'manager')`: into the inbox at once, `state: 'sent'`. Same throttle (key `_manager`), no notification (it is you) |
69
+ | GET | `/sent` | reports with site `_manager`, `senderView()` fields, `unread` from `data/own.json` |
70
+ | PUT | `/sent/:id/seen` | `{replyAt}` - kept in `data/own.json` (`createOutbox(dir, 'own.json')`, `seen` only) |
71
+
72
+ Replying in the inbox to a `_manager` report you filed yourself marks it seen for you.
73
+
57
74
  ## Storage
58
75
 
59
76
  - Manager: collection `feedback-reports` (file adapter), NOT `feedback` - that slug is the starter preset
@@ -61,6 +78,8 @@ Receiver (inbox routes: permission `feedback` action `manage`):
61
78
  read, and these carry admins' names and emails). Record: site, ref, type, title, description, where,
62
79
  severity, cmsVersion, userId/userName/userEmail, status, reply, replyAt, notes, issueId, issueKey,
63
80
  issueStatus, history[].
81
+ - Manager's own reports: the same collection, `site: '_manager'` (site slugs start `[a-z0-9]`, so no site
82
+ can be it, and no fleet token resolves to it). `data/own.json` holds which of their replies were read.
64
83
  - Site: `data/outbox.json` - `{items: [{ref, state: waiting|sent|refused, payload, remoteId, reply, replyAt,
65
84
  lastError}], seen: {reportId: replyAt}}`. The engine updater preserves plugin `data/`.
66
85
 
@@ -6,6 +6,8 @@
6
6
  * @module feedback/admin/lib/kit
7
7
  */
8
8
 
9
+ import {fmtDate, fmtDateTime} from '/plugins/_lib/admin/ui/dates.js';
10
+
9
11
  export const SIDEBAR_URL = '#/plugins/feedback';
10
12
  export const BASE = '/api/plugins/feedback';
11
13
 
@@ -67,7 +69,7 @@ export function refreshBadge() {
67
69
  try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: SIDEBAR_URL}})); } catch { /* no sidebar */ }
68
70
  }
69
71
 
70
- /** "3 min ago", "26 Sep". */
72
+ /** "3 min ago", "26/09/2026". */
71
73
  export function when(iso) {
72
74
  const t = new Date(iso).getTime();
73
75
  if (!Number.isFinite(t)) return '';
@@ -76,14 +78,11 @@ export function when(iso) {
76
78
  if (s < 3600) return `${Math.round(s / 60)} min ago`;
77
79
  if (s < 86400) return `${Math.round(s / 3600)} h ago`;
78
80
  if (s < 86400 * 6) return `${Math.round(s / 86400)} d ago`;
79
- return new Date(t).toLocaleDateString(undefined, {day: 'numeric', month: 'short', ...(s > 86400 * 300 ? {year: 'numeric'} : {})});
81
+ return fmtDate(t);
80
82
  }
81
83
 
82
- /** Full date and time, for a detail panel. */
83
- export const stamp = (iso) => {
84
- const t = new Date(iso);
85
- return Number.isFinite(t.getTime()) ? t.toLocaleString(undefined, {dateStyle: 'medium', timeStyle: 'short'}) : '';
86
- };
84
+ /** Full date and time, for a detail panel: dd/mm/yyyy HH:mm. */
85
+ export const stamp = (iso) => (Number.isFinite(new Date(iso ?? '').getTime()) ? fmtDateTime(iso) : '');
87
86
 
88
87
  /**
89
88
  * A slideover holding `html`, bound to `model`. Everything is disposed and the