domma-cms 0.15.0 → 0.16.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 (31) hide show
  1. package/package.json +2 -2
  2. package/plugins/analytics/admin/templates/analytics.html +52 -1
  3. package/plugins/analytics/admin/views/analytics.js +157 -32
  4. package/plugins/analytics/config.js +10 -2
  5. package/plugins/analytics/plugin.js +214 -25
  6. package/plugins/analytics/plugin.json +9 -5
  7. package/plugins/analytics/public/inject-body.html +25 -7
  8. package/plugins/blog/admin/templates/blog.html +25 -2
  9. package/plugins/blog/admin/views/blog.js +72 -56
  10. package/plugins/blog/admin/views/post-editor.js +98 -79
  11. package/plugins/blog/plugin.js +133 -0
  12. package/plugins/blog/plugin.json +3 -3
  13. package/plugins/blog/templates/post.html +2 -1
  14. package/plugins/invoice/admin/templates/editor.html +129 -0
  15. package/plugins/invoice/admin/templates/index.html +43 -0
  16. package/plugins/invoice/admin/templates/issuers.html +5 -0
  17. package/plugins/invoice/admin/templates/receivers.html +5 -0
  18. package/plugins/invoice/admin/views/editor.js +267 -0
  19. package/plugins/invoice/admin/views/index.js +155 -0
  20. package/plugins/invoice/admin/views/issuers.js +23 -0
  21. package/plugins/invoice/admin/views/party-view.js +148 -0
  22. package/plugins/invoice/admin/views/receivers.js +22 -0
  23. package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
  24. package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
  25. package/plugins/invoice/collections/invoices/schema.json +27 -0
  26. package/plugins/invoice/config.js +16 -0
  27. package/plugins/invoice/plugin.js +283 -0
  28. package/plugins/invoice/plugin.json +85 -0
  29. package/plugins/invoice/templates/invoice-print.html +213 -0
  30. package/server/services/markdown.js +24 -4
  31. package/server/services/renderer.js +9 -3
@@ -1,10 +1,29 @@
1
1
  <div class="view-header d-flex align-items-center justify-content-between mb-4">
2
- <h1 class="h3 mb-0">Blog Posts</h1>
2
+ <h1 class="h3 mb-0"><span data-icon="file-text"></span> Blog Posts</h1>
3
3
  <button id="btn-new-post" class="btn btn-primary">
4
4
  <span data-icon="plus"></span> New Post
5
5
  </button>
6
6
  </div>
7
7
 
8
+ <div class="grid grid-cols-4 gap-3 mb-4">
9
+ <div class="card"><div class="card-body">
10
+ <div class="text-muted small">Total</div>
11
+ <div class="h3 mb-0" id="kpi-total">—</div>
12
+ </div></div>
13
+ <div class="card"><div class="card-body">
14
+ <div class="text-muted small">Published</div>
15
+ <div class="h3 mb-0" id="kpi-published">—</div>
16
+ </div></div>
17
+ <div class="card"><div class="card-body">
18
+ <div class="text-muted small">Drafts</div>
19
+ <div class="h3 mb-0" id="kpi-drafts">—</div>
20
+ </div></div>
21
+ <div class="card"><div class="card-body">
22
+ <div class="text-muted small">Scheduled</div>
23
+ <div class="h3 mb-0" id="kpi-scheduled">—</div>
24
+ </div></div>
25
+ </div>
26
+
8
27
  <div class="card mb-3">
9
28
  <div class="card-body">
10
29
  <div class="d-flex gap-2 flex-wrap">
@@ -14,7 +33,11 @@
14
33
  <option value="scheduled">Scheduled</option>
15
34
  <option value="published">Published</option>
16
35
  </select>
17
- <input id="filter-search" type="text" class="form-control" style="width:200px;" placeholder="Search posts…">
36
+ <select id="filter-category" class="form-select" style="width:auto;">
37
+ <option value="">All categories</option>
38
+ </select>
39
+ <input id="filter-search" type="text" class="form-control" style="width:240px;" placeholder="Search title or excerpt…">
40
+ <button id="filter-reset" class="btn btn-ghost btn-sm">Reset</button>
18
41
  </div>
19
42
  </div>
