domma-cms 0.66.0 → 0.69.4

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 +94 -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 +310 -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,230 +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.className = 'blog-modal-form';
37
-
38
- const mkField = (labelText, id, tag = 'input', rows) => {
39
- const group = document.createElement('div');
40
-
41
- const lbl = document.createElement('label');
42
- lbl.className = 'form-label';
43
- lbl.textContent = labelText;
44
- lbl.setAttribute('for', id);
45
- group.appendChild(lbl);
46
-
47
- const el = document.createElement(tag);
48
- el.id = id;
49
- el.className = tag === 'textarea' ? 'form-textarea' : 'form-input';
50
- if (tag === 'textarea' && rows) el.rows = rows;
51
- group.appendChild(el);
52
-
53
- return { group, el };
54
- };
55
-
56
- const { group: nameGroup, el: nameEl } = mkField('Name', 'cat-name-input');
57
- const { group: slugGroup, el: slugEl } = mkField('Slug', 'cat-slug-input');
58
- const { group: descGroup, el: descEl } = mkField('Description', 'cat-desc-input', 'textarea', 3);
59
-
60
- form.appendChild(nameGroup);
61
- form.appendChild(slugGroup);
62
- form.appendChild(descGroup);
63
-
64
- // Footer buttons
65
- const footer = document.createElement('div');
66
- footer.className = 'blog-modal-actions';
67
-
68
- const cancelBtn = document.createElement('button');
69
- cancelBtn.className = 'btn btn-secondary';
70
- cancelBtn.textContent = 'Cancel';
71
-
72
- const saveBtn = document.createElement('button');
73
- saveBtn.className = 'btn btn-primary';
74
- saveBtn.textContent = 'Save';
75
-
76
- footer.appendChild(cancelBtn);
77
- footer.appendChild(saveBtn);
78
- form.appendChild(footer);
79
-
80
- modal = E.modal({ title: 'Category' });
81
- modal.element.appendChild(form);
82
-
83
- nameInput = nameEl;
84
- slugInput = slugEl;
85
- descInput = descEl;
86
-
87
- // Auto-slug from name
88
- nameEl.addEventListener('input', () => {
89
- if (!editingId) {
90
- slugEl.value = nameEl.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
91
- }
92
- });
93
-
94
- 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
+ }
95
123
 
96
- saveBtn.addEventListener('click', async () => {
97
- const name = nameEl.value.trim();
98
- const slug = slugEl.value.trim();
99
- if (!name) {
100
- E.toast('Name is required.', { type: 'error' });
101
- return;
102
- }
103
- const body = { name, slug, description: descInput.value.trim() };
104
- try {
105
- if (editingId) {
106
- const res = await H.put(`${BASE}/categories/${editingId}`, body);
107
- if (res.error) throw new Error(res.error);
108
- E.toast('Category updated.', { type: 'success' });
109
- } else {
110
- const res = await H.post(`${BASE}/categories`, body);
111
- if (res.error) throw new Error(res.error);
112
- E.toast('Category created.', { type: 'success' });
113
- }
114
- modal.close();
115
- await reload();
116
- } catch (err) {
117
- E.toast(err?.message ?? 'Failed to save category.', { type: 'error' });
118
- }
119
- });
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
+ }
120
137
  }
121
138
 
122
- function openModal(cat = null) {
123
- editingId = cat?.id ?? null;
124
- if (nameInput) nameInput.value = cat?.name ?? '';
125
- if (slugInput) slugInput.value = cat?.slug ?? '';
126
- if (descInput) descInput.value = cat?.description ?? '';
127
- modal?.open();
139
+ function showPosts(id) {
140
+ kit.handOff('category', id);
141
+ R.navigate('#/plugins/blog/posts');
128
142
  }
129
143
 
130
- // ---------------------------------------------------------------
131
- // Table
132
- // ---------------------------------------------------------------
133
-
134
- function buildTable() {
135
- const tableEl = $container.find('#categories-table').get(0);
136
- if (!tableEl) return;
137
-
138
- T.create(tableEl, {
139
- data: categories,
140
- columns: [
141
- {
142
- key: 'name',
143
- title: 'Name',
144
- sortable: true,
145
- render: (v) => `<strong>${escapeHtml(v)}</strong>`
146
- },
147
- {
148
- key: 'slug',
149
- title: 'Slug',
150
- sortable: true,
151
- render: (v) => `<code>${escapeHtml(v)}</code>`
152
- },
153
- {
154
- key: 'description',
155
- title: 'Description',
156
- sortable: false,
157
- render: (v) => escapeHtml(v) || '<span class="blog-empty-cell">-</span>'
158
- },
159
- {
160
- key: 'id',
161
- title: 'Actions',
162
- sortable: false,
163
- render: (id) =>
164
- `<div class="blog-row-actions">` +
165
- `<button class="btn btn-sm btn-outline cat-edit-btn" data-id="${escapeHtml(id)}">` +
166
- `<span data-icon="edit" data-icon-size="14"></span> Edit</button>` +
167
- `<button class="btn btn-sm btn-danger cat-delete-btn" data-id="${escapeHtml(id)}">` +
168
- `<span data-icon="trash" data-icon-size="14"></span> Delete</button>` +
169
- `</div>`
170
- }
171
- ],
172
- 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);
173
158
  });
174
159
 
175
- tableEl.addEventListener('click', async (e) => {
176
- const editBtn = e.target.closest('.cat-edit-btn');
177
- const deleteBtn = e.target.closest('.cat-delete-btn');
178
-
179
- if (editBtn) {
180
- const cat = categories.find((c) => c.id === editBtn.dataset.id);
181
- if (cat) openModal(cat);
182
- }
183
-
184
- if (deleteBtn) {
185
- const id = deleteBtn.dataset.id;
186
- const cat = categories.find((c) => c.id === id);
187
- const ok = await E.confirm(`Delete category "${cat?.name ?? id}"?`);
188
- if (!ok) return;
189
- try {
190
- await H.delete(`${BASE}/categories/${id}`);
191
- E.toast('Category deleted.', { type: 'success' });
192
- await reload();
193
- } catch {
194
- 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
+ }
195
186
  }
196
187
  }
197
188
  });
198
-
199
- 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);
200
197
  }
201
198
 
202
- // ---------------------------------------------------------------
203
- // Load
204
- // ---------------------------------------------------------------
205
-
206
- async function reload() {
207
- try {
208
- const res = await H.get(`${BASE}/categories`);
209
- if (res.error) throw new Error(res.error);
210
- categories = res.data ?? (Array.isArray(res) ? res : []);
211
- } catch {
212
- categories = [];
213
- 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
+ ]);
214
222
  }
215
- buildTable();
216
- }
217
-
218
- // ---------------------------------------------------------------
219
- // Wire up
220
- // ---------------------------------------------------------------
221
-
222
- buildModal();
223
-
224
- const newBtn = $container.find('#btn-new-category').get(0);
225
- if (newBtn) newBtn.addEventListener('click', () => openModal(null));
226
-
227
- await reload();
228
- 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();
229
240
  }
230
241
  };