domma-cms 0.66.0 → 0.69.3

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 (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +92 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +307 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -1,9 +1,18 @@
1
- <div class="view-header">
2
- <h1 id="editor-title">New Post</h1>
1
+ <div class="view-header blog-editor-bar">
2
+ <button type="button" id="btn-back" class="btn btn-sm btn-ghost" title="Back to Posts - asks first if there is anything unsaved">
3
+ <span data-icon="arrow-left" data-icon-size="14"></span> Posts
4
+ </button>
5
+ <span id="editor-title" class="blog-editor-title">New post</span>
6
+ <span id="save-state" class="blog-save-state" role="status" aria-live="polite"></span>
3
7
  <div class="view-header-actions">
4
- <button id="btn-save-draft" class="btn btn-secondary">Save Draft</button>
5
- <button id="btn-schedule" class="btn btn-warning blog-hidden">Schedule</button>
6
- <button id="btn-publish" class="btn btn-primary">Publish Now</button>
8
+ <span data-help-title="Saving and publishing"
9
+ data-help="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. Publish puts it on the site now; Schedule picks a date and time. Move to drafts takes a live or scheduled post off the site. You stay on this page throughout; Posts takes you back to the list."></span>
10
+ <button type="button" id="btn-drafts" class="btn btn-sm btn-ghost blog-hidden" title="Take this post off the site">Move to drafts</button>
11
+ <button type="button" id="btn-schedule" class="btn btn-secondary">Schedule</button>
12
+ <button type="button" id="btn-publish" class="btn btn-secondary">Publish</button>
13
+ <button type="button" id="btn-save" class="btn btn-primary" title="Save (Ctrl+S)">
14
+ <span data-icon="save" data-icon-size="14"></span> Save
15
+ </button>
7
16
  </div>
8
17
  </div>
9
18
 
@@ -17,7 +26,7 @@
17
26
  <input type="text" id="post-title" class="form-input form-input-lg" placeholder="Post title…">
18
27
  </div>
19
28
  <div class="mb-3">
20
- <label class="form-label" for="post-slug">Slug</label>
29
+ <label class="form-label" for="post-slug">Slug <span data-help="The last part of the post's address, e.g. my-first-post. With Auto ticked it follows the title. Changing it on a published post breaks links to the old address."></span></label>
21
30
  <div class="flex gap-2 items-center">
22
31
  <input type="text" id="post-slug" class="form-input font-mono">
23
32
  <label class="flex items-center gap-1 text-muted text-sm blog-nowrap" for="slug-auto">
@@ -29,11 +38,11 @@
29
38
  </div>
30
39
  </div>
31
40
  <div class="mb-3">
32
- <label class="form-label" for="post-excerpt">Excerpt</label>
41
+ <label class="form-label" for="post-excerpt">Excerpt <span data-help="A sentence or two shown in post listings and used as the search-engine description when SEO Description is empty."></span></label>
33
42
  <textarea id="post-excerpt" class="form-textarea" rows="2" placeholder="Short summary…"></textarea>
34
43
  </div>
35
44
  <div class="mb-3">
36
- <label class="form-label" for="post-content">Content</label>
45
+ <label class="form-label" for="post-content">Content <span data-help="Markdown. Every shortcode the site supports works here too."></span></label>
37
46
  <textarea id="post-content" class="form-textarea font-mono" rows="20" placeholder="Write in Markdown…"></textarea>
38
47
  </div>
39
48
  </div>
@@ -42,7 +51,7 @@
42
51
  <!-- SEO accordion -->
43
52
  <div class="card mb-3">
44
53
  <div class="card-header blog-clickable" id="seo-toggle">
45
- SEO <span class="text-muted text-sm">(click to expand)</span>
54
+ SEO <span class="text-muted text-sm">(click to expand)</span> <span data-help="Optional overrides for what search engines and link previews show. Leave empty to use the title and excerpt."></span>
46
55
  </div>
47
56
  <div class="card-body blog-hidden" id="seo-panel">
48
57
  <div class="mb-3">
@@ -60,7 +69,7 @@
60
69
  <!-- Sidebar -->
61
70
  <div class="blog-editor-side">
62
71
  <div class="card mb-3">
63
- <div class="card-header">Status</div>
72
+ <div class="card-header">Status <span data-help="Save Draft keeps the post private. Publish Now puts it live. Schedule publishes it at the date and time you choose."></span></div>
64
73
  <div class="card-body">
65
74
  <div id="schedule-picker" class="mb-3 blog-hidden">
66
75
  <label class="form-label" for="post-scheduled-at">Scheduled for</label>
@@ -70,15 +79,29 @@
70
79
  </div>
71
80
  </div>
72
81
 
82
+ <div class="card mb-3" id="layout-card">
83
+ <div class="card-header blog-card-head">Layout
84
+ <span data-help-title="Post layout"
85
+ data-help="How this post looks on the site. Site default follows Blog settings, so changing the default there moves every post that has not chosen. Each layout is a block you can restyle under Blocks."></span>
86
+ <button type="button" class="btn btn-xs btn-ghost blog-preview-btn" id="btn-preview" title="See the post as readers will, before saving">
87
+ <span data-icon="eye" data-icon-size="13"></span> Preview
88
+ </button>
89
+ </div>
90
+ <div class="card-body">
91
+ <div class="blog-layouts" role="radiogroup" aria-label="Layout" data-each="layoutChoices.value key=id"></div>
92
+ <p class="blog-layout-desc" data-bind-text="layoutDesc.value"></p>
93
+ </div>
94
+ </div>
95
+
73
96
  <div class="card mb-3">
74
- <div class="card-header">Categories</div>
97
+ <div class="card-header">Categories <span data-help="Manage the list under Blog > Categories. A post can be in several."></span></div>
75
98
  <div class="card-body" id="categories-checklist">
76
99
  <p class="text-muted text-sm">Loading…</p>
77
100
  </div>
78
101
  </div>
79
102
 
80
103
  <div class="card mb-3">
81
- <div class="card-header">Tags</div>
104
+ <div class="card-header">Tags <span data-help="Free-form labels, comma-separated. Each tag gets its own page on the site."></span></div>
82
105
  <div class="card-body">
83
106
  <input type="text" id="post-tags" class="form-input" placeholder="tag1, tag2, …">
84
107
  <div class="text-muted text-sm mt-1">Comma-separated</div>
@@ -86,7 +109,7 @@
86
109
  </div>
87
110
 
88
111
  <div class="card mb-3">
89
- <div class="card-header">Featured Image</div>
112
+ <div class="card-header">Featured Image <span data-help="Shown at the top of the post and in listings. Pick one from the Media library."></span></div>
90
113
  <div class="card-body">
91
114
  <div id="featured-image-preview" class="mb-2"></div>
92
115
  <button id="btn-pick-image" class="btn btn-sm btn-secondary">Pick Image</button>
@@ -1,14 +1,5 @@
1
- <div class="view-header">
2
- <h1><span data-icon="settings"></span> Blog Settings</h1>
3
- <button id="btn-save-settings" class="btn btn-primary">
4
- <span data-icon="save"></span> Save
5
- </button>
6
- </div>
7
-
8
- <div class="alert alert-warning mb-4">
9
- Base path and posts-per-page changes require a server restart to take effect.
10
- </div>
11
-
1
+ <!-- No heading: the banner above is the title. The same panel opens from the
2
+ banner's cog on every Blog screen; this page is for links that land here. -->
12
3
  <div class="card blog-settings-card">
13
4
  <div class="card-body" id="settings-form"></div>
14
5
  </div>
@@ -1,202 +1,328 @@
1
1
  /**
2
- * Blog posts overview - KPI cards + filterable, sortable post table.
2
+ * Blog posts - the list, on the Tool upgrade path.
3
+ *
4
+ * State is domma-reactive observables bound with M.applyBindings; there is no
5
+ * render function. The four status counts are chips that also filter, which is
6
+ * what the KPI cards were taking a row of screen for. Everything you can do to
7
+ * a post is on its context menu (right-click, Shift+F10, or ⋮); a click on the
8
+ * row opens it.
3
9
  *
4
10
  * @module blog/admin/views/blog
5
11
  */
6
12
 
7
- function escapeHtml(s) {
8
- return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
9
- }
13
+ // The loader's cache-busting token, passed on so helpers are refetched with this view.
14
+ const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
10
15
 
11
- const BASE = '/api/plugins/blog';
16
+ const STATUSES = [
17
+ {key: '', label: 'All'},
18
+ {key: 'published', label: 'Published'},
19
+ {key: 'draft', label: 'Drafts'},
20
+ {key: 'scheduled', label: 'Scheduled'}
21
+ ];
12
22
 
13
- const STATUS_BADGE = {
14
- draft: 'badge-secondary',
15
- scheduled: 'badge-warning',
16
- published: 'badge-success'
17
- };
23
+ /**
24
+ * One post row - the body of the rows `data-each`. Here, not in blog.html,
25
+ * because the router renders a templateUrl through `_.render` first, which
26
+ * would replace every `{{…}}` with nothing. Mustache escapes its values.
27
+ */
28
+ const ROW_TEMPLATE = `
29
+ <div class="blog-row" data-id="{{id}}" tabindex="0">
30
+ <span class="blog-row-main">
31
+ <strong class="blog-row-title">{{title}}</strong>
32
+ <small class="blog-row-sub">{{sub}}</small>
33
+ </span>
34
+ <span class="badge {{badge}}">{{status}}</span>
35
+ <span class="blog-row-cats">{{cats}}</span>
36
+ <time class="blog-row-date" title="{{dateTitle}}">{{date}}</time>
37
+ <button type="button" class="blog-kebab" aria-label="Actions" title="Actions">⋮</button>
38
+ </div>`;
39
+
40
+ const CHIP_TEMPLATE = `<button type="button" class="blog-chip" data-status="{{key}}">{{label}} <b>{{count}}</b></button>`;
41
+
42
+ const BADGE = {draft: 'badge-secondary', scheduled: 'badge-warning', published: 'badge-success'};
18
43
 
19
44
  export const blogView = {
20
45
  templateUrl: '/plugins/blog/admin/templates/blog.html',
21
46
 
22
47
  async onMount($container) {
23
- let allPosts = [];
24
- let categories = [];
25
- let postsTable = null;
26
-
27
- function buildTable(data) {
28
- const tableEl = $container.find('#posts-table').get(0);
29
- if (!tableEl) return;
30
-
31
- if (postsTable) {
32
- postsTable.setData(data);
33
- Domma.icons.scan(tableEl);
34
- return;
35
- }
48
+ // Not awaited: the cog is chrome, and the screen should not wait on it.
49
+ import(`./settings-cog.js?v=${ASSET_VERSION}`)
50
+ .then(m => m.addSettingsCog($container, '.blog-bar'))
51
+ .catch(() => { /* no cog is better than no screen */ });
36
52
 
37
- const catNameById = (id) => categories.find((c) => c.id === id)?.name ?? '';
38
-
39
- postsTable = T.create(tableEl, {
40
- data,
41
- columns: [
42
- {
43
- key: 'title',
44
- title: 'Title',
45
- sortable: true,
46
- render: (v, row) =>
47
- `<a href="#/plugins/blog/posts/${escapeHtml(row.id)}" class="blog-title-link">${escapeHtml(v || 'Untitled')}</a>`
48
- },
49
- {
50
- key: 'status',
51
- title: 'Status',
52
- sortable: true,
53
- render: (v) => {
54
- const cls = STATUS_BADGE[v] || 'badge-secondary';
55
- return `<span class="badge ${cls}">${escapeHtml(v ?? 'draft')}</span>`;
56
- }
57
- },
58
- {
59
- key: 'categories',
60
- title: 'Categories',
61
- sortable: false,
62
- render: (val) => Array.isArray(val) && val.length
63
- ? val.map((id) => `<span class="badge badge-secondary mr-1">${escapeHtml(catNameById(id) || id)}</span>`).join('')
64
- : '<span class="blog-empty-cell">-</span>'
65
- },
66
- {
67
- key: 'publishedAt',
68
- title: 'Published',
69
- sortable: true,
70
- render: (v) => v
71
- ? `<span title="${escapeHtml(D(v).format('DD MMM YYYY HH:mm'))}">${escapeHtml(D(v).format('DD MMM YYYY'))}</span>`
72
- : '<span class="blog-empty-cell">-</span>'
73
- },
74
- {
75
- key: 'id',
76
- title: 'Actions',
77
- sortable: false,
78
- render: (id, row) => {
79
- const slug = escapeHtml(row.slug || '');
80
- const viewLink = row.status === 'published' && slug
81
- ? `<a href="/blog/${slug}" target="_blank" class="btn btn-sm btn-outline" title="View on site"><span data-icon="external-link" data-icon-size="14"></span></a>`
82
- : '';
83
- return `<div class="blog-row-actions">` +
84
- `<button class="btn btn-sm btn-outline post-edit-btn" data-id="${escapeHtml(id)}"><span data-icon="edit" data-icon-size="14"></span></button>` +
85
- viewLink +
86
- `<button class="btn btn-sm btn-danger post-delete-btn" data-id="${escapeHtml(id)}"><span data-icon="trash" data-icon-size="14"></span></button>` +
87
- `</div>`;
88
- }
89
- }
90
- ],
91
- emptyMessage: 'No posts found.'
92
- });
53
+ const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
54
+ const {BASE} = kit;
55
+ const add = kit.viewScope('posts', /^#\/plugins\/blog(\/posts)?\/?$/);
56
+ const root = $container.get(0);
93
57
 
94
- tableEl.addEventListener('click', async (e) => {
95
- const editBtn = e.target.closest('.post-edit-btn');
96
- const deleteBtn = e.target.closest('.post-delete-btn');
58
+ // ---- State ----
59
+ const posts = M.observable([]);
60
+ const categories = M.observable([]);
61
+ const status = M.observable('');
62
+ const category = M.observable(kit.takeHandOff('category') || '');
63
+ const query = M.observable('');
64
+ const sort = M.observable('newest');
65
+ const loading = M.observable(true);
66
+ let site = '/blog';
97
67
 
98
- if (editBtn) {
99
- R.navigate('#/plugins/blog/posts/' + editBtn.dataset.id);
100
- }
68
+ const catName = (id) => categories.peek().find(c => c.id === id)?.name ?? id;
69
+ const when = (p) => p.publishedAt || p.updatedAt || p.createdAt || '';
101
70
 
102
- if (deleteBtn) {
103
- const id = deleteBtn.dataset.id;
104
- const post = allPosts.find((p) => p.id === id);
105
- const ok = await E.confirm(`Delete "${post?.title ?? 'this post'}"?`);
106
- if (!ok) return;
107
- try {
108
- await H.delete(`${BASE}/posts/${id}`);
109
- E.toast('Post deleted.', { type: 'success' });
110
- await reload();
111
- } catch {
112
- E.toast('Failed to delete post.', { type: 'error' });
113
- }
114
- }
71
+ const categoryOptions = M.computed(() => categories.value.map(c => ({id: c.id, name: c.name ?? c.slug ?? c.id})));
72
+
73
+ // Plain observables fed by effects: the admin's Domma never re-renders
74
+ // a data-each whose source is a computed.
75
+ const rows = M.observable([]);
76
+ const chips = M.observable([]);
77
+ add(M.effect(() => {
78
+ const term = query.value.trim().toLowerCase();
79
+ const list = posts.value.filter(p =>
80
+ (!status.value || p.status === status.value)
81
+ && (!category.value || (Array.isArray(p.categories) && p.categories.includes(category.value)))
82
+ && (!term || [p.title, p.excerpt, p.slug].some(v => String(v ?? '').toLowerCase().includes(term))));
83
+ const order = {
84
+ newest: (a, b) => String(when(b)).localeCompare(String(when(a))),
85
+ oldest: (a, b) => String(when(a)).localeCompare(String(when(b))),
86
+ title: (a, b) => String(a.title ?? '').localeCompare(String(b.title ?? ''), undefined, {sensitivity: 'base'})
87
+ }[sort.value];
88
+ categories.value; // re-label when categories arrive
89
+ rows.value = [...list].sort(order).map(p => {
90
+ const at = when(p);
91
+ const label = p.status === 'published' ? 'Published' : p.status === 'scheduled' ? 'Scheduled for' : 'Updated';
92
+ return {
93
+ id: p.id,
94
+ title: p.title || 'Untitled',
95
+ sub: `${p.sample ? 'Sample · ' : ''}/${p.slug || ''}${p.excerpt ? ' · ' + String(p.excerpt).slice(0, 90) : ''}`,
96
+ status: p.status ?? 'draft',
97
+ badge: BADGE[p.status] || 'badge-secondary',
98
+ cats: (p.categories || []).map(catName).join(', '),
99
+ date: at ? D(at).format('DD MMM YYYY') : '',
100
+ dateTitle: at ? `${label} ${D(at).format('DD MMM YYYY HH:mm')}` : ''
101
+ };
115
102
  });
103
+ }));
104
+ add(M.effect(() => {
105
+ const all = posts.value;
106
+ chips.value = STATUSES.map(s => ({
107
+ key: s.key || 'all', label: s.label,
108
+ count: s.key ? all.filter(p => (p.status ?? 'draft') === s.key).length : all.length
109
+ }));
110
+ }));
111
+ // The active chip, marked without re-rendering the list.
112
+ add(M.effect(() => {
113
+ const active = status.value || 'all';
114
+ chips.value;
115
+ queueMicrotask(() => root.querySelectorAll('.blog-chip').forEach(c =>
116
+ c.classList.toggle('is-active', c.dataset.status === active)));
117
+ }));
118
+
119
+ const filtered = M.computed(() => Boolean(status.value || category.value || query.value.trim()));
120
+ const data = {
121
+ rows, chips, query, category, categoryOptions, sort, loading,
122
+ hasRows: M.computed(() => rows.value.length > 0),
123
+ isEmpty: M.computed(() => !loading.value && rows.value.length === 0),
124
+ emptyText: M.computed(() => filtered.value ? 'No posts match these filters.' : 'No posts yet - New post starts one, or ⋮ adds some samples to look at.'),
125
+ canReset: filtered
126
+ };
127
+
128
+ // ---- Server ----
129
+ const byId = (id) => posts.peek().find(p => p.id === id);
116
130
 
117
- Domma.icons.scan(tableEl);
131
+ async function load() {
132
+ loading.value = true;
133
+ try {
134
+ const [postsRes, catsRes] = await Promise.all([
135
+ H.get(`${BASE}/posts`),
136
+ H.get(`${BASE}/categories`).catch(() => [])
137
+ ]);
138
+ site = await kit.sitePath();
139
+ // GET /posts sends a BARE ARRAY; `.data` is only for a wrapped reply.
140
+ posts.value = postsRes?.data ?? (Array.isArray(postsRes) ? postsRes : []);
141
+ categories.value = catsRes?.data ?? (Array.isArray(catsRes) ? catsRes : []);
142
+ } catch {
143
+ E.toast('Failed to load posts.', {type: 'error'});
144
+ } finally {
145
+ loading.value = false;
146
+ }
118
147
  }
119
148
 
120
- function refreshKpis() {
121
- const counts = { total: allPosts.length, published: 0, draft: 0, scheduled: 0 };
122
- for (const p of allPosts) {
123
- if (p.status === 'published') counts.published++;
124
- else if (p.status === 'scheduled') counts.scheduled++;
125
- else counts.draft++;
149
+ /** Replace one post in place, as a NEW array, from a route's reply. */
150
+ const upsert = (post) => {
151
+ const list = posts.peek();
152
+ posts.value = list.some(p => p.id === post.id) ? list.map(p => (p.id === post.id ? post : p)) : [post, ...list];
153
+ };
154
+
155
+ async function publish(id) {
156
+ try {
157
+ upsert(await H.post(`${BASE}/posts/${id}/publish`, {}));
158
+ E.toast('Post published.', {type: 'success'});
159
+ } catch (err) {
160
+ E.toast(err?.message ?? 'Failed to publish.', {type: 'error'});
126
161
  }
127
- $container.find('#kpi-total').text(counts.total);
128
- $container.find('#kpi-published').text(counts.published);
129
- $container.find('#kpi-drafts').text(counts.draft);
130
- $container.find('#kpi-scheduled').text(counts.scheduled);
131
162
  }
132
163
 
133
- function applyFilters() {
134
- const status = $container.find('#filter-status').val() || '';
135
- const cat = $container.find('#filter-category').val() || '';
136
- const query = ($container.find('#filter-search').val() || '').toString().toLowerCase().trim();
137
-
138
- let filtered = allPosts;
139
- if (status) filtered = filtered.filter((p) => p.status === status);
140
- if (cat) filtered = filtered.filter((p) => Array.isArray(p.categories) && p.categories.includes(cat));
141
- if (query) {
142
- filtered = filtered.filter((p) =>
143
- (p.title ?? '').toLowerCase().includes(query) ||
144
- (p.excerpt ?? '').toLowerCase().includes(query)
145
- );
164
+ async function unpublish(id) {
165
+ try {
166
+ upsert(await H.put(`${BASE}/posts/${id}`, {status: 'draft'}));
167
+ E.toast('Post moved back to drafts.', {type: 'success'});
168
+ } catch (err) {
169
+ E.toast(err?.message ?? 'Failed to unpublish.', {type: 'error'});
146
170
  }
147
- buildTable(filtered);
148
171
  }
149
172
 
150
- function populateCategoryFilter() {
151
- const select = $container.find('#filter-category').get(0);
152
- if (!select) return;
153
- for (const cat of categories) {
154
- const opt = document.createElement('option');
155
- opt.value = cat.id;
156
- opt.textContent = cat.name ?? cat.slug ?? cat.id;
157
- select.appendChild(opt);
173
+ async function duplicate(id) {
174
+ const p = byId(id);
175
+ if (!p) return;
176
+ try {
177
+ const copy = await H.post(`${BASE}/posts`, {
178
+ title: `${p.title || 'Untitled'} (copy)`,
179
+ slug: `${p.slug || 'post'}-copy-${Date.now().toString(36)}`,
180
+ content: p.content ?? '', excerpt: p.excerpt ?? '', featuredImage: p.featuredImage ?? null,
181
+ categories: p.categories ?? [], tags: p.tags ?? [], template: p.template ?? '', status: 'draft'
182
+ });
183
+ upsert(copy);
184
+ E.toast('Copied as a draft.', {type: 'success'});
185
+ } catch (err) {
186
+ E.toast(err?.message ?? 'Failed to duplicate.', {type: 'error'});
158
187
  }
159
188
  }
160
189
 
161
- async function reload() {
190
+ async function remove(id) {
191
+ const p = byId(id);
192
+ // No title in the prompt: E.confirm is not promised to escape it.
193
+ const ok = await E.confirm(p?.status === 'published'
194
+ ? 'Delete this published post? Its page goes offline and links to it will break.'
195
+ : 'Delete this post? This cannot be undone.');
196
+ if (!ok) return;
162
197
  try {
163
- const [postsRes, catsRes] = await Promise.all([
164
- H.get(`${BASE}/posts`),
165
- H.get(`${BASE}/categories`).catch(() => ({ data: [] }))
166
- ]);
167
- if (postsRes.error) throw new Error(postsRes.error);
168
- // GET /posts sends a BARE ARRAY, so `.data` is undefined and this
169
- // read emptied the whole screen - no rows, all four KPIs zero.
170
- // Same guard as categories.js and comments.js.
171
- allPosts = postsRes.data ?? (Array.isArray(postsRes) ? postsRes : []);
172
- categories = catsRes.data ?? catsRes ?? [];
198
+ await H.delete(`${BASE}/posts/${id}`);
199
+ posts.value = posts.peek().filter(x => x.id !== id);
200
+ E.toast('Post deleted.', {type: 'success'});
173
201
  } catch {
174
- allPosts = [];
175
- categories = [];
176
- E.toast('Failed to load posts.', { type: 'error' });
202
+ E.toast('Failed to delete post.', {type: 'error'});
177
203
  }
178
- refreshKpis();
179
- populateCategoryFilter();
180
- applyFilters();
181
204
  }
182
205
 
183
- $container.find('#btn-new-post').on('click', () => R.navigate('#/plugins/blog/posts/new'));
184
- $container.find('#filter-status').on('change', applyFilters);
185
- $container.find('#filter-category').on('change', applyFilters);
186
- $container.find('#filter-reset').on('click', () => {
187
- $container.find('#filter-status').val('');
188
- $container.find('#filter-category').val('');
189
- $container.find('#filter-search').val('');
190
- applyFilters();
206
+ async function copyLink(p) {
207
+ try {
208
+ await navigator.clipboard.writeText(`${location.origin}${site}/${p.slug}`);
209
+ E.toast('Link copied.', {type: 'success'});
210
+ } catch {
211
+ E.toast('The browser would not allow copying.', {type: 'error'});
212
+ }
213
+ }
214
+
215
+ // ---- Bindings ----
216
+ // Refs and help BEFORE binding: data-if lifts elements out of the document.
217
+ const list = root.querySelector('.blog-list');
218
+ root.querySelector('.blog-rows').innerHTML = ROW_TEMPLATE;
219
+ root.querySelector('.blog-chips').innerHTML = CHIP_TEMPLATE;
220
+ add(await kit.scanHelp(root));
221
+ const handle = M.applyBindings(data, root, {
222
+ methods: {
223
+ create: () => R.navigate('#/plugins/blog/posts/new'),
224
+ reset() {
225
+ status.value = '';
226
+ category.value = '';
227
+ query.value = '';
228
+ }
229
+ }
230
+ });
231
+ add(() => handle.dispose());
232
+
233
+ root.querySelector('.blog-chips').addEventListener('click', (e) => {
234
+ const chip = e.target.closest('.blog-chip');
235
+ if (chip) status.value = chip.dataset.status === 'all' ? '' : chip.dataset.status;
236
+ });
237
+
238
+ // ---- Context menu: every action on a post ----
239
+ const menu = E.contextMenu(list, {
240
+ match: '.blog-row',
241
+ inherit: false,
242
+ density: 'compact',
243
+ items: (row) => {
244
+ const p = byId(row.dataset.id);
245
+ if (!p) return [];
246
+ const live = p.status === 'published';
247
+ return kit.tidy([
248
+ {label: 'Edit', icon: 'edit', action: () => R.navigate(`#/plugins/blog/posts/${p.id}`)},
249
+ {label: 'View on site', icon: 'external-link', visible: live && Boolean(p.slug),
250
+ action: () => window.open(`${site}/${encodeURIComponent(p.slug)}`, '_blank', 'noopener')},
251
+ {label: 'Copy link', icon: 'link', visible: live && Boolean(p.slug), action: () => copyLink(p)},
252
+ {type: 'divider'},
253
+ {label: 'Publish now', icon: 'check-circle', visible: !live, action: () => publish(p.id)},
254
+ {label: 'Unpublish', icon: 'eye-off', visible: p.status !== 'draft', action: () => unpublish(p.id)},
255
+ {label: 'Duplicate as draft', icon: 'copy', action: () => duplicate(p.id)},
256
+ {type: 'divider'},
257
+ {label: 'Delete', icon: 'trash', danger: true, action: () => remove(p.id)}
258
+ ]);
259
+ }
191
260
  });
261
+ add(() => menu?.destroy?.());
192
262
 
193
- let searchTimer = null;
194
- $container.find('#filter-search').on('input', () => {
195
- clearTimeout(searchTimer);
196
- searchTimer = setTimeout(applyFilters, 250);
263
+ // ---- The bar's ⋮: the blog as a whole ----
264
+ // Adding and removing samples is admin-only on the server; ask it who
265
+ // this is rather than trusting whatever the browser cached at login.
266
+ let isAdmin = false;
267
+ H.get('/api/auth/me')
268
+ .then(me => { isAdmin = ['admin', 'super-admin'].includes((me?.data ?? me)?.role); })
269
+ .catch(() => { /* not shown, then */ });
270
+ const samples = () => posts.peek().filter(p => p.sample === true).length;
271
+ const barMenu = E.contextMenu(root.querySelector('.blog-bar-end'), {
272
+ match: '.blog-more',
273
+ inherit: false,
274
+ density: 'compact',
275
+ items: () => kit.tidy([
276
+ {label: 'View the blog', icon: 'external-link', action: () => window.open(site, '_blank', 'noopener')},
277
+ {label: 'Edit layouts in Blocks', icon: 'layout', action: () => R.navigate('#/blocks')},
278
+ {type: 'divider'},
279
+ {label: 'Add sample posts', icon: 'file-plus', visible: isAdmin && !samples(), action: addSamples},
280
+ {label: `Remove ${samples()} sample post${samples() === 1 ? '' : 's'}…`, icon: 'trash', danger: true,
281
+ visible: isAdmin && samples() > 0, action: removeSamples}
282
+ ])
283
+ });
284
+ add(() => barMenu?.destroy?.());
285
+ root.querySelector('.blog-bar-end').addEventListener('click', (e) => {
286
+ const more = e.target.closest('.blog-more');
287
+ if (more) kit.openMenuFrom(more);
288
+ });
289
+
290
+ async function addSamples() {
291
+ try {
292
+ const res = await H.post(`${BASE}/samples`, {});
293
+ if (res?.error) throw new Error(res.error);
294
+ E.toast(res.added ? `Added ${res.added} sample posts. Remove them from ⋮ whenever you like.` : 'The sample posts are already here.', {type: 'success'});
295
+ await load();
296
+ } catch (err) {
297
+ E.toast(err?.message ?? 'Could not add the sample posts.', {type: 'error'});
298
+ }
299
+ }
300
+
301
+ async function removeSamples() {
302
+ if (!(await E.confirm('Remove the sample posts, and any sample category nothing else uses? Your own posts are not touched.'))) return;
303
+ try {
304
+ const res = await H.delete(`${BASE}/samples`);
305
+ if (res?.error) throw new Error(res.error);
306
+ E.toast(`Removed ${res.posts} sample posts.`, {type: 'success'});
307
+ await load();
308
+ } catch (err) {
309
+ E.toast(err?.message ?? 'Could not remove the sample posts.', {type: 'error'});
310
+ }
311
+ }
312
+
313
+ list.addEventListener('click', (e) => {
314
+ const row = e.target.closest('.blog-row');
315
+ if (!row) return;
316
+ const kebab = e.target.closest('.blog-kebab');
317
+ if (kebab) return kit.openMenuFrom(kebab);
318
+ R.navigate(`#/plugins/blog/posts/${row.dataset.id}`);
319
+ });
320
+ list.addEventListener('keydown', (e) => {
321
+ const row = e.target.closest?.('.blog-row');
322
+ if (row && e.key === 'Enter' && e.target === row) R.navigate(`#/plugins/blog/posts/${row.dataset.id}`);
197
323
  });
198
324
 
199
- await reload();
200
- Domma.icons.scan();
325
+ if (typeof I !== 'undefined' && I.scan) I.scan(root);
326
+ await load();
201
327
  }
202
328
  };