20
43
  </div>
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Blog posts list view.
2
+ * Blog posts overview — KPI cards + filterable, sortable post table.
3
3
  *
4
4
  * @module blog/admin/views/blog
5
5
  */
@@ -19,29 +19,23 @@ const STATUS_BADGE = {
19
19
  export const blogView = {
20
20
  templateUrl: '/plugins/blog/admin/templates/blog.html',
21
21
 
22
- /**
23
- * Mount the blog posts list view.
24
- *
25
- * @param {object} $container - Domma-wrapped container element
26
- * @returns {Promise<void>}
27
- */
28
22
  async onMount($container) {
29
23
  let allPosts = [];
24
+ let categories = [];
30
25
  let postsTable = null;
31
26
 
32
- // ---------------------------------------------------------------
33
- // Table
34
- // ---------------------------------------------------------------
35
-
36
27
  function buildTable(data) {
37
28
  const tableEl = $container.find('#posts-table').get(0);
38
29
  if (!tableEl) return;
39
30
 
40
31
  if (postsTable) {
41
32
  postsTable.setData(data);
33
+ Domma.icons.scan(tableEl);
42
34
  return;
43
35
  }
44
36
 
37
+ const catNameById = (id) => categories.find((c) => c.id === id)?.name ?? '';
38
+
45
39
  postsTable = T.create(tableEl, {
46
40
  data,
47
41
  columns: [
@@ -62,10 +56,12 @@ export const blogView = {
62
56
  }
63
57
  },
64
58
  {
65
- key: 'authorId',
66
- title: 'Author',
59
+ key: 'categories',
60
+ title: 'Categories',
67
61
  sortable: false,
68
- render: (v) => escapeHtml(v) || '—'
62
+ render: (val) => Array.isArray(val) && val.length
63
+ ? val.map((id) => `<span class="badge badge-secondary mr-1">${escapeHtml(catNameById(id) || id)}</span>`).join('')
64
+ : '<span style="opacity:0.4;">—</span>'
69
65
  },
70
66
  {
71
67
  key: 'publishedAt',
@@ -79,19 +75,22 @@ export const blogView = {
79
75
  key: 'id',
80
76
  title: 'Actions',
81
77
  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>`
78
+ render: (id, row) => {
79
+ const slug = escapeHtml(row.slug || '');
80
+ const viewLink = row.status === 'published' && slug
81
+ ? `<a href="/blog/${slug}" target="_blank" class="btn btn-sm btn-outline" title="View on site"><span data-icon="external-link" data-icon-size="14"></span></a>`
82
+ : '';
83
+ return `<div style="display:flex;gap:.4rem;">` +
84
+ `<button class="btn btn-sm btn-outline post-edit-btn" data-id="${escapeHtml(id)}"><span data-icon="edit" data-icon-size="14"></span></button>` +
85
+ viewLink +
86
+ `<button class="btn btn-sm btn-danger post-delete-btn" data-id="${escapeHtml(id)}"><span data-icon="trash" data-icon-size="14"></span></button>` +
87
+ `</div>`;
88
+ }
89
89
  }
90
90
  ],
91
91
  emptyMessage: 'No posts found.'
92
92
  });
93
93
 
94
- // Event delegation for action buttons
95
94
  tableEl.addEventListener('click', async (e) => {
96
95
  const editBtn = e.target.closest('.post-edit-btn');
97
96
  const deleteBtn = e.target.closest('.post-delete-btn');
@@ -115,21 +114,30 @@ export const blogView = {
115
114
  }
116
115
  });
117
116
 
118
- Domma.icons.scan();
117
+ Domma.icons.scan(tableEl);
119
118
  }
120
119
 
121
- // ---------------------------------------------------------------
122
- // Filter
123
- // ---------------------------------------------------------------
120
+ function refreshKpis() {
121
+ const counts = { total: allPosts.length, published: 0, draft: 0, scheduled: 0 };
122
+ for (const p of allPosts) {
123
+ if (p.status === 'published') counts.published++;
124
+ else if (p.status === 'scheduled') counts.scheduled++;
125
+ else counts.draft++;
126
+ }
127
+ $container.find('#kpi-total').text(counts.total);
128
+ $container.find('#kpi-published').text(counts.published);
129
+ $container.find('#kpi-drafts').text(counts.draft);
130
+ $container.find('#kpi-scheduled').text(counts.scheduled);
131
+ }
124
132
 
125
133
  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();
134
+ const status = $container.find('#filter-status').val() || '';
135
+ const cat = $container.find('#filter-category').val() || '';
136
+ const query = ($container.find('#filter-search').val() || '').toString().toLowerCase().trim();
130
137
 
131
138
  let filtered = allPosts;
132
139
  if (status) filtered = filtered.filter((p) => p.status === status);
140
+ if (cat) filtered = filtered.filter((p) => Array.isArray(p.categories) && p.categories.includes(cat));
133
141
  if (query) {
134
142
  filtered = filtered.filter((p) =>
135
143
  (p.title ?? '').toLowerCase().includes(query) ||
@@ -139,44 +147,52 @@ export const blogView = {
139
147
  buildTable(filtered);
140
148
  }
141
149
 
142
- // ---------------------------------------------------------------
143
- // Load
144
- // ---------------------------------------------------------------
150
+ function populateCategoryFilter() {
151
+ const select = $container.find('#filter-category').get(0);
152
+ if (!select) return;
153
+ for (const cat of categories) {
154
+ const opt = document.createElement('option');
155
+ opt.value = cat.id;
156
+ opt.textContent = cat.name ?? cat.slug ?? cat.id;
157
+ select.appendChild(opt);
158
+ }
159
+ }
145
160
 
146
161
  async function reload() {
147
162
  try {
148
- const res = await H.get(`${BASE}/posts`);
149
- if (res.error) throw new Error(res.error);
150
- allPosts = res.data ?? [];
163
+ const [postsRes, catsRes] = await Promise.all([
164
+ H.get(`${BASE}/posts`),
165
+ H.get(`${BASE}/categories`).catch(() => ({ data: [] }))
166
+ ]);
167
+ if (postsRes.error) throw new Error(postsRes.error);
168
+ allPosts = postsRes.data ?? [];
169
+ categories = catsRes.data ?? catsRes ?? [];
151
170
  } catch {
152
171
  allPosts = [];
172
+ categories = [];
153
173
  E.toast('Failed to load posts.', { type: 'error' });
154
174
  }
175
+ refreshKpis();
176
+ populateCategoryFilter();
155
177
  applyFilters();
156
178
  }
157
179
 
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);
180
+ $container.find('#btn-new-post').on('click', () => R.navigate('#/plugins/blog/posts/new'));
181
+ $container.find('#filter-status').on('change', applyFilters);
182
+ $container.find('#filter-category').on('change', applyFilters);
183
+ $container.find('#filter-reset').on('click', () => {
184
+ $container.find('#filter-status').val('');
185
+ $container.find('#filter-category').val('');
186
+ $container.find('#filter-search').val('');
187
+ applyFilters();
188
+ });
167
189
 
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
- }
190
+ let searchTimer = null;
191
+ $container.find('#filter-search').on('input', () => {
192
+ clearTimeout(searchTimer);
193
+ searchTimer = setTimeout(applyFilters, 250);
194
+ });
176
195
 
177
- // ---------------------------------------------------------------
178
- // Initial load
179
- // ---------------------------------------------------------------
180
196
  await reload();
181
197
  Domma.icons.scan();
182
198
  }
@@ -17,29 +17,17 @@ const BASE = '/api/plugins/blog';
17
17
  export const postEditorView = {
18
18
  templateUrl: '/plugins/blog/admin/templates/post-editor.html',
19
19
 
20
- /**
21
- * Mount the post editor view.
22
- *
23
- * @param {object} $container - Domma-wrapped container element
24
- * @returns {Promise<void>}
25
- */
26
20
  async onMount($container) {
27
- // Resolve post id from router params
28
21
  const id = R.current?.params?.id !== 'new' ? R.current?.params?.id : null;
29
22
  const isEdit = Boolean(id);
30
23
 
31
24
  let post = null;
32
25
  let categories = [];
33
26
  let selectedCategoryIds = [];
34
-
35
- // ---------------------------------------------------------------
36
- // Load data
37
- // ---------------------------------------------------------------
27
+ let featuredImageUrl = '';
38
28
 
39
29
  try {
40
- const [catsRes] = await Promise.all([
41
- H.get(`${BASE}/categories`).catch(() => ({ data: [] }))
42
- ]);
30
+ const catsRes = await H.get(`${BASE}/categories`).catch(() => ({ data: [] }));
43
31
  categories = catsRes.data ?? catsRes ?? [];
44
32
  } catch {
45
33
  categories = [];
@@ -57,10 +45,6 @@ export const postEditorView = {
57
45
  }
58
46
  }
59
47
 
60
- // ---------------------------------------------------------------
61
- // DOM refs
62
- // ---------------------------------------------------------------
63
-
64
48
  const titleEl = $container.find('#editor-title').get(0);
65
49
  const postTitleEl = $container.find('#post-title').get(0);
66
50
  const postSlugEl = $container.find('#post-slug').get(0);
@@ -75,10 +59,8 @@ export const postEditorView = {
75
59
  const scheduleEl = $container.find('#schedule-picker').get(0);
76
60
  const schedAtEl = $container.find('#post-scheduled-at').get(0);
77
61
  const catsListEl = $container.find('#categories-checklist').get(0);
78
-
79
- // ---------------------------------------------------------------
80
- // Populate
81
- // ---------------------------------------------------------------
62
+ const imagePreviewEl = $container.find('#featured-image-preview').get(0);
63
+ const clearImageBtn = $container.find('#btn-clear-image').get(0);
82
64
 
83
65
  if (titleEl) titleEl.textContent = isEdit ? 'Edit Post' : 'New Post';
84
66
 
@@ -88,19 +70,33 @@ export const postEditorView = {
88
70
  if (excerptEl) excerptEl.value = post.excerpt ?? '';
89
71
  if (contentEl) contentEl.value = post.content ?? '';
90
72
  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 ?? '';
73
+ if (seoTitleEl) seoTitleEl.value = post.seo?.title ?? '';
74
+ if (seoDescEl) seoDescEl.value = post.seo?.description ?? '';
93
75
  if (statusBadge) statusBadge.textContent = post.status ?? 'draft';
94
- selectedCategoryIds = Array.isArray(post.categoryIds) ? [...post.categoryIds] : [];
76
+ selectedCategoryIds = Array.isArray(post.categories) ? [...post.categories] : [];
77
+ featuredImageUrl = post.featuredImage ?? '';
78
+ renderImagePreview();
95
79
  } else {
96
- // New post: enable auto-slug by default
97
80
  if (slugAutoEl) slugAutoEl.checked = true;
98
81
  }
99
82
 
100
- // Build categories checklist using DOM APIs (not innerHTML — interactive elements)
83
+ function renderImagePreview() {
84
+ if (!imagePreviewEl) return;
85
+ while (imagePreviewEl.firstChild) imagePreviewEl.removeChild(imagePreviewEl.firstChild);
86
+ if (featuredImageUrl) {
87
+ const img = document.createElement('img');
88
+ img.src = featuredImageUrl;
89
+ img.alt = 'Featured image';
90
+ img.style.cssText = 'max-width:100%;border-radius:.25rem;display:block;margin-bottom:.5rem;';
91
+ imagePreviewEl.appendChild(img);
92
+ if (clearImageBtn) clearImageBtn.style.display = '';
93
+ } else {
94
+ if (clearImageBtn) clearImageBtn.style.display = 'none';
95
+ }
96
+ }
97
+
101
98
  if (catsListEl) {
102
99
  while (catsListEl.firstChild) catsListEl.removeChild(catsListEl.firstChild);
103
-
104
100
  if (categories.length === 0) {
105
101
  const p = document.createElement('p');
106
102
  p.className = 'text-muted small';
@@ -134,27 +130,22 @@ export const postEditorView = {
134
130
  }
135
131
  }
136
132
 
137
- // ---------------------------------------------------------------
138
- // Collect form values
139
- // ---------------------------------------------------------------
140
-
141
133
  function collectValues() {
142
134
  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() ?? ''
135
+ title: postTitleEl?.value?.trim() ?? '',
136
+ slug: postSlugEl?.value?.trim() ?? '',
137
+ excerpt: excerptEl?.value?.trim() ?? '',
138
+ content: contentEl?.value ?? '',
139
+ tags: (tagsEl?.value ?? '').split(',').map((t) => t.trim()).filter(Boolean),
140
+ categories: [...selectedCategoryIds],
141
+ featuredImage: featuredImageUrl || null,
142
+ seo: {
143
+ title: seoTitleEl?.value?.trim() ?? '',
144
+ description: seoDescEl?.value?.trim() ?? ''
145
+ }
151
146
  };
152
147
  }
153
148
 
154
- // ---------------------------------------------------------------
155
- // Save helpers
156
- // ---------------------------------------------------------------
157
-
158
149
  async function doSave(extra = {}) {
159
150
  const body = { ...collectValues(), ...extra };
160
151
  if (!body.title) {
@@ -162,37 +153,25 @@ export const postEditorView = {
162
153
  return null;
163
154
  }
164
155
  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;
156
+ const res = isEdit
157
+ ? await H.put(`${BASE}/posts/${id}`, body)
158
+ : await H.post(`${BASE}/posts`, body);
159
+ if (res.error) throw new Error(res.error);
160
+ return res.data ?? res;
176
161
  } catch (err) {
177
162
  E.toast(err?.message ?? 'Failed to save post.', { type: 'error' });
178
163
  return null;
179
164
  }
180
165
  }
181
166
 
182
- // ---------------------------------------------------------------
183
- // Event bindings
184
- // ---------------------------------------------------------------
185
-
186
- // Auto-slug from title
187
167
  if (postTitleEl) {
188
168
  postTitleEl.addEventListener('input', () => {
189
- if (slugAutoEl?.checked) {
190
- if (postSlugEl) postSlugEl.value = slugify(postTitleEl.value);
169
+ if (slugAutoEl?.checked && postSlugEl) {
170
+ postSlugEl.value = slugify(postTitleEl.value);
191
171
  }
192
172
  });
193
173
  }
194
174
 
195
- // Slug-auto checkbox
196
175
  if (slugAutoEl) {
197
176
  slugAutoEl.addEventListener('change', () => {
198
177
  if (!slugAutoEl.checked && isEdit && post?.status === 'published') {
@@ -200,13 +179,12 @@ export const postEditorView = {
200
179
  } else {
201
180
  if (slugWarnEl) slugWarnEl.style.display = 'none';
202
181
  }
203
- if (slugAutoEl.checked && postTitleEl) {
204
- if (postSlugEl) postSlugEl.value = slugify(postTitleEl.value);
182
+ if (slugAutoEl.checked && postTitleEl && postSlugEl) {
183
+ postSlugEl.value = slugify(postTitleEl.value);
205
184
  }
206
185
  });
207
186
  }
208
187
 
209
- // SEO panel toggle
210
188
  const seoToggle = $container.find('#seo-toggle').get(0);
211
189
  const seoPanel = $container.find('#seo-panel').get(0);
212
190
  if (seoToggle && seoPanel) {
@@ -215,19 +193,17 @@ export const postEditorView = {
215
193
  });
216
194
  }
217
195
 
218
- // Save draft
219
196
  const saveDraftBtn = $container.find('#btn-save-draft').get(0);
220
197
  if (saveDraftBtn) {
221
198
  saveDraftBtn.addEventListener('click', async () => {
222
199
  const saved = await doSave({ status: 'draft' });
223
200
  if (saved) {
224
201
  E.toast('Draft saved.', { type: 'success' });
225
- R.navigate('#/plugins/blog');
202
+ R.navigate('#/plugins/blog/posts');
226
203
  }
227
204
  });
228
205
  }
229
206
 
230
- // Publish now
231
207
  const publishBtn = $container.find('#btn-publish').get(0);
232
208
  if (publishBtn) {
233
209
  publishBtn.addEventListener('click', async () => {
@@ -238,22 +214,18 @@ export const postEditorView = {
238
214
  const res = await H.post(`${BASE}/posts/${savedId}/publish`);
239
215
  if (res.error) throw new Error(res.error);
240
216
  E.toast('Post published.', { type: 'success' });
241
- R.navigate('#/plugins/blog');
217
+ R.navigate('#/plugins/blog/posts');
242
218
  } catch (err) {
243
219
  E.toast(err?.message ?? 'Failed to publish post.', { type: 'error' });
244
220
  }
245
221
  });
246
222
  }
247
223
 
248
- // Schedule
249
224
  const scheduleBtn = $container.find('#btn-schedule').get(0);
250
225
  if (scheduleBtn) {
251
226
  scheduleBtn.style.display = '';
252
227
  scheduleBtn.addEventListener('click', async () => {
253
- // Toggle schedule picker visibility
254
228
  if (scheduleEl) scheduleEl.style.display = scheduleEl.style.display === 'none' ? '' : 'none';
255
-
256
- // If picker is now visible, wait for user to confirm
257
229
  if (scheduleEl && scheduleEl.style.display !== 'none') return;
258
230
 
259
231
  const publishedAt = schedAtEl?.value;
@@ -270,22 +242,69 @@ export const postEditorView = {
270
242
  const res = await H.post(`${BASE}/posts/${savedId}/schedule`, { publishedAt });
271
243
  if (res.error) throw new Error(res.error);
272
244
  E.toast('Post scheduled.', { type: 'success' });
273
- R.navigate('#/plugins/blog');
245
+ R.navigate('#/plugins/blog/posts');
274
246
  } catch (err) {
275
247
  E.toast(err?.message ?? 'Failed to schedule post.', { type: 'error' });
276
248
  }
277
249
  });
278
250
  }
279
251
 
280
- // Pick image (TBD)
281
252
  const pickImageBtn = $container.find('#btn-pick-image').get(0);
282
253
  if (pickImageBtn) {
283
- pickImageBtn.addEventListener('click', () => {
284
- console.log('media picker TBD');
285
- E.toast('Media picker coming soon.', { type: 'info' });
254
+ pickImageBtn.addEventListener('click', () => openMediaPicker());
255
+ }
256
+ if (clearImageBtn) {
257
+ clearImageBtn.addEventListener('click', () => {
258
+ featuredImageUrl = '';
259
+ renderImagePreview();
286
260
  });
287
261
  }
288
262
 
263
+ async function openMediaPicker() {
264
+ let media = [];
265
+ try {
266
+ const res = await H.get('/api/media');
267
+ media = (res.data ?? res ?? []).filter((m) => /\.(png|jpe?g|gif|webp|svg)$/i.test(m.name || m.url || ''));
268
+ } catch {
269
+ E.toast('Could not load media library.', { type: 'error' });
270
+ return;
271
+ }
272
+
273
+ const grid = document.createElement('div');
274
+ grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill, minmax(120px, 1fr));gap:.75rem;padding:.5rem;';
275
+
276
+ if (!media.length) {
277
+ const p = document.createElement('p');
278
+ p.className = 'text-muted p-3';
279
+ p.textContent = 'No images uploaded yet. Use the Media admin to upload one first.';
280
+ grid.appendChild(p);
281
+ } else {
282
+ media.forEach((m) => {
283
+ const tile = document.createElement('button');
284
+ tile.type = 'button';
285
+ tile.style.cssText = 'border:1px solid var(--dm-border,#333);background:transparent;border-radius:.25rem;padding:.25rem;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.25rem;';
286
+ const img = document.createElement('img');
287
+ img.src = m.url;
288
+ img.alt = m.name;
289
+ img.style.cssText = 'width:100%;height:90px;object-fit:cover;border-radius:.15rem;';
290
+ const cap = document.createElement('span');
291
+ cap.textContent = m.name;
292
+ cap.style.cssText = 'font-size:.7rem;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;width:100%;';
293
+ tile.appendChild(img);
294
+ tile.appendChild(cap);
295
+ tile.addEventListener('click', () => {
296
+ featuredImageUrl = m.url;
297
+ renderImagePreview();
298
+ slideover.close();
299
+ });
300
+ grid.appendChild(tile);
301
+ });
302
+ }
303
+
304
+ const slideover = E.slideover({ title: 'Pick Featured Image', size: 'lg', position: 'right', content: grid });
305
+ slideover.open();
306
+ }
307
+
289
308
  Domma.icons.scan();
290
309
  }
291
310
  };