domma-cms 0.9.10 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CLAUDE.md +248 -159
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +7 -3
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/http-interceptor.js +1 -0
  7. package/admin/js/lib/card-builder.js +2 -2
  8. package/admin/js/lib/markdown-toolbar.js +5 -5
  9. package/admin/js/lib/safe-html.js +1 -0
  10. package/admin/js/lib/shortcode-modal.js +1 -0
  11. package/admin/js/templates/layouts.html +5 -4
  12. package/admin/js/templates/notifications.html +14 -0
  13. package/admin/js/templates/plugin-marketplace.html +16 -0
  14. package/admin/js/templates/plugins.html +17 -5
  15. package/admin/js/views/index.js +1 -1
  16. package/admin/js/views/layouts.js +1 -16
  17. package/admin/js/views/notifications.js +1 -0
  18. package/admin/js/views/page-editor.js +37 -33
  19. package/admin/js/views/plugin-marketplace.js +1 -0
  20. package/admin/js/views/plugins.js +16 -16
  21. package/config/navigation.json +5 -72
  22. package/config/plugins.json +10 -14
  23. package/config/presets.json +50 -13
  24. package/config/site.json +11 -63
  25. package/package.json +2 -1
  26. package/plugins/_template/admin/templates/index.html +17 -0
  27. package/plugins/_template/admin/views/index.js +19 -0
  28. package/plugins/_template/config.js +8 -0
  29. package/plugins/_template/plugin.js +23 -0
  30. package/plugins/_template/plugin.json +34 -0
  31. package/plugins/analytics/plugin.json +41 -31
  32. package/plugins/blog/admin/templates/blog.html +22 -0
  33. package/plugins/blog/admin/templates/categories.html +7 -0
  34. package/plugins/blog/admin/templates/comments.html +11 -0
  35. package/plugins/blog/admin/templates/post-editor.html +97 -0
  36. package/plugins/blog/admin/templates/settings.html +11 -0
  37. package/plugins/blog/admin/views/blog.js +183 -0
  38. package/plugins/blog/admin/views/categories.js +235 -0
  39. package/plugins/blog/admin/views/comments.js +187 -0
  40. package/plugins/blog/admin/views/post-editor.js +291 -0
  41. package/plugins/blog/admin/views/settings.js +100 -0
  42. package/plugins/blog/collections/categories/schema.json +12 -0
  43. package/plugins/blog/collections/comments/schema.json +16 -0
  44. package/plugins/blog/collections/posts/schema.json +19 -0
  45. package/plugins/blog/config.js +8 -0
  46. package/plugins/blog/plugin.js +352 -0
  47. package/plugins/blog/plugin.json +96 -0
  48. package/plugins/blog/roles/blog-author.json +10 -0
  49. package/plugins/blog/roles/blog-editor.json +12 -0
  50. package/plugins/blog/templates/author.html +9 -0
  51. package/plugins/blog/templates/category.html +9 -0
  52. package/plugins/blog/templates/index.html +9 -0
  53. package/plugins/blog/templates/post.html +17 -0
  54. package/plugins/blog/templates/tag.html +9 -0
  55. package/plugins/contacts/collections/user-contact-groups/schema.json +1 -1
  56. package/plugins/contacts/collections/user-contacts/schema.json +1 -1
  57. package/plugins/contacts/plugin.js +4 -10
  58. package/plugins/contacts/plugin.json +13 -3
  59. package/plugins/notes/collections/user-notes/schema.json +1 -1
  60. package/plugins/notes/plugin.js +3 -9
  61. package/plugins/notes/plugin.json +13 -3
  62. package/plugins/site-search/plugin.json +5 -2
  63. package/plugins/theme-switcher/plugin.json +1 -1
  64. package/plugins/todo/collections/todos/schema.json +1 -1
  65. package/plugins/todo/plugin.js +3 -9
  66. package/plugins/todo/plugin.json +13 -3
  67. package/public/css/site.css +1 -1
  68. package/public/js/site.js +1 -1
  69. package/scripts/build.js +48 -0
  70. package/scripts/create-plugin.js +113 -0
  71. package/scripts/fresh.js +6 -7
  72. package/scripts/gen-instance-secret.js +46 -0
  73. package/scripts/reset.js +3 -3
  74. package/scripts/setup.js +31 -13
  75. package/server/middleware/auth.js +48 -0
  76. package/server/middleware/managerAuth.js +36 -0
  77. package/server/routes/api/actions.js +1 -1
  78. package/server/routes/api/auth.js +4 -3
  79. package/server/routes/api/layouts.js +173 -49
  80. package/server/routes/api/notifications.js +155 -0
  81. package/server/routes/api/plugin-marketplace.js +75 -0
  82. package/server/routes/api/users.js +1 -1
  83. package/server/routes/api/views.js +1 -1
  84. package/server/routes/public.js +4 -9
  85. package/server/server.js +32 -3
  86. package/server/services/actions.js +1 -1
  87. package/server/services/managerClient.js +182 -0
  88. package/server/services/markdown.js +76 -9
  89. package/server/services/permissionRegistry.js +245 -173
  90. package/server/services/pluginInstaller.js +301 -0
  91. package/server/services/plugins.js +117 -10
  92. package/server/services/presetCollections.js +66 -251
  93. package/server/services/renderer.js +99 -0
  94. package/server/services/roles.js +191 -39
  95. package/server/services/users.js +1 -1
  96. package/server/services/views.js +1 -1
  97. package/server/templates/page.html +2 -2
  98. package/plugins/docs/admin/templates/docs.html +0 -69
  99. package/plugins/docs/admin/views/docs.js +0 -276
  100. package/plugins/docs/config.js +0 -8
  101. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +0 -11
  102. package/plugins/docs/data/folders.json +0 -9
  103. package/plugins/docs/data/templates.json +0 -1
  104. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +0 -5
  105. package/plugins/docs/plugin.js +0 -375
  106. package/plugins/docs/plugin.json +0 -23
  107. package/plugins/form-builder/data/forms/contacts.json +0 -66
  108. package/plugins/form-builder/data/forms/enquiries.json +0 -103
  109. package/plugins/form-builder/data/forms/feedback.json +0 -131
  110. package/plugins/form-builder/data/forms/notes.json +0 -79
  111. package/plugins/form-builder/data/forms/to-do.json +0 -100
  112. package/plugins/form-builder/data/submissions/contacts.json +0 -1
  113. package/plugins/form-builder/data/submissions/enquiries.json +0 -1
  114. package/plugins/form-builder/data/submissions/feedback.json +0 -1
  115. package/plugins/form-builder/data/submissions/notes.json +0 -1
  116. package/plugins/form-builder/data/submissions/to-do.json +0 -1
  117. package/plugins/garage/admin/templates/garage.html +0 -111
  118. package/plugins/garage/admin/views/garage.js +0 -622
  119. package/plugins/garage/collections/garage-vehicles/schema.json +0 -101
  120. package/plugins/garage/config.js +0 -18
  121. package/plugins/garage/data/vehicles.json +0 -70
  122. package/plugins/garage/plugin.js +0 -398
  123. package/plugins/garage/plugin.json +0 -33
  124. package/scripts/seed.js +0 -1996
  125. package/server/services/userTypes.js +0 -227
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Blog comments admin view.
3
+ *
4
+ * @module blog/admin/views/comments
5
+ */
6
+
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
+
13
+ export const commentsView = {
14
+ templateUrl: '/plugins/blog/admin/templates/comments.html',
15
+
16
+ /**
17
+ * Mount the blog comments view.
18
+ *
19
+ * @param {object} $container - Domma-wrapped container element
20
+ * @returns {Promise<void>}
21
+ */
22
+ async onMount($container) {
23
+ let currentTab = 'pending';
24
+ let comments = [];
25
+ let commentsTable = null;
26
+
27
+ // ---------------------------------------------------------------
28
+ // Load + render
29
+ // ---------------------------------------------------------------
30
+
31
+ async function loadComments() {
32
+ try {
33
+ const res = await H.get(`${BASE}/comments?status=${encodeURIComponent(currentTab)}`);
34
+ if (res.error) throw new Error(res.error);
35
+ comments = res.data ?? (Array.isArray(res) ? res : []);
36
+ } catch {
37
+ comments = [];
38
+ E.toast('Failed to load comments.', { type: 'error' });
39
+ }
40
+ buildTable();
41
+ }
42
+
43
+ function buildTable() {
44
+ const tableEl = $container.find('#comments-table').get(0);
45
+ if (!tableEl) return;
46
+
47
+ if (commentsTable) {
48
+ commentsTable.setData(comments);
49
+ Domma.icons.scan();
50
+ return;
51
+ }
52
+
53
+ commentsTable = T.create(tableEl, {
54
+ data: comments,
55
+ columns: [
56
+ {
57
+ key: 'authorName',
58
+ title: 'Author',
59
+ sortable: true,
60
+ render: (v) => `<strong>${escapeHtml(v)}</strong>`
61
+ },
62
+ {
63
+ key: 'authorEmail',
64
+ title: 'Email',
65
+ sortable: false,
66
+ render: (v) => v
67
+ ? `<a href="mailto:${escapeHtml(v)}">${escapeHtml(v)}</a>`
68
+ : '<span style="opacity:0.4;">—</span>'
69
+ },
70
+ {
71
+ key: 'body',
72
+ title: 'Comment',
73
+ sortable: false,
74
+ render: (v) => {
75
+ const preview = String(v ?? '').slice(0, 80);
76
+ const ellipsis = String(v ?? '').length > 80 ? '\u2026' : '';
77
+ return `<span style="font-size:0.87rem;">${escapeHtml(preview)}${ellipsis}</span>`;
78
+ }
79
+ },
80
+ {
81
+ key: 'postId',
82
+ title: 'Post',
83
+ sortable: false,
84
+ render: (v) => v
85
+ ? `<a href="#/plugins/blog/posts/${escapeHtml(v)}">${escapeHtml(v)}</a>`
86
+ : '<span style="opacity:0.4;">—</span>'
87
+ },
88
+ {
89
+ key: 'createdAt',
90
+ title: 'Date',
91
+ sortable: true,
92
+ render: (v) => v
93
+ ? `<span title="${escapeHtml(D(v).format('DD MMM YYYY HH:mm'))}">${escapeHtml(D(v).fromNow())}</span>`
94
+ : '<span style="opacity:0.4;">—</span>'
95
+ },
96
+ {
97
+ key: 'id',
98
+ title: 'Actions',
99
+ sortable: false,
100
+ render: (id) => {
101
+ const btns = [];
102
+ if (currentTab !== 'approved') {
103
+ btns.push(`<button class="btn btn-sm btn-success comment-approve-btn" data-id="${escapeHtml(id)}">Approve</button>`);
104
+ }
105
+ if (currentTab !== 'spam') {
106
+ btns.push(`<button class="btn btn-sm btn-warning comment-spam-btn" data-id="${escapeHtml(id)}">Spam</button>`);
107
+ }
108
+ if (currentTab !== 'rejected') {
109
+ btns.push(`<button class="btn btn-sm btn-outline comment-reject-btn" data-id="${escapeHtml(id)}">Reject</button>`);
110
+ }
111
+ btns.push(`<button class="btn btn-sm btn-danger comment-delete-btn" data-id="${escapeHtml(id)}">` +
112
+ `<span data-icon="trash" data-icon-size="14"></span></button>`);
113
+ return `<div style="display:flex;gap:.3rem;flex-wrap:wrap;">${btns.join('')}</div>`;
114
+ }
115
+ }
116
+ ],
117
+ emptyMessage: `No ${currentTab} comments.`
118
+ });
119
+
120
+ // Event delegation — attach once
121
+ if (!tableEl.dataset.listenerAttached) {
122
+ tableEl.dataset.listenerAttached = '1';
123
+ tableEl.addEventListener('click', async (e) => {
124
+ const approveBtn = e.target.closest('.comment-approve-btn');
125
+ const rejectBtn = e.target.closest('.comment-reject-btn');
126
+ const spamBtn = e.target.closest('.comment-spam-btn');
127
+ const deleteBtn = e.target.closest('.comment-delete-btn');
128
+
129
+ if (approveBtn) await updateStatus(approveBtn.dataset.id, 'approved');
130
+ if (rejectBtn) await updateStatus(rejectBtn.dataset.id, 'rejected');
131
+ if (spamBtn) await updateStatus(spamBtn.dataset.id, 'spam');
132
+
133
+ if (deleteBtn) {
134
+ const ok = await E.confirm('Delete this comment?');
135
+ if (!ok) return;
136
+ try {
137
+ await H.delete(`${BASE}/comments/${deleteBtn.dataset.id}`);
138
+ E.toast('Comment deleted.', { type: 'success' });
139
+ await loadComments();
140
+ } catch {
141
+ E.toast('Failed to delete comment.', { type: 'error' });
142
+ }
143
+ }
144
+ });
145
+ }
146
+
147
+ Domma.icons.scan();
148
+ }
149
+
150
+ async function updateStatus(commentId, status) {
151
+ try {
152
+ const res = await H.put(`${BASE}/comments/${commentId}`, { status });
153
+ if (res.error) throw new Error(res.error);
154
+ E.toast(`Comment marked as ${status}.`, { type: 'success' });
155
+ await loadComments();
156
+ } catch {
157
+ E.toast('Failed to update comment.', { type: 'error' });
158
+ }
159
+ }
160
+
161
+ // ---------------------------------------------------------------
162
+ // Tab switching
163
+ // ---------------------------------------------------------------
164
+
165
+ const tabList = $container.find('.tab-list').get(0);
166
+ if (tabList) {
167
+ tabList.addEventListener('click', async (e) => {
168
+ const tab = e.target.closest('.tab-item');
169
+ if (!tab) return;
170
+ currentTab = tab.dataset.tab;
171
+
172
+ // Update active class
173
+ tabList.querySelectorAll('.tab-item').forEach((t) => {
174
+ t.classList.toggle('active', t === tab);
175
+ });
176
+
177
+ await loadComments();
178
+ });
179
+ }
180
+
181
+ // ---------------------------------------------------------------
182
+ // Initial load
183
+ // ---------------------------------------------------------------
184
+ await loadComments();
185
+ Domma.icons.scan();
186
+ }
187
+ };
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Blog post editor view — create and edit posts.
3
+ *
4
+ * @module blog/admin/views/post-editor
5
+ */
6
+
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
+ function slugify(s) {
12
+ return String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
13
+ }
14
+
15
+ const BASE = '/api/plugins/blog';
16
+
17
+ export const postEditorView = {
18
+ templateUrl: '/plugins/blog/admin/templates/post-editor.html',
19
+
20
+ /**
21
+ * Mount the post editor view.
22
+ *
23
+ * @param {object} $container - Domma-wrapped container element
24
+ * @returns {Promise<void>}
25
+ */
26
+ async onMount($container) {
27
+ // Resolve post id from router params
28
+ const id = R.current?.params?.id !== 'new' ? R.current?.params?.id : null;
29
+ const isEdit = Boolean(id);
30
+
31
+ let post = null;
32
+ let categories = [];
33
+ let selectedCategoryIds = [];
34
+
35
+ // ---------------------------------------------------------------
36
+ // Load data
37
+ // ---------------------------------------------------------------
38
+
39
+ try {
40
+ const [catsRes] = await Promise.all([
41
+ H.get(`${BASE}/categories`).catch(() => ({ data: [] }))
42
+ ]);
43
+ categories = catsRes.data ?? catsRes ?? [];
44
+ } catch {
45
+ categories = [];
46
+ }
47
+
48
+ if (isEdit) {
49
+ try {
50
+ const res = await H.get(`${BASE}/posts/${id}`);
51
+ if (res.error) throw new Error(res.error);
52
+ post = res.data ?? res;
53
+ } catch {
54
+ E.toast('Failed to load post.', { type: 'error' });
55
+ R.navigate('#/plugins/blog');
56
+ return;
57
+ }
58
+ }
59
+
60
+ // ---------------------------------------------------------------
61
+ // DOM refs
62
+ // ---------------------------------------------------------------
63
+
64
+ const titleEl = $container.find('#editor-title').get(0);
65
+ const postTitleEl = $container.find('#post-title').get(0);
66
+ const postSlugEl = $container.find('#post-slug').get(0);
67
+ const slugAutoEl = $container.find('#slug-auto').get(0);
68
+ const slugWarnEl = $container.find('#slug-warning').get(0);
69
+ const excerptEl = $container.find('#post-excerpt').get(0);
70
+ const contentEl = $container.find('#post-content').get(0);
71
+ const tagsEl = $container.find('#post-tags').get(0);
72
+ const seoTitleEl = $container.find('#seo-title').get(0);
73
+ const seoDescEl = $container.find('#seo-description').get(0);
74
+ const statusBadge = $container.find('#post-status-badge').get(0);
75
+ const scheduleEl = $container.find('#schedule-picker').get(0);
76
+ const schedAtEl = $container.find('#post-scheduled-at').get(0);
77
+ const catsListEl = $container.find('#categories-checklist').get(0);
78
+
79
+ // ---------------------------------------------------------------
80
+ // Populate
81
+ // ---------------------------------------------------------------
82
+
83
+ if (titleEl) titleEl.textContent = isEdit ? 'Edit Post' : 'New Post';
84
+
85
+ if (isEdit && post) {
86
+ if (postTitleEl) postTitleEl.value = post.title ?? '';
87
+ if (postSlugEl) postSlugEl.value = post.slug ?? '';
88
+ if (excerptEl) excerptEl.value = post.excerpt ?? '';
89
+ if (contentEl) contentEl.value = post.content ?? '';
90
+ if (tagsEl) tagsEl.value = Array.isArray(post.tags) ? post.tags.join(', ') : (post.tags ?? '');
91
+ if (seoTitleEl) seoTitleEl.value = post.seoTitle ?? '';
92
+ if (seoDescEl) seoDescEl.value = post.seoDescription ?? '';
93
+ if (statusBadge) statusBadge.textContent = post.status ?? 'draft';
94
+ selectedCategoryIds = Array.isArray(post.categoryIds) ? [...post.categoryIds] : [];
95
+ } else {
96
+ // New post: enable auto-slug by default
97
+ if (slugAutoEl) slugAutoEl.checked = true;
98
+ }
99
+
100
+ // Build categories checklist using DOM APIs (not innerHTML — interactive elements)
101
+ if (catsListEl) {
102
+ while (catsListEl.firstChild) catsListEl.removeChild(catsListEl.firstChild);
103
+
104
+ if (categories.length === 0) {
105
+ const p = document.createElement('p');
106
+ p.className = 'text-muted small';
107
+ p.textContent = 'No categories yet.';
108
+ catsListEl.appendChild(p);
109
+ } else {
110
+ categories.forEach((cat) => {
111
+ const label = document.createElement('label');
112
+ label.className = 'd-flex align-items-center gap-2 mb-2';
113
+ label.style.cursor = 'pointer';
114
+
115
+ const cb = document.createElement('input');
116
+ cb.type = 'checkbox';
117
+ cb.value = cat.id;
118
+ cb.checked = selectedCategoryIds.includes(cat.id);
119
+ cb.addEventListener('change', () => {
120
+ if (cb.checked) {
121
+ if (!selectedCategoryIds.includes(cat.id)) selectedCategoryIds.push(cat.id);
122
+ } else {
123
+ selectedCategoryIds = selectedCategoryIds.filter((x) => x !== cat.id);
124
+ }
125
+ });
126
+
127
+ const span = document.createElement('span');
128
+ span.textContent = cat.name ?? cat.slug ?? cat.id;
129
+
130
+ label.appendChild(cb);
131
+ label.appendChild(span);
132
+ catsListEl.appendChild(label);
133
+ });
134
+ }
135
+ }
136
+
137
+ // ---------------------------------------------------------------
138
+ // Collect form values
139
+ // ---------------------------------------------------------------
140
+
141
+ function collectValues() {
142
+ return {
143
+ title: postTitleEl?.value?.trim() ?? '',
144
+ slug: postSlugEl?.value?.trim() ?? '',
145
+ excerpt: excerptEl?.value?.trim() ?? '',
146
+ content: contentEl?.value ?? '',
147
+ tags: (tagsEl?.value ?? '').split(',').map((t) => t.trim()).filter(Boolean),
148
+ categoryIds: selectedCategoryIds,
149
+ seoTitle: seoTitleEl?.value?.trim() ?? '',
150
+ seoDescription: seoDescEl?.value?.trim() ?? ''
151
+ };
152
+ }
153
+
154
+ // ---------------------------------------------------------------
155
+ // Save helpers
156
+ // ---------------------------------------------------------------
157
+
158
+ async function doSave(extra = {}) {
159
+ const body = { ...collectValues(), ...extra };
160
+ if (!body.title) {
161
+ E.toast('Title is required.', { type: 'error' });
162
+ return null;
163
+ }
164
+ try {
165
+ let saved;
166
+ if (isEdit) {
167
+ const res = await H.put(`${BASE}/posts/${id}`, body);
168
+ if (res.error) throw new Error(res.error);
169
+ saved = res.data ?? res;
170
+ } else {
171
+ const res = await H.post(`${BASE}/posts`, body);
172
+ if (res.error) throw new Error(res.error);
173
+ saved = res.data ?? res;
174
+ }
175
+ return saved;
176
+ } catch (err) {
177
+ E.toast(err?.message ?? 'Failed to save post.', { type: 'error' });
178
+ return null;
179
+ }
180
+ }
181
+
182
+ // ---------------------------------------------------------------
183
+ // Event bindings
184
+ // ---------------------------------------------------------------
185
+
186
+ // Auto-slug from title
187
+ if (postTitleEl) {
188
+ postTitleEl.addEventListener('input', () => {
189
+ if (slugAutoEl?.checked) {
190
+ if (postSlugEl) postSlugEl.value = slugify(postTitleEl.value);
191
+ }
192
+ });
193
+ }
194
+
195
+ // Slug-auto checkbox
196
+ if (slugAutoEl) {
197
+ slugAutoEl.addEventListener('change', () => {
198
+ if (!slugAutoEl.checked && isEdit && post?.status === 'published') {
199
+ if (slugWarnEl) slugWarnEl.style.display = '';
200
+ } else {
201
+ if (slugWarnEl) slugWarnEl.style.display = 'none';
202
+ }
203
+ if (slugAutoEl.checked && postTitleEl) {
204
+ if (postSlugEl) postSlugEl.value = slugify(postTitleEl.value);
205
+ }
206
+ });
207
+ }
208
+
209
+ // SEO panel toggle
210
+ const seoToggle = $container.find('#seo-toggle').get(0);
211
+ const seoPanel = $container.find('#seo-panel').get(0);
212
+ if (seoToggle && seoPanel) {
213
+ seoToggle.addEventListener('click', () => {
214
+ seoPanel.style.display = seoPanel.style.display === 'none' ? '' : 'none';
215
+ });
216
+ }
217
+
218
+ // Save draft
219
+ const saveDraftBtn = $container.find('#btn-save-draft').get(0);
220
+ if (saveDraftBtn) {
221
+ saveDraftBtn.addEventListener('click', async () => {
222
+ const saved = await doSave({ status: 'draft' });
223
+ if (saved) {
224
+ E.toast('Draft saved.', { type: 'success' });
225
+ R.navigate('#/plugins/blog');
226
+ }
227
+ });
228
+ }
229
+
230
+ // Publish now
231
+ const publishBtn = $container.find('#btn-publish').get(0);
232
+ if (publishBtn) {
233
+ publishBtn.addEventListener('click', async () => {
234
+ const saved = await doSave();
235
+ if (!saved) return;
236
+ const savedId = saved.id ?? id;
237
+ try {
238
+ const res = await H.post(`${BASE}/posts/${savedId}/publish`);
239
+ if (res.error) throw new Error(res.error);
240
+ E.toast('Post published.', { type: 'success' });
241
+ R.navigate('#/plugins/blog');
242
+ } catch (err) {
243
+ E.toast(err?.message ?? 'Failed to publish post.', { type: 'error' });
244
+ }
245
+ });
246
+ }
247
+
248
+ // Schedule
249
+ const scheduleBtn = $container.find('#btn-schedule').get(0);
250
+ if (scheduleBtn) {
251
+ scheduleBtn.style.display = '';
252
+ scheduleBtn.addEventListener('click', async () => {
253
+ // Toggle schedule picker visibility
254
+ if (scheduleEl) scheduleEl.style.display = scheduleEl.style.display === 'none' ? '' : 'none';
255
+
256
+ // If picker is now visible, wait for user to confirm
257
+ if (scheduleEl && scheduleEl.style.display !== 'none') return;
258
+
259
+ const publishedAt = schedAtEl?.value;
260
+ if (!publishedAt) {
261
+ E.toast('Please select a scheduled date/time.', { type: 'error' });
262
+ if (scheduleEl) scheduleEl.style.display = '';
263
+ return;
264
+ }
265
+
266
+ const saved = await doSave();
267
+ if (!saved) return;
268
+ const savedId = saved.id ?? id;
269
+ try {
270
+ const res = await H.post(`${BASE}/posts/${savedId}/schedule`, { publishedAt });
271
+ if (res.error) throw new Error(res.error);
272
+ E.toast('Post scheduled.', { type: 'success' });
273
+ R.navigate('#/plugins/blog');
274
+ } catch (err) {
275
+ E.toast(err?.message ?? 'Failed to schedule post.', { type: 'error' });
276
+ }
277
+ });
278
+ }
279
+
280
+ // Pick image (TBD)
281
+ const pickImageBtn = $container.find('#btn-pick-image').get(0);
282
+ if (pickImageBtn) {
283
+ pickImageBtn.addEventListener('click', () => {
284
+ console.log('media picker TBD');
285
+ E.toast('Media picker coming soon.', { type: 'info' });
286
+ });
287
+ }
288
+
289
+ Domma.icons.scan();
290
+ }
291
+ };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Blog settings admin view.
3
+ *
4
+ * @module blog/admin/views/settings
5
+ */
6
+
7
+ const BASE = '/api/plugins/blog';
8
+
9
+ export const settingsView = {
10
+ templateUrl: '/plugins/blog/admin/templates/settings.html',
11
+
12
+ /**
13
+ * Mount the blog settings view.
14
+ *
15
+ * @param {object} $container - Domma-wrapped container element
16
+ * @returns {Promise<void>}
17
+ */
18
+ async onMount($container) {
19
+ let settings = {};
20
+
21
+ // ---------------------------------------------------------------
22
+ // Load
23
+ // ---------------------------------------------------------------
24
+
25
+ try {
26
+ const res = await H.get(`${BASE}/settings`);
27
+ if (res.error) throw new Error(res.error);
28
+ settings = res.data ?? res ?? {};
29
+ } catch {
30
+ E.toast('Failed to load settings.', { type: 'error' });
31
+ }
32
+
33
+ // ---------------------------------------------------------------
34
+ // Render form using F.create
35
+ // ---------------------------------------------------------------
36
+
37
+ const formEl = $container.find('#settings-form').get(0);
38
+ if (!formEl) return;
39
+
40
+ F.create(formEl, {
41
+ blueprint: [
42
+ {
43
+ key: 'basePath',
44
+ label: 'Base path',
45
+ type: 'text',
46
+ placeholder: '/blog',
47
+ value: settings.basePath ?? '/blog',
48
+ hint: 'URL prefix for all blog pages (e.g. /blog).'
49
+ },
50
+ {
51
+ key: 'postsPerPage',
52
+ label: 'Posts per page',
53
+ type: 'number',
54
+ placeholder: '10',
55
+ value: settings.postsPerPage ?? 10,
56
+ hint: 'Number of posts shown per listing page.'
57
+ },
58
+ {
59
+ key: 'commentsEnabled',
60
+ label: 'Enable comments',
61
+ type: 'toggle',
62
+ value: settings.commentsEnabled ?? true,
63
+ hint: 'Allow readers to submit comments on posts.'
64
+ },
65
+ {
66
+ key: 'commentModeration',
67
+ label: 'Moderate comments',
68
+ type: 'toggle',
69
+ value: settings.commentModeration ?? true,
70
+ hint: 'Hold new comments for approval before they appear.'
71
+ },
72
+ {
73
+ key: 'feedLength',
74
+ label: 'RSS feed length',
75
+ type: 'number',
76
+ placeholder: '20',
77
+ value: settings.feedLength ?? 20,
78
+ hint: 'Maximum number of items in the RSS/Atom feed.'
79
+ }
80
+ ],
81
+ onSubmit: async (values) => {
82
+ try {
83
+ const res = await H.put(`${BASE}/settings`, {
84
+ basePath: values.basePath,
85
+ postsPerPage: Number(values.postsPerPage) || 10,
86
+ commentsEnabled: Boolean(values.commentsEnabled),
87
+ commentModeration: Boolean(values.commentModeration),
88
+ feedLength: Number(values.feedLength) || 20
89
+ });
90
+ if (res.error) throw new Error(res.error);
91
+ E.toast('Settings saved.', { type: 'success' });
92
+ } catch (err) {
93
+ E.toast(err?.message ?? 'Failed to save settings.', { type: 'error' });
94
+ }
95
+ }
96
+ });
97
+
98
+ Domma.icons.scan();
99
+ }
100
+ };
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "blog-categories",
3
+ "label": "Blog Categories",
4
+ "slug": "blog-categories",
5
+ "storage": { "adapter": "file" },
6
+ "fields": [
7
+ { "key": "name", "label": "Name", "type": "text", "required": true },
8
+ { "key": "slug", "label": "Slug", "type": "text", "required": true },
9
+ { "key": "description", "label": "Description", "type": "textarea", "required": false },
10
+ { "key": "parentId", "label": "Parent", "type": "text", "required": false }
11
+ ]
12
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "blog-comments",
3
+ "label": "Blog Comments",
4
+ "slug": "blog-comments",
5
+ "storage": { "adapter": "file" },
6
+ "fields": [
7
+ { "key": "postId", "label": "Post", "type": "text", "required": true },
8
+ { "key": "parentId", "label": "Parent", "type": "text", "required": false },
9
+ { "key": "authorName", "label": "Author Name", "type": "text", "required": true },
10
+ { "key": "authorEmail", "label": "Author Email", "type": "text", "required": true },
11
+ { "key": "authorUrl", "label": "Author URL", "type": "text", "required": false },
12
+ { "key": "body", "label": "Body", "type": "textarea", "required": true },
13
+ { "key": "status", "label": "Status", "type": "select", "required": true, "options": ["pending", "approved", "rejected", "spam"] },
14
+ { "key": "createdAt", "label": "Created At", "type": "datetime", "required": false }
15
+ ]
16
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "blog-posts",
3
+ "label": "Blog Posts",
4
+ "slug": "blog-posts",
5
+ "storage": { "adapter": "file" },
6
+ "fields": [
7
+ { "key": "title", "label": "Title", "type": "text", "required": true },
8
+ { "key": "slug", "label": "Slug", "type": "text", "required": true },
9
+ { "key": "excerpt", "label": "Excerpt", "type": "textarea", "required": false },
10
+ { "key": "content", "label": "Content", "type": "markdown", "required": false },
11
+ { "key": "featuredImage", "label": "Featured Image", "type": "text", "required": false },
12
+ { "key": "categories", "label": "Categories", "type": "array", "required": false },
13
+ { "key": "tags", "label": "Tags", "type": "array", "required": false },
14
+ { "key": "authorId", "label": "Author", "type": "text", "required": true },
15
+ { "key": "status", "label": "Status", "type": "select", "required": true, "options": ["draft", "scheduled", "published"] },
16
+ { "key": "publishedAt", "label": "Published At", "type": "datetime", "required": false },
17
+ { "key": "seo", "label": "SEO", "type": "object", "required": false }
18
+ ]
19
+ }
@@ -0,0 +1,8 @@
1
+ export default {
2
+ basePath: '/blog',
3
+ postsPerPage: 10,
4
+ commentsEnabled: false,
5
+ commentModeration: true,
6
+ feedLength: 20,
7
+ storage: { adapter: 'file' }
8
+ };