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.
- package/CLAUDE.md +248 -159
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +7 -3
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/http-interceptor.js +1 -0
- package/admin/js/lib/card-builder.js +2 -2
- package/admin/js/lib/markdown-toolbar.js +5 -5
- package/admin/js/lib/safe-html.js +1 -0
- package/admin/js/lib/shortcode-modal.js +1 -0
- package/admin/js/templates/layouts.html +5 -4
- package/admin/js/templates/notifications.html +14 -0
- package/admin/js/templates/plugin-marketplace.html +16 -0
- package/admin/js/templates/plugins.html +17 -5
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/layouts.js +1 -16
- package/admin/js/views/notifications.js +1 -0
- package/admin/js/views/page-editor.js +37 -33
- package/admin/js/views/plugin-marketplace.js +1 -0
- package/admin/js/views/plugins.js +16 -16
- package/config/navigation.json +5 -72
- package/config/plugins.json +10 -14
- package/config/presets.json +50 -13
- package/config/site.json +11 -63
- package/package.json +2 -1
- package/plugins/_template/admin/templates/index.html +17 -0
- package/plugins/_template/admin/views/index.js +19 -0
- package/plugins/_template/config.js +8 -0
- package/plugins/_template/plugin.js +23 -0
- package/plugins/_template/plugin.json +34 -0
- package/plugins/analytics/plugin.json +41 -31
- package/plugins/blog/admin/templates/blog.html +22 -0
- package/plugins/blog/admin/templates/categories.html +7 -0
- package/plugins/blog/admin/templates/comments.html +11 -0
- package/plugins/blog/admin/templates/post-editor.html +97 -0
- package/plugins/blog/admin/templates/settings.html +11 -0
- package/plugins/blog/admin/views/blog.js +183 -0
- package/plugins/blog/admin/views/categories.js +235 -0
- package/plugins/blog/admin/views/comments.js +187 -0
- package/plugins/blog/admin/views/post-editor.js +291 -0
- package/plugins/blog/admin/views/settings.js +100 -0
- package/plugins/blog/collections/categories/schema.json +12 -0
- package/plugins/blog/collections/comments/schema.json +16 -0
- package/plugins/blog/collections/posts/schema.json +19 -0
- package/plugins/blog/config.js +8 -0
- package/plugins/blog/plugin.js +352 -0
- package/plugins/blog/plugin.json +96 -0
- package/plugins/blog/roles/blog-author.json +10 -0
- package/plugins/blog/roles/blog-editor.json +12 -0
- package/plugins/blog/templates/author.html +9 -0
- package/plugins/blog/templates/category.html +9 -0
- package/plugins/blog/templates/index.html +9 -0
- package/plugins/blog/templates/post.html +17 -0
- package/plugins/blog/templates/tag.html +9 -0
- package/plugins/contacts/collections/user-contact-groups/schema.json +1 -1
- package/plugins/contacts/collections/user-contacts/schema.json +1 -1
- package/plugins/contacts/plugin.js +4 -10
- package/plugins/contacts/plugin.json +13 -3
- package/plugins/notes/collections/user-notes/schema.json +1 -1
- package/plugins/notes/plugin.js +3 -9
- package/plugins/notes/plugin.json +13 -3
- package/plugins/site-search/plugin.json +5 -2
- package/plugins/theme-switcher/plugin.json +1 -1
- package/plugins/todo/collections/todos/schema.json +1 -1
- package/plugins/todo/plugin.js +3 -9
- package/plugins/todo/plugin.json +13 -3
- package/public/css/site.css +1 -1
- package/public/js/site.js +1 -1
- package/scripts/build.js +48 -0
- package/scripts/create-plugin.js +113 -0
- package/scripts/fresh.js +6 -7
- package/scripts/gen-instance-secret.js +46 -0
- package/scripts/reset.js +3 -3
- package/scripts/setup.js +31 -13
- package/server/middleware/auth.js +48 -0
- package/server/middleware/managerAuth.js +36 -0
- package/server/routes/api/actions.js +1 -1
- package/server/routes/api/auth.js +4 -3
- package/server/routes/api/layouts.js +173 -49
- package/server/routes/api/notifications.js +155 -0
- package/server/routes/api/plugin-marketplace.js +75 -0
- package/server/routes/api/users.js +1 -1
- package/server/routes/api/views.js +1 -1
- package/server/routes/public.js +4 -9
- package/server/server.js +32 -3
- package/server/services/actions.js +1 -1
- package/server/services/managerClient.js +182 -0
- package/server/services/markdown.js +76 -9
- package/server/services/permissionRegistry.js +245 -173
- package/server/services/pluginInstaller.js +301 -0
- package/server/services/plugins.js +117 -10
- package/server/services/presetCollections.js +66 -251
- package/server/services/renderer.js +99 -0
- package/server/services/roles.js +191 -39
- package/server/services/users.js +1 -1
- package/server/services/views.js +1 -1
- package/server/templates/page.html +2 -2
- package/plugins/docs/admin/templates/docs.html +0 -69
- package/plugins/docs/admin/views/docs.js +0 -276
- package/plugins/docs/config.js +0 -8
- package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +0 -11
- package/plugins/docs/data/folders.json +0 -9
- package/plugins/docs/data/templates.json +0 -1
- package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +0 -5
- package/plugins/docs/plugin.js +0 -375
- package/plugins/docs/plugin.json +0 -23
- package/plugins/form-builder/data/forms/contacts.json +0 -66
- package/plugins/form-builder/data/forms/enquiries.json +0 -103
- package/plugins/form-builder/data/forms/feedback.json +0 -131
- package/plugins/form-builder/data/forms/notes.json +0 -79
- package/plugins/form-builder/data/forms/to-do.json +0 -100
- package/plugins/form-builder/data/submissions/contacts.json +0 -1
- package/plugins/form-builder/data/submissions/enquiries.json +0 -1
- package/plugins/form-builder/data/submissions/feedback.json +0 -1
- package/plugins/form-builder/data/submissions/notes.json +0 -1
- package/plugins/form-builder/data/submissions/to-do.json +0 -1
- package/plugins/garage/admin/templates/garage.html +0 -111
- package/plugins/garage/admin/views/garage.js +0 -622
- package/plugins/garage/collections/garage-vehicles/schema.json +0 -101
- package/plugins/garage/config.js +0 -18
- package/plugins/garage/data/vehicles.json +0 -70
- package/plugins/garage/plugin.js +0 -398
- package/plugins/garage/plugin.json +0 -33
- package/scripts/seed.js +0 -1996
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
+
}
|