domma-cms 0.55.1 → 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 (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -1,235 +1,241 @@
1
1
  /**
2
- * Blog categories admin view.
2
+ * Blog categories, on the Tool upgrade path.
3
+ *
4
+ * A bound list with how many posts use each category, so deleting one is not a
5
+ * guess. New and Edit open a (resizable) slideover rather than a modal; every
6
+ * row action is on its context menu (right-click, Shift+F10, or ⋮), including
7
+ * "Show its posts", which lands on Posts already filtered.
3
8
  *
4
9
  * @module blog/admin/views/categories
5
10
  */
6
11
 
7
- function escapeHtml(s) {
8
- return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
9
- }
10
-
11
- const BASE = '/api/plugins/blog';
12
+ // The loader's cache-busting token, passed on so helpers are refetched with this view.
13
+ const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
14
+
15
+ /** One row. In JS, not the template: the router's _.render would eat {{…}}. */
16
+ const ROW_TEMPLATE = `
17
+ <div class="blog-row" data-id="{{id}}" tabindex="0">
18
+ <span class="blog-row-main">
19
+ <strong class="blog-row-title">{{name}}</strong>
20
+ <small class="blog-row-sub">{{description}}</small>
21
+ </span>
22
+ <code class="blog-row-slug">{{slug}}</code>
23
+ <span class="blog-row-count" title="{{countTitle}}">{{count}}</span>
24
+ <button type="button" class="blog-kebab" aria-label="Actions" title="Actions">⋮</button>
25
+ </div>`;
26
+
27
+ const EDITOR = `
28
+ <div class="blog-panel">
29
+ <div class="blog-field">
30
+ <label class="form-label" for="blog-cat-name">Name</label>
31
+ <input id="blog-cat-name" type="text" class="form-input" data-model="name.value" data-focus="focusName.value">
32
+ </div>
33
+ <div class="blog-field">
34
+ <label class="form-label" for="blog-cat-slug">Slug
35
+ <span data-help="The category's address: /blog/category/<slug>. Filled from the name for a new category. Changing it breaks links to the old address."></span>
36
+ </label>
37
+ <input id="blog-cat-slug" type="text" class="form-input font-mono" data-model="slug.value">
38
+ </div>
39
+ <div class="blog-field">
40
+ <label class="form-label" for="blog-cat-desc">Description
41
+ <span data-help="Shown at the top of the category's page on the site. Optional."></span>
42
+ </label>
43
+ <textarea id="blog-cat-desc" class="form-textarea" rows="4" data-model="description.value"></textarea>
44
+ </div>
45
+ <div class="blog-panel-actions">
46
+ <span class="blog-spacer"></span>
47
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
48
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="saving.value">
49
+ <span data-icon="save" data-icon-size="13"></span> Save
50
+ </button>
51
+ </div>
52
+ </div>`;
53
+
54
+ const slugify = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
12
55
 
13
56
  export const categoriesView = {
14
57
  templateUrl: '/plugins/blog/admin/templates/categories.html',
15
58
 
16
59
  /**
17
- * Mount the blog categories view.
18
- *
19
60
  * @param {object} $container - Domma-wrapped container element
20
61
  * @returns {Promise<void>}
21
62
  */
22
63
  async onMount($container) {
23
- let categories = [];
24
- let editingId = null;
25
- let modal = null;
26
- let nameInput = null;
27
- let slugInput = null;
28
- let descInput = null;
29
-
30
- // ---------------------------------------------------------------
31
- // Modal setup (built with DOM APIs - not innerHTML)
32
- // ---------------------------------------------------------------
33
-
34
- function buildModal() {
35
- const form = document.createElement('div');
36
- form.style.display = 'flex';
37
- form.style.flexDirection = 'column';
38
- form.style.gap = '1rem';
39
-
40
- const mkField = (labelText, id, tag = 'input', rows) => {
41
- const group = document.createElement('div');
42
-
43
- const lbl = document.createElement('label');
44
- lbl.className = 'form-label';
45
- lbl.textContent = labelText;
46
- lbl.setAttribute('for', id);
47
- group.appendChild(lbl);
48
-
49
- const el = document.createElement(tag);
50
- el.id = id;
51
- el.className = 'form-control';
52
- if (tag === 'textarea' && rows) el.rows = rows;
53
- group.appendChild(el);
54
-
55
- return { group, el };
56
- };
57
-
58
- const { group: nameGroup, el: nameEl } = mkField('Name', 'cat-name-input');
59
- const { group: slugGroup, el: slugEl } = mkField('Slug', 'cat-slug-input');
60
- const { group: descGroup, el: descEl } = mkField('Description', 'cat-desc-input', 'textarea', 3);
61
-
62
- form.appendChild(nameGroup);
63
- form.appendChild(slugGroup);
64
- form.appendChild(descGroup);
65
-
66
- // Footer buttons
67
- const footer = document.createElement('div');
68
- footer.style.display = 'flex';
69
- footer.style.justifyContent = 'flex-end';
70
- footer.style.gap = '0.5rem';
71
- footer.style.marginTop = '0.5rem';
72
-
73
- const cancelBtn = document.createElement('button');
74
- cancelBtn.className = 'btn btn-secondary';
75
- cancelBtn.textContent = 'Cancel';
76
-
77
- const saveBtn = document.createElement('button');
78
- saveBtn.className = 'btn btn-primary';
79
- saveBtn.textContent = 'Save';
80
-
81
- footer.appendChild(cancelBtn);
82
- footer.appendChild(saveBtn);
83
- form.appendChild(footer);
84
-
85
- modal = E.modal({ title: 'Category' });
86
- modal.element.appendChild(form);
87
-
88
- nameInput = nameEl;
89
- slugInput = slugEl;
90
- descInput = descEl;
91
-
92
- // Auto-slug from name
93
- nameEl.addEventListener('input', () => {
94
- if (!editingId) {
95
- slugEl.value = nameEl.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
96
- }
97
- });
98
-
99
- cancelBtn.addEventListener('click', () => modal.close());
64
+ // Not awaited: the cog is chrome, and the screen should not wait on it.
65
+ import(`./settings-cog.js?v=${ASSET_VERSION}`)
66
+ .then(m => m.addSettingsCog($container, '.blog-bar'))
67
+ .catch(() => { /* no cog is better than no screen */ });
68
+
69
+ const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
70
+ const {BASE} = kit;
71
+ const add = kit.viewScope('categories', /^#\/plugins\/blog\/categories\b/);
72
+ const root = $container.get(0);
73
+
74
+ // ---- State ----
75
+ const categories = M.observable([]);
76
+ const posts = M.observable([]);
77
+ const loading = M.observable(true);
78
+ let site = '/blog';
79
+
80
+ const usage = (id) => posts.peek().filter(p => Array.isArray(p.categories) && p.categories.includes(id)).length;
81
+
82
+ const rows = M.observable([]);
83
+ add(M.effect(() => {
84
+ posts.value;
85
+ rows.value = [...categories.value]
86
+ .sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), undefined, {sensitivity: 'base'}))
87
+ .map(c => {
88
+ const n = usage(c.id);
89
+ return {
90
+ id: c.id, name: c.name || c.slug || 'Unnamed', slug: c.slug ?? '',
91
+ description: c.description ?? '',
92
+ count: `${n} post${n === 1 ? '' : 's'}`,
93
+ countTitle: n ? 'Right-click to show them' : 'No post uses this category'
94
+ };
95
+ });
96
+ }));
97
+
98
+ const data = {
99
+ rows, loading,
100
+ isEmpty: M.computed(() => !loading.value && rows.value.length === 0),
101
+ countText: M.computed(() => loading.value ? '' : `${categories.value.length} categor${categories.value.length === 1 ? 'y' : 'ies'}`)
102
+ };
103
+
104
+ // ---- Server ----
105
+ const byId = (id) => categories.peek().find(c => c.id === id);
106
+
107
+ async function load() {
108
+ loading.value = true;
109
+ try {
110
+ const [catsRes, postsRes] = await Promise.all([
111
+ H.get(`${BASE}/categories`),
112
+ H.get(`${BASE}/posts`).catch(() => [])
113
+ ]);
114
+ site = await kit.sitePath();
115
+ categories.value = catsRes?.data ?? (Array.isArray(catsRes) ? catsRes : []);
116
+ posts.value = postsRes?.data ?? (Array.isArray(postsRes) ? postsRes : []);
117
+ } catch {
118
+ E.toast('Failed to load categories.', {type: 'error'});
119
+ } finally {
120
+ loading.value = false;
121
+ }
122
+ }
100
123
 
101
- saveBtn.addEventListener('click', async () => {
102
- const name = nameEl.value.trim();
103
- const slug = slugEl.value.trim();
104
- if (!name) {
105
- E.toast('Name is required.', { type: 'error' });
106
- return;
107
- }
108
- const body = { name, slug, description: descInput.value.trim() };
109
- try {
110
- if (editingId) {
111
- const res = await H.put(`${BASE}/categories/${editingId}`, body);
112
- if (res.error) throw new Error(res.error);
113
- E.toast('Category updated.', { type: 'success' });
114
- } else {
115
- const res = await H.post(`${BASE}/categories`, body);
116
- if (res.error) throw new Error(res.error);
117
- E.toast('Category created.', { type: 'success' });
118
- }
119
- modal.close();
120
- await reload();
121
- } catch (err) {
122
- E.toast(err?.message ?? 'Failed to save category.', { type: 'error' });
123
- }
124
- });
124
+ async function remove(id) {
125
+ const n = usage(id);
126
+ const ok = await E.confirm(n
127
+ ? `Delete this category? ${n} post${n === 1 ? ' uses' : 's use'} it and will simply lose it.`
128
+ : 'Delete this category?');
129
+ if (!ok) return;
130
+ try {
131
+ await H.delete(`${BASE}/categories/${id}`);
132
+ categories.value = categories.peek().filter(c => c.id !== id);
133
+ E.toast('Category deleted.', {type: 'success'});
134
+ } catch {
135
+ E.toast('Failed to delete category.', {type: 'error'});
136
+ }
125
137
  }
126
138
 
127
- function openModal(cat = null) {
128
- editingId = cat?.id ?? null;
129
- if (nameInput) nameInput.value = cat?.name ?? '';
130
- if (slugInput) slugInput.value = cat?.slug ?? '';
131
- if (descInput) descInput.value = cat?.description ?? '';
132
- modal?.open();
139
+ function showPosts(id) {
140
+ kit.handOff('category', id);
141
+ R.navigate('#/plugins/blog/posts');
133
142
  }
134
143
 
135
- // ---------------------------------------------------------------
136
- // Table
137
- // ---------------------------------------------------------------
138
-
139
- function buildTable() {
140
- const tableEl = $container.find('#categories-table').get(0);
141
- if (!tableEl) return;
142
-
143
- T.create(tableEl, {
144
- data: categories,
145
- columns: [
146
- {
147
- key: 'name',
148
- title: 'Name',
149
- sortable: true,
150
- render: (v) => `<strong>${escapeHtml(v)}</strong>`
151
- },
152
- {
153
- key: 'slug',
154
- title: 'Slug',
155
- sortable: true,
156
- render: (v) => `<code>${escapeHtml(v)}</code>`
157
- },
158
- {
159
- key: 'description',
160
- title: 'Description',
161
- sortable: false,
162
- render: (v) => escapeHtml(v) || '<span style="opacity:0.4;">-</span>'
163
- },
164
- {
165
- key: 'id',
166
- title: 'Actions',
167
- sortable: false,
168
- render: (id) =>
169
- `<div style="display:flex;gap:.4rem;">` +
170
- `<button class="btn btn-sm btn-outline cat-edit-btn" data-id="${escapeHtml(id)}">` +
171
- `<span data-icon="edit" data-icon-size="14"></span> Edit</button>` +
172
- `<button class="btn btn-sm btn-danger cat-delete-btn" data-id="${escapeHtml(id)}">` +
173
- `<span data-icon="trash" data-icon-size="14"></span> Delete</button>` +
174
- `</div>`
175
- }
176
- ],
177
- emptyMessage: 'No categories yet.'
144
+ // ---- Editor: a slideover ----
145
+ async function openEditor(cat = null) {
146
+ const so = E.slideover({title: cat ? 'Edit category' : 'New category', size: 'sm', position: 'right'});
147
+ const name = M.observable(cat?.name ?? '');
148
+ const slug = M.observable(cat?.slug ?? '');
149
+ const description = M.observable(cat?.description ?? '');
150
+ const saving = M.observable(false);
151
+ const focusName = M.observable(false);
152
+
153
+ // A new category's slug follows its name until someone edits the slug.
154
+ let slugTouched = Boolean(cat);
155
+ const stopFollow = M.effect(() => {
156
+ const n = name.value;
157
+ if (!slugTouched) slug.value = slugify(n);
178
158
  });
179
159
 
180
- tableEl.addEventListener('click', async (e) => {
181
- const editBtn = e.target.closest('.cat-edit-btn');
182
- const deleteBtn = e.target.closest('.cat-delete-btn');
183
-
184
- if (editBtn) {
185
- const cat = categories.find((c) => c.id === editBtn.dataset.id);
186
- if (cat) openModal(cat);
187
- }
188
-
189
- if (deleteBtn) {
190
- const id = deleteBtn.dataset.id;
191
- const cat = categories.find((c) => c.id === id);
192
- const ok = await E.confirm(`Delete category "${cat?.name ?? id}"?`);
193
- if (!ok) return;
194
- try {
195
- await H.delete(`${BASE}/categories/${id}`);
196
- E.toast('Category deleted.', { type: 'success' });
197
- await reload();
198
- } catch {
199
- E.toast('Failed to delete category.', { type: 'error' });
160
+ const el = kit.fragment(EDITOR);
161
+ el.querySelector('#blog-cat-slug').addEventListener('input', () => { slugTouched = true; });
162
+ const disposeHelp = await kit.scanHelp(el);
163
+ const handle = M.applyBindings({name, slug, description, saving, focusName}, el, {
164
+ methods: {
165
+ cancel: () => so.close(),
166
+ async save() {
167
+ const body = {name: name.peek().trim(), slug: slug.peek().trim(), description: description.peek().trim()};
168
+ if (!body.name) {
169
+ E.toast('Name is required.', {type: 'error'});
170
+ return;
171
+ }
172
+ saving.value = true;
173
+ try {
174
+ const res = cat
175
+ ? await H.put(`${BASE}/categories/${cat.id}`, body)
176
+ : await H.post(`${BASE}/categories`, body);
177
+ if (res?.error) throw new Error(res.error);
178
+ E.toast(cat ? 'Category updated.' : 'Category created.', {type: 'success'});
179
+ so.close();
180
+ await load();
181
+ } catch (err) {
182
+ E.toast(err?.message ?? 'Failed to save category.', {type: 'error'});
183
+ } finally {
184
+ saving.value = false;
185
+ }
200
186
  }
201
187
  }
202
188
  });
203
-
204
- Domma.icons.scan();
189
+ el.addEventListener('keydown', (e) => {
190
+ if (e.key === 'Enter' && e.target.tagName === 'INPUT') { e.preventDefault(); el.querySelector('.btn-primary').click(); }
191
+ });
192
+ so.element.appendChild(el);
193
+ kit.onceClosed(so, () => { handle.dispose(); disposeHelp(); stopFollow(); });
194
+ if (typeof I !== 'undefined' && I.scan) I.scan(el);
195
+ so.open();
196
+ setTimeout(() => { focusName.value = true; }, 60);
205
197
  }
206
198
 
207
- // ---------------------------------------------------------------
208
- // Load
209
- // ---------------------------------------------------------------
210
-
211
- async function reload() {
212
- try {
213
- const res = await H.get(`${BASE}/categories`);
214
- if (res.error) throw new Error(res.error);
215
- categories = res.data ?? (Array.isArray(res) ? res : []);
216
- } catch {
217
- categories = [];
218
- E.toast('Failed to load categories.', { type: 'error' });
199
+ // ---- Bindings ----
200
+ const list = root.querySelector('.blog-list');
201
+ root.querySelector('.blog-rows').innerHTML = ROW_TEMPLATE;
202
+ add(await kit.scanHelp(root));
203
+ const handle = M.applyBindings(data, root, {methods: {create: () => openEditor(null)}});
204
+ add(() => handle.dispose());
205
+
206
+ const menu = E.contextMenu(list, {
207
+ match: '.blog-row',
208
+ inherit: false,
209
+ density: 'compact',
210
+ items: (row) => {
211
+ const c = byId(row.dataset.id);
212
+ if (!c) return [];
213
+ const n = usage(c.id);
214
+ return kit.tidy([
215
+ {label: 'Edit', icon: 'edit', action: () => openEditor(c)},
216
+ {label: `Show its posts (${n})`, icon: 'filter', disabled: n === 0, action: () => showPosts(c.id)},
217
+ {label: 'View on site', icon: 'external-link', visible: Boolean(c.slug),
218
+ action: () => window.open(`${site}/category/${encodeURIComponent(c.slug)}`, '_blank', 'noopener')},
219
+ {type: 'divider'},
220
+ {label: 'Delete', icon: 'trash', danger: true, action: () => remove(c.id)}
221
+ ]);
219
222
  }
220
- buildTable();
221
- }
222
-
223
- // ---------------------------------------------------------------
224
- // Wire up
225
- // ---------------------------------------------------------------
226
-
227
- buildModal();
228
-
229
- const newBtn = $container.find('#btn-new-category').get(0);
230
- if (newBtn) newBtn.addEventListener('click', () => openModal(null));
231
-
232
- await reload();
233
- Domma.icons.scan();
223
+ });
224
+ add(() => menu?.destroy?.());
225
+
226
+ list.addEventListener('click', (e) => {
227
+ const row = e.target.closest('.blog-row');
228
+ if (!row) return;
229
+ const kebab = e.target.closest('.blog-kebab');
230
+ if (kebab) return kit.openMenuFrom(kebab);
231
+ openEditor(byId(row.dataset.id));
232
+ });
233
+ list.addEventListener('keydown', (e) => {
234
+ const row = e.target.closest?.('.blog-row');
235
+ if (row && e.key === 'Enter' && e.target === row) openEditor(byId(row.dataset.id));
236
+ });
237
+
238
+ if (typeof I !== 'undefined' && I.scan) I.scan(root);
239
+ await load();
234
240
  }
235
241
  };