domma-cms 0.10.0 → 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 (119) 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/safe-html.js +1 -0
  8. package/admin/js/templates/layouts.html +5 -4
  9. package/admin/js/templates/notifications.html +14 -0
  10. package/admin/js/templates/plugin-marketplace.html +16 -0
  11. package/admin/js/templates/plugins.html +17 -5
  12. package/admin/js/views/index.js +1 -1
  13. package/admin/js/views/layouts.js +1 -16
  14. package/admin/js/views/notifications.js +1 -0
  15. package/admin/js/views/plugin-marketplace.js +1 -0
  16. package/admin/js/views/plugins.js +16 -16
  17. package/config/navigation.json +5 -72
  18. package/config/plugins.json +10 -14
  19. package/config/presets.json +50 -13
  20. package/config/site.json +11 -63
  21. package/package.json +2 -1
  22. package/plugins/_template/admin/templates/index.html +17 -0
  23. package/plugins/_template/admin/views/index.js +19 -0
  24. package/plugins/_template/config.js +8 -0
  25. package/plugins/_template/plugin.js +23 -0
  26. package/plugins/_template/plugin.json +34 -0
  27. package/plugins/analytics/plugin.json +41 -31
  28. package/plugins/blog/admin/templates/blog.html +22 -0
  29. package/plugins/blog/admin/templates/categories.html +7 -0
  30. package/plugins/blog/admin/templates/comments.html +11 -0
  31. package/plugins/blog/admin/templates/post-editor.html +97 -0
  32. package/plugins/blog/admin/templates/settings.html +11 -0
  33. package/plugins/blog/admin/views/blog.js +183 -0
  34. package/plugins/blog/admin/views/categories.js +235 -0
  35. package/plugins/blog/admin/views/comments.js +187 -0
  36. package/plugins/blog/admin/views/post-editor.js +291 -0
  37. package/plugins/blog/admin/views/settings.js +100 -0
  38. package/plugins/blog/collections/categories/schema.json +12 -0
  39. package/plugins/blog/collections/comments/schema.json +16 -0
  40. package/plugins/blog/collections/posts/schema.json +19 -0
  41. package/plugins/blog/config.js +8 -0
  42. package/plugins/blog/plugin.js +352 -0
  43. package/plugins/blog/plugin.json +96 -0
  44. package/plugins/blog/roles/blog-author.json +10 -0
  45. package/plugins/blog/roles/blog-editor.json +12 -0
  46. package/plugins/blog/templates/author.html +9 -0
  47. package/plugins/blog/templates/category.html +9 -0
  48. package/plugins/blog/templates/index.html +9 -0
  49. package/plugins/blog/templates/post.html +17 -0
  50. package/plugins/blog/templates/tag.html +9 -0
  51. package/plugins/contacts/collections/user-contact-groups/schema.json +1 -1
  52. package/plugins/contacts/collections/user-contacts/schema.json +1 -1
  53. package/plugins/contacts/plugin.js +4 -10
  54. package/plugins/contacts/plugin.json +13 -3
  55. package/plugins/notes/collections/user-notes/schema.json +1 -1
  56. package/plugins/notes/plugin.js +3 -9
  57. package/plugins/notes/plugin.json +13 -3
  58. package/plugins/site-search/plugin.json +5 -2
  59. package/plugins/theme-switcher/plugin.json +1 -1
  60. package/plugins/todo/collections/todos/schema.json +1 -1
  61. package/plugins/todo/plugin.js +3 -9
  62. package/plugins/todo/plugin.json +13 -3
  63. package/public/css/site.css +1 -1
  64. package/scripts/build.js +48 -0
  65. package/scripts/create-plugin.js +113 -0
  66. package/scripts/fresh.js +6 -7
  67. package/scripts/gen-instance-secret.js +46 -0
  68. package/scripts/reset.js +3 -3
  69. package/scripts/setup.js +31 -13
  70. package/server/middleware/auth.js +48 -0
  71. package/server/middleware/managerAuth.js +36 -0
  72. package/server/routes/api/actions.js +1 -1
  73. package/server/routes/api/auth.js +4 -3
  74. package/server/routes/api/layouts.js +173 -49
  75. package/server/routes/api/notifications.js +155 -0
  76. package/server/routes/api/plugin-marketplace.js +75 -0
  77. package/server/routes/api/users.js +1 -1
  78. package/server/routes/api/views.js +1 -1
  79. package/server/routes/public.js +4 -9
  80. package/server/server.js +32 -3
  81. package/server/services/actions.js +1 -1
  82. package/server/services/managerClient.js +182 -0
  83. package/server/services/permissionRegistry.js +245 -173
  84. package/server/services/pluginInstaller.js +301 -0
  85. package/server/services/plugins.js +117 -10
  86. package/server/services/presetCollections.js +66 -251
  87. package/server/services/renderer.js +99 -0
  88. package/server/services/roles.js +191 -39
  89. package/server/services/users.js +1 -1
  90. package/server/services/views.js +1 -1
  91. package/server/templates/page.html +2 -2
  92. package/plugins/docs/admin/templates/docs.html +0 -69
  93. package/plugins/docs/admin/views/docs.js +0 -276
  94. package/plugins/docs/config.js +0 -8
  95. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +0 -11
  96. package/plugins/docs/data/folders.json +0 -9
  97. package/plugins/docs/data/templates.json +0 -1
  98. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +0 -5
  99. package/plugins/docs/plugin.js +0 -375
  100. package/plugins/docs/plugin.json +0 -23
  101. package/plugins/form-builder/data/forms/contacts.json +0 -66
  102. package/plugins/form-builder/data/forms/enquiries.json +0 -103
  103. package/plugins/form-builder/data/forms/feedback.json +0 -131
  104. package/plugins/form-builder/data/forms/notes.json +0 -79
  105. package/plugins/form-builder/data/forms/to-do.json +0 -100
  106. package/plugins/form-builder/data/submissions/contacts.json +0 -1
  107. package/plugins/form-builder/data/submissions/enquiries.json +0 -1
  108. package/plugins/form-builder/data/submissions/feedback.json +0 -1
  109. package/plugins/form-builder/data/submissions/notes.json +0 -1
  110. package/plugins/form-builder/data/submissions/to-do.json +0 -1
  111. package/plugins/garage/admin/templates/garage.html +0 -111
  112. package/plugins/garage/admin/views/garage.js +0 -622
  113. package/plugins/garage/collections/garage-vehicles/schema.json +0 -101
  114. package/plugins/garage/config.js +0 -18
  115. package/plugins/garage/data/vehicles.json +0 -70
  116. package/plugins/garage/plugin.js +0 -398
  117. package/plugins/garage/plugin.json +0 -33
  118. package/scripts/seed.js +0 -1996
  119. package/server/services/userTypes.js +0 -227
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Blog categories admin view.
3
+ *
4
+ * @module blog/admin/views/categories
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 categoriesView = {
14
+ templateUrl: '/plugins/blog/admin/templates/categories.html',
15
+
16
+ /**
17
+ * Mount the blog categories view.
18
+ *
19
+ * @param {object} $container - Domma-wrapped container element
20
+ * @returns {Promise<void>}
21
+ */
22
+ 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());
100
+
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
+ });
125
+ }
126
+
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();
133
+ }
134
+
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.'
178
+ });
179
+
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' });
200
+ }
201
+ }
202
+ });
203
+
204
+ Domma.icons.scan();
205
+ }
206
+
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' });
219
+ }
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();
234
+ }
235
+ };
@@ -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
+ };