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,97 @@
|
|
|
1
|
+
<div class="view-header d-flex align-items-center justify-content-between mb-4">
|
|
2
|
+
<h1 class="h3 mb-0" id="editor-title">New Post</h1>
|
|
3
|
+
<div class="d-flex gap-2">
|
|
4
|
+
<button id="btn-save-draft" class="btn btn-secondary">Save Draft</button>
|
|
5
|
+
<button id="btn-schedule" class="btn btn-warning" style="display:none;">Schedule</button>
|
|
6
|
+
<button id="btn-publish" class="btn btn-primary">Publish Now</button>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="d-flex gap-4" style="align-items:flex-start;">
|
|
11
|
+
<!-- Main content area -->
|
|
12
|
+
<div style="flex:1; min-width:0;">
|
|
13
|
+
<div class="card mb-3">
|
|
14
|
+
<div class="card-body">
|
|
15
|
+
<div class="mb-3">
|
|
16
|
+
<label class="form-label fw-bold">Title</label>
|
|
17
|
+
<input type="text" id="post-title" class="form-control form-control-lg" placeholder="Post title…">
|
|
18
|
+
</div>
|
|
19
|
+
<div class="mb-3">
|
|
20
|
+
<label class="form-label">Slug</label>
|
|
21
|
+
<div class="d-flex gap-2 align-items-center">
|
|
22
|
+
<input type="text" id="post-slug" class="form-control font-monospace">
|
|
23
|
+
<label class="d-flex align-items-center gap-1 text-muted small" style="white-space:nowrap;">
|
|
24
|
+
<input type="checkbox" id="slug-auto"> Auto
|
|
25
|
+
</label>
|
|
26
|
+
</div>
|
|
27
|
+
<div id="slug-warning" class="text-warning small mt-1" style="display:none;">
|
|
28
|
+
⚠ Changing slug breaks existing links
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="mb-3">
|
|
32
|
+
<label class="form-label">Excerpt</label>
|
|
33
|
+
<textarea id="post-excerpt" class="form-control" rows="2" placeholder="Short summary…"></textarea>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="mb-3">
|
|
36
|
+
<label class="form-label">Content</label>
|
|
37
|
+
<textarea id="post-content" class="form-control font-monospace" rows="20" placeholder="Write in Markdown…"></textarea>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- SEO accordion -->
|
|
43
|
+
<div class="card mb-3">
|
|
44
|
+
<div class="card-header" id="seo-toggle" style="cursor:pointer;">
|
|
45
|
+
SEO <span class="text-muted small">(click to expand)</span>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="card-body" id="seo-panel" style="display:none;">
|
|
48
|
+
<div class="mb-3">
|
|
49
|
+
<label class="form-label">SEO Title</label>
|
|
50
|
+
<input type="text" id="seo-title" class="form-control" placeholder="Overrides page title">
|
|
51
|
+
</div>
|
|
52
|
+
<div class="mb-3">
|
|
53
|
+
<label class="form-label">SEO Description</label>
|
|
54
|
+
<textarea id="seo-description" class="form-control" rows="2" placeholder="Overrides excerpt for meta description"></textarea>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Sidebar -->
|
|
61
|
+
<div style="width:280px; flex-shrink:0;">
|
|
62
|
+
<div class="card mb-3">
|
|
63
|
+
<div class="card-header">Status</div>
|
|
64
|
+
<div class="card-body">
|
|
65
|
+
<div id="schedule-picker" style="display:none;" class="mb-3">
|
|
66
|
+
<label class="form-label">Scheduled for</label>
|
|
67
|
+
<input type="datetime-local" id="post-scheduled-at" class="form-control">
|
|
68
|
+
</div>
|
|
69
|
+
<span id="post-status-badge" class="badge bg-secondary">draft</span>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="card mb-3">
|
|
74
|
+
<div class="card-header">Categories</div>
|
|
75
|
+
<div class="card-body" id="categories-checklist">
|
|
76
|
+
<p class="text-muted small">Loading…</p>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div class="card mb-3">
|
|
81
|
+
<div class="card-header">Tags</div>
|
|
82
|
+
<div class="card-body">
|
|
83
|
+
<input type="text" id="post-tags" class="form-control" placeholder="tag1, tag2, …">
|
|
84
|
+
<div class="text-muted small mt-1">Comma-separated</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div class="card mb-3">
|
|
89
|
+
<div class="card-header">Featured Image</div>
|
|
90
|
+
<div class="card-body">
|
|
91
|
+
<div id="featured-image-preview" class="mb-2"></div>
|
|
92
|
+
<button id="btn-pick-image" class="btn btn-sm btn-secondary">Pick Image</button>
|
|
93
|
+
<button id="btn-clear-image" class="btn btn-sm btn-outline-secondary" style="display:none;">Clear</button>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<div class="view-header mb-4">
|
|
2
|
+
<h1 class="h3 mb-0">Blog Settings</h1>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<div class="alert alert-warning mb-4">
|
|
6
|
+
Base path and posts-per-page changes require a server restart to take effect.
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="card" style="max-width:600px;">
|
|
10
|
+
<div class="card-body" id="settings-form"></div>
|
|
11
|
+
</div>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog posts list view.
|
|
3
|
+
*
|
|
4
|
+
* @module blog/admin/views/blog
|
|
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
|
+
const STATUS_BADGE = {
|
|
14
|
+
draft: 'badge-secondary',
|
|
15
|
+
scheduled: 'badge-warning',
|
|
16
|
+
published: 'badge-success'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const blogView = {
|
|
20
|
+
templateUrl: '/plugins/blog/admin/templates/blog.html',
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mount the blog posts list view.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} $container - Domma-wrapped container element
|
|
26
|
+
* @returns {Promise<void>}
|
|
27
|
+
*/
|
|
28
|
+
async onMount($container) {
|
|
29
|
+
let allPosts = [];
|
|
30
|
+
let postsTable = null;
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------
|
|
33
|
+
// Table
|
|
34
|
+
// ---------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function buildTable(data) {
|
|
37
|
+
const tableEl = $container.find('#posts-table').get(0);
|
|
38
|
+
if (!tableEl) return;
|
|
39
|
+
|
|
40
|
+
if (postsTable) {
|
|
41
|
+
postsTable.setData(data);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
postsTable = T.create(tableEl, {
|
|
46
|
+
data,
|
|
47
|
+
columns: [
|
|
48
|
+
{
|
|
49
|
+
key: 'title',
|
|
50
|
+
title: 'Title',
|
|
51
|
+
sortable: true,
|
|
52
|
+
render: (v, row) =>
|
|
53
|
+
`<a href="#/plugins/blog/posts/${escapeHtml(row.id)}" style="font-weight:600;">${escapeHtml(v || 'Untitled')}</a>`
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
key: 'status',
|
|
57
|
+
title: 'Status',
|
|
58
|
+
sortable: true,
|
|
59
|
+
render: (v) => {
|
|
60
|
+
const cls = STATUS_BADGE[v] || 'badge-secondary';
|
|
61
|
+
return `<span class="badge ${cls}">${escapeHtml(v ?? 'draft')}</span>`;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
key: 'authorId',
|
|
66
|
+
title: 'Author',
|
|
67
|
+
sortable: false,
|
|
68
|
+
render: (v) => escapeHtml(v) || '—'
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
key: 'publishedAt',
|
|
72
|
+
title: 'Published',
|
|
73
|
+
sortable: true,
|
|
74
|
+
render: (v) => v
|
|
75
|
+
? `<span title="${escapeHtml(D(v).format('DD MMM YYYY HH:mm'))}">${escapeHtml(D(v).format('DD MMM YYYY'))}</span>`
|
|
76
|
+
: '<span style="opacity:0.4;">—</span>'
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
key: 'id',
|
|
80
|
+
title: 'Actions',
|
|
81
|
+
sortable: false,
|
|
82
|
+
render: (id) =>
|
|
83
|
+
`<div style="display:flex;gap:.4rem;">` +
|
|
84
|
+
`<button class="btn btn-sm btn-outline post-edit-btn" data-id="${escapeHtml(id)}">` +
|
|
85
|
+
`<span data-icon="edit" data-icon-size="14"></span> Edit</button>` +
|
|
86
|
+
`<button class="btn btn-sm btn-danger post-delete-btn" data-id="${escapeHtml(id)}">` +
|
|
87
|
+
`<span data-icon="trash" data-icon-size="14"></span> Delete</button>` +
|
|
88
|
+
`</div>`
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
emptyMessage: 'No posts found.'
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Event delegation for action buttons
|
|
95
|
+
tableEl.addEventListener('click', async (e) => {
|
|
96
|
+
const editBtn = e.target.closest('.post-edit-btn');
|
|
97
|
+
const deleteBtn = e.target.closest('.post-delete-btn');
|
|
98
|
+
|
|
99
|
+
if (editBtn) {
|
|
100
|
+
R.navigate('#/plugins/blog/posts/' + editBtn.dataset.id);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (deleteBtn) {
|
|
104
|
+
const id = deleteBtn.dataset.id;
|
|
105
|
+
const post = allPosts.find((p) => p.id === id);
|
|
106
|
+
const ok = await E.confirm(`Delete "${post?.title ?? 'this post'}"?`);
|
|
107
|
+
if (!ok) return;
|
|
108
|
+
try {
|
|
109
|
+
await H.delete(`${BASE}/posts/${id}`);
|
|
110
|
+
E.toast('Post deleted.', { type: 'success' });
|
|
111
|
+
await reload();
|
|
112
|
+
} catch {
|
|
113
|
+
E.toast('Failed to delete post.', { type: 'error' });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
Domma.icons.scan();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------
|
|
122
|
+
// Filter
|
|
123
|
+
// ---------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
function applyFilters() {
|
|
126
|
+
const statusEl = $container.find('#filter-status').get(0);
|
|
127
|
+
const searchEl = $container.find('#filter-search').get(0);
|
|
128
|
+
const status = statusEl?.value ?? '';
|
|
129
|
+
const query = (searchEl?.value ?? '').toLowerCase().trim();
|
|
130
|
+
|
|
131
|
+
let filtered = allPosts;
|
|
132
|
+
if (status) filtered = filtered.filter((p) => p.status === status);
|
|
133
|
+
if (query) {
|
|
134
|
+
filtered = filtered.filter((p) =>
|
|
135
|
+
(p.title ?? '').toLowerCase().includes(query) ||
|
|
136
|
+
(p.excerpt ?? '').toLowerCase().includes(query)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
buildTable(filtered);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------
|
|
143
|
+
// Load
|
|
144
|
+
// ---------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
async function reload() {
|
|
147
|
+
try {
|
|
148
|
+
const res = await H.get(`${BASE}/posts`);
|
|
149
|
+
if (res.error) throw new Error(res.error);
|
|
150
|
+
allPosts = res.data ?? [];
|
|
151
|
+
} catch {
|
|
152
|
+
allPosts = [];
|
|
153
|
+
E.toast('Failed to load posts.', { type: 'error' });
|
|
154
|
+
}
|
|
155
|
+
applyFilters();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------
|
|
159
|
+
// Event bindings
|
|
160
|
+
// ---------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
const newPostBtn = $container.find('#btn-new-post').get(0);
|
|
163
|
+
if (newPostBtn) newPostBtn.addEventListener('click', () => R.navigate('#/plugins/blog/posts/new'));
|
|
164
|
+
|
|
165
|
+
const filterStatus = $container.find('#filter-status').get(0);
|
|
166
|
+
if (filterStatus) filterStatus.addEventListener('change', applyFilters);
|
|
167
|
+
|
|
168
|
+
const filterSearch = $container.find('#filter-search').get(0);
|
|
169
|
+
if (filterSearch) {
|
|
170
|
+
let searchTimer = null;
|
|
171
|
+
filterSearch.addEventListener('input', () => {
|
|
172
|
+
clearTimeout(searchTimer);
|
|
173
|
+
searchTimer = setTimeout(applyFilters, 250);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------
|
|
178
|
+
// Initial load
|
|
179
|
+
// ---------------------------------------------------------------
|
|
180
|
+
await reload();
|
|
181
|
+
Domma.icons.scan();
|
|
182
|
+
}
|
|
183
|
+
};
|
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
+
};
|