domma-cms 1.6.56 → 1.6.57
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/admin/css/calculator.css +1 -1
- package/admin/js/lib/analytics-shape.js +1 -1
- package/admin/js/lib/calculator-engine.js +1 -1
- package/admin/js/lib/calculator.js +22 -10
- package/admin/js/lib/form-editor-arrange.js +1 -1
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/notes-arrange.js +1 -1
- package/admin/js/lib/search-settings-arrange.js +1 -1
- package/admin/js/lib/seo-arrange.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -1
- package/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/lib/tool-recovery.js +4 -4
- package/admin/js/templates/analytics.html +26 -14
- package/admin/js/templates/contacts.html +26 -5
- package/admin/js/templates/form-editor.html +2 -0
- package/admin/js/templates/notes.html +11 -8
- package/admin/js/templates/search.html +9 -3
- package/admin/js/templates/seo.html +23 -17
- package/admin/js/templates/todo.html +18 -14
- package/admin/js/views/analytics.js +10 -10
- package/admin/js/views/contacts.js +47 -47
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/forms.js +5 -5
- package/admin/js/views/notes.js +6 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +43 -43
- package/admin/js/views/todo.js +8 -8
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-kit.js +364 -0
- package/plugins/_lib/admin/mail/reader-view.js +1009 -161
- package/plugins/_lib/admin/mail/when-closed.js +33 -3
- package/plugins/_lib/mail/readRoutes.js +48 -10
- package/plugins/_lib/mail/unreadBadge.js +4 -1
- package/plugins/blog/CLAUDE.md +14 -0
- package/plugins/blog/admin/css/index.css +5 -0
- package/plugins/blog/admin/lib/drafts.js +95 -0
- package/plugins/blog/admin/templates/blog.html +1 -1
- package/plugins/blog/admin/templates/categories.html +1 -1
- package/plugins/blog/admin/templates/comments.html +1 -1
- package/plugins/blog/admin/templates/post-editor.html +5 -1
- package/plugins/blog/admin/views/blog.js +44 -19
- package/plugins/blog/admin/views/categories.js +138 -20
- package/plugins/blog/admin/views/comments.js +32 -23
- package/plugins/blog/admin/views/kit.js +232 -22
- package/plugins/blog/admin/views/post-editor.js +348 -178
- package/plugins/blog/admin/views/settings.js +17 -3
- package/plugins/blog/plugin.js +1 -1
- package/plugins/blog/plugin.json +7 -7
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/drafts.test.js +50 -0
- package/plugins/booking/CLAUDE.md +13 -0
- package/plugins/booking/admin/lib/drafts.js +139 -0
- package/plugins/booking/admin/lib/kit.js +246 -25
- package/plugins/booking/admin/views/booking.js +11 -2
- package/plugins/booking/admin/views/bookings.js +7 -2
- package/plugins/booking/admin/views/diary.js +13 -6
- package/plugins/booking/admin/views/panels.js +85 -12
- package/plugins/booking/admin/views/people.js +41 -2
- package/plugins/booking/admin/views/services.js +45 -3
- package/plugins/booking/admin/views/settings.js +22 -2
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/admin-drafts.test.js +67 -0
- package/plugins/feedback/CLAUDE.md +12 -0
- package/plugins/feedback/admin/css/index.css +5 -0
- package/plugins/feedback/admin/lib/drafts.js +145 -0
- package/plugins/feedback/admin/lib/kit.js +161 -5
- package/plugins/feedback/admin/lib/settings.js +46 -28
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/admin/views/inbox.js +110 -46
- package/plugins/feedback/admin/views/send.js +174 -83
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/feedback/tests/drafts.test.js +93 -0
- package/plugins/free-tier.lock.json +75 -63
- package/plugins/security/CLAUDE.md +9 -0
- package/plugins/security/admin/lib/screen-state.js +120 -0
- package/plugins/security/admin/templates/security.html +15 -10
- package/plugins/security/admin/views/security.js +270 -132
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/screen-state.test.js +69 -0
- package/plugins/shopping-cart/CLAUDE.md +13 -0
- package/plugins/shopping-cart/admin/css/index.css +21 -5
- package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
- package/plugins/shopping-cart/admin/lib/kit.js +136 -28
- package/plugins/shopping-cart/admin/views/orders.js +117 -36
- package/plugins/shopping-cart/admin/views/overview.js +38 -8
- package/plugins/shopping-cart/admin/views/products.js +317 -68
- package/plugins/shopping-cart/admin/views/sales.js +21 -3
- package/plugins/shopping-cart/admin/views/settings.js +62 -3
- package/plugins/shopping-cart/admin/views/shop.js +18 -3
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/tests/accounts.test.js +11 -0
- package/plugins/shopping-cart/tests/drafts.test.js +118 -0
- package/plugins/support/CLAUDE.md +14 -0
- package/plugins/support/admin/css/index.css +10 -0
- package/plugins/support/admin/lib/drafts.js +162 -0
- package/plugins/support/admin/lib/kit.js +156 -16
- package/plugins/support/admin/lib/settings.js +52 -29
- package/plugins/support/admin/views/desk.js +114 -33
- package/plugins/support/admin/views/inbox.js +85 -26
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/plugins/support/tests/drafts.test.js +92 -0
- package/server/routes/api/analytics.js +5 -1
- package/server/routes/api/contacts.js +42 -0
- package/server/routes/api/forms.js +8 -0
- package/server/routes/api/seo.js +4 -1
- package/server/routes/api/todo.js +50 -10
- package/server/server.js +1 -1
|
@@ -4,10 +4,6 @@
|
|
|
4
4
|
* @module blog/admin/views/post-editor
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
function escapeHtml(s) {
|
|
8
|
-
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
9
|
-
}
|
|
10
|
-
|
|
11
7
|
function slugify(s) {
|
|
12
8
|
return String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
13
9
|
}
|
|
@@ -58,14 +54,15 @@ const PREVIEW_PANEL = `
|
|
|
58
54
|
* inserted here because the router renders the page template through _.render,
|
|
59
55
|
* which eats {{…}}.
|
|
60
56
|
*
|
|
57
|
+
* Its effect, bindings and an open Preview go when the editor does (`add`);
|
|
58
|
+
* Preview opens one at a time and closes when the address changes.
|
|
59
|
+
*
|
|
61
60
|
* @param {HTMLElement} card
|
|
62
|
-
* @param {{current: string, collect: () => object}} opts
|
|
63
|
-
* @returns {
|
|
61
|
+
* @param {{current: string, collect: () => object, catalogue: object, kit: object, add: Function}} opts
|
|
62
|
+
* @returns {{value: () => string, set: (id: string) => void, layout: object}}
|
|
64
63
|
*/
|
|
65
|
-
|
|
64
|
+
function mountLayoutCard(card, {current, collect, catalogue, kit, add}) {
|
|
66
65
|
const BASE_URL = '/api/plugins/blog';
|
|
67
|
-
let catalogue = {postLayouts: [], postLayout: 'blog-post-classic'};
|
|
68
|
-
try { catalogue = await H.get(`${BASE_URL}/layouts`); } catch { /* the card still offers Site default */ }
|
|
69
66
|
const known = catalogue.postLayouts ?? [];
|
|
70
67
|
const defaultLabel = known.find(l => l.id === catalogue.postLayout)?.label ?? 'Classic';
|
|
71
68
|
const thumbOf = (id) => (['blog-post-classic', 'blog-post-feature', 'blog-post-minimal', 'blog-post-sidebar', 'blog-post-split', 'blog-post-essay'].includes(id) ? id.slice(10) : 'custom');
|
|
@@ -78,16 +75,17 @@ async function mountLayoutCard(card, {current, collect}) {
|
|
|
78
75
|
|
|
79
76
|
const layout = M.observable(current || '');
|
|
80
77
|
const layoutChoices = M.observable([]);
|
|
81
|
-
M.effect(() => {
|
|
78
|
+
add(M.effect(() => {
|
|
82
79
|
layoutChoices.value = choices.map(c => ({...c, id: c.id || 'default', checked: String(c.id === layout.value), cls: c.id === layout.value ? 'is-on' : ''}));
|
|
83
|
-
});
|
|
80
|
+
}));
|
|
84
81
|
const data = {
|
|
85
82
|
layoutChoices,
|
|
86
83
|
layoutDesc: M.computed(() => choices.find(c => c.id === layout.value)?.description ?? '')
|
|
87
84
|
};
|
|
88
85
|
|
|
89
86
|
card.querySelector('.blog-layouts').innerHTML = LAYOUT_CHOICE;
|
|
90
|
-
M.applyBindings(data, card.querySelector('.card-body'));
|
|
87
|
+
const bound = M.applyBindings(data, card.querySelector('.card-body'));
|
|
88
|
+
add(() => bound.dispose());
|
|
91
89
|
card.querySelector('.blog-layouts').addEventListener('click', (e) => {
|
|
92
90
|
const b = e.target.closest('[data-layout]');
|
|
93
91
|
if (b) layout.value = b.dataset.layout === 'default' ? '' : b.dataset.layout;
|
|
@@ -95,7 +93,17 @@ async function mountLayoutCard(card, {current, collect}) {
|
|
|
95
93
|
card.querySelector('#btn-preview').addEventListener('click', () => openPreview());
|
|
96
94
|
|
|
97
95
|
async function openPreview() {
|
|
98
|
-
const
|
|
96
|
+
const release = kit.claim('blog-preview'); // one at a time
|
|
97
|
+
if (!release) return;
|
|
98
|
+
try {
|
|
99
|
+
await preview(release);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
release();
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function preview(release) {
|
|
99
107
|
const trial = M.observable(layout.peek() || catalogue.postLayout || 'blog-post-classic');
|
|
100
108
|
const width = M.observable('desktop');
|
|
101
109
|
const state = M.observable('');
|
|
@@ -105,11 +113,19 @@ async function mountLayoutCard(card, {current, collect}) {
|
|
|
105
113
|
changed: M.computed(() => trial.value !== (layout.value || catalogue.postLayout))
|
|
106
114
|
};
|
|
107
115
|
const so = E.slideover({title: 'Preview', size: 'xl', position: 'right'});
|
|
116
|
+
let alive = true;
|
|
117
|
+
let handle = null;
|
|
118
|
+
let stop = null;
|
|
119
|
+
let disposeHelp = () => {};
|
|
120
|
+
// Leaving the editor closes it: a panel over the next screen catches its clicks.
|
|
121
|
+
const unhook = kit.closeOnLeave(so);
|
|
122
|
+
kit.onceClosed(so, () => { alive = false; release(); unhook(); stop?.(); handle?.dispose(); disposeHelp(); });
|
|
108
123
|
const el = kit.fragment(PREVIEW_PANEL);
|
|
109
124
|
so.element.appendChild(el);
|
|
110
|
-
|
|
125
|
+
disposeHelp = await kit.scanHelp(el);
|
|
126
|
+
if (!alive) return;
|
|
111
127
|
const frame = el.querySelector('iframe');
|
|
112
|
-
|
|
128
|
+
handle = M.applyBindings(pdata, el, {
|
|
113
129
|
methods: {
|
|
114
130
|
desktop: () => { width.value = 'desktop'; },
|
|
115
131
|
tablet: () => { width.value = 'tablet'; },
|
|
@@ -123,19 +139,19 @@ async function mountLayoutCard(card, {current, collect}) {
|
|
|
123
139
|
if (typeof I !== 'undefined' && I.scan) I.scan(el);
|
|
124
140
|
|
|
125
141
|
let seq = 0;
|
|
126
|
-
|
|
142
|
+
stop = M.effect(async () => {
|
|
127
143
|
const chosen = trial.value;
|
|
128
144
|
const mine = ++seq;
|
|
129
145
|
state.value = 'Rendering…';
|
|
130
146
|
try {
|
|
131
147
|
const res = await H.post(`${BASE_URL}/preview`, {...collect(), layout: chosen});
|
|
132
|
-
if (mine !== seq) return;
|
|
148
|
+
if (mine !== seq || !alive) return;
|
|
133
149
|
if (res?.error) throw new Error(res.error);
|
|
134
150
|
// The page's own addresses (/dist/…, /plugins/…) resolve against the site.
|
|
135
151
|
frame.srcdoc = String(res.html).replace(/<head>/i, `<head><base href="${location.origin}/">`);
|
|
136
152
|
state.value = '';
|
|
137
153
|
} catch (err) {
|
|
138
|
-
if (mine === seq) state.value = err?.message ?? 'Preview failed.';
|
|
154
|
+
if (mine === seq && alive) state.value = err?.message ?? 'Preview failed.';
|
|
139
155
|
}
|
|
140
156
|
});
|
|
141
157
|
// Links inside the preview would navigate the frame away from the post.
|
|
@@ -149,11 +165,10 @@ async function mountLayoutCard(card, {current, collect}) {
|
|
|
149
165
|
}, true);
|
|
150
166
|
} catch { /* not ours to reach */ }
|
|
151
167
|
});
|
|
152
|
-
kit.onceClosed(so, () => { stop?.(); handle.dispose(); disposeHelp(); });
|
|
153
168
|
so.open();
|
|
154
169
|
}
|
|
155
170
|
|
|
156
|
-
return {value: () => layout.peek()};
|
|
171
|
+
return {value: () => layout.peek(), set: (id) => { layout.value = id || ''; }, layout};
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
const STATUS_CLASS = {published: 'badge-success', scheduled: 'badge-warning'};
|
|
@@ -260,7 +275,8 @@ function mountPills(input, {data, value, creatable = false, placeholder, empty,
|
|
|
260
275
|
|
|
261
276
|
const wrapper = input.nextElementSibling;
|
|
262
277
|
field = wrapper?.querySelector('.dm-pillbox-input');
|
|
263
|
-
|
|
278
|
+
const set = (v) => { box.setValue?.([...v]); selected.value = box.getValue(); };
|
|
279
|
+
if (!field) return {pending: () => '', set, destroy: () => box.destroy?.()};
|
|
264
280
|
|
|
265
281
|
// Pillbox only opens once something is typed; a list you cannot see is a
|
|
266
282
|
// list you cannot pick from. _filterOptions is Pillbox's own "narrow to
|
|
@@ -318,6 +334,8 @@ function mountPills(input, {data, value, creatable = false, placeholder, empty,
|
|
|
318
334
|
|
|
319
335
|
return {
|
|
320
336
|
pending: () => (creatable ? field.value.trim() : ''),
|
|
337
|
+
/** Put these values back (a picked-up draft). */
|
|
338
|
+
set,
|
|
321
339
|
destroy: () => box.destroy?.()
|
|
322
340
|
};
|
|
323
341
|
}
|
|
@@ -326,11 +344,6 @@ export const postEditorView = {
|
|
|
326
344
|
templateUrl: '/plugins/blog/admin/templates/post-editor.html',
|
|
327
345
|
|
|
328
346
|
async onMount($container) {
|
|
329
|
-
// Help (?) popovers, loaded at run time - a plugin must not import admin/.
|
|
330
|
-
// Scanned now, before anything below reshapes the form.
|
|
331
|
-
import('/admin/js/lib/help-popover.js')
|
|
332
|
-
.then(m => m.helpPopovers?.($container, {position: 'bottom'}))
|
|
333
|
-
.catch(() => null);
|
|
334
347
|
import(`./settings-cog.js?v=${ASSET_VERSION}`)
|
|
335
348
|
.then(m => m.addSettingsCog($container, '.view-header-actions'))
|
|
336
349
|
.catch(() => { /* no cog is better than no screen */ });
|
|
@@ -339,34 +352,87 @@ export const postEditorView = {
|
|
|
339
352
|
// function object, so this view opened blank on every edit and Save
|
|
340
353
|
// created a duplicate. Read the slug off the hash, as every core editor
|
|
341
354
|
// view does.
|
|
342
|
-
const routeMatch = location.hash.match(/^#\/plugins\/blog\/posts\/([
|
|
355
|
+
const routeMatch = location.hash.match(/^#\/plugins\/blog\/posts\/([^?#/]+)/);
|
|
343
356
|
const routeId = routeMatch ? decodeURIComponent(routeMatch[1]) : null;
|
|
344
357
|
// `let`: a new post becomes an existing one on its first Save.
|
|
345
358
|
let id = routeId && routeId !== 'new' ? routeId : null;
|
|
346
359
|
let isEdit = Boolean(id);
|
|
347
360
|
|
|
361
|
+
const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
|
|
362
|
+
const {draftState, restoreQuestion} = kit.draftLib;
|
|
363
|
+
// Everything below is torn down when the hash leaves this editor (the
|
|
364
|
+
// router has no unmount) - or when another post's editor mounts.
|
|
365
|
+
const add = kit.viewScope('editor', /^#\/plugins\/blog\/posts\/[^/?#]+$/);
|
|
366
|
+
const here = add.here;
|
|
367
|
+
const root = $container.get(0);
|
|
368
|
+
const editorEl = root.querySelector('.blog-editor');
|
|
369
|
+
const slot = root.querySelector('.blog-loading');
|
|
370
|
+
const draftKey = (pid) => `post.${pid || 'new'}`;
|
|
371
|
+
|
|
372
|
+
// ---- Load: a skeleton until the post, the categories, tags and layouts are in ----
|
|
348
373
|
let post = null;
|
|
349
374
|
let categories = [];
|
|
350
|
-
let
|
|
351
|
-
|
|
375
|
+
let allPosts = [];
|
|
376
|
+
let catalogue = {postLayouts: [], postLayout: 'blog-post-classic'};
|
|
377
|
+
let missing = false;
|
|
352
378
|
try {
|
|
353
|
-
const catsRes = await
|
|
354
|
-
|
|
379
|
+
const [catsRes, postRes, postsRes, layouts] = await kit.withSkeleton(slot, Promise.all([
|
|
380
|
+
H.get(`${BASE}/categories`).catch(() => []),
|
|
381
|
+
isEdit ? H.get(`${BASE}/posts/${encodeURIComponent(id)}`).catch((err) => {
|
|
382
|
+
if (err?.status === 404 || /not found/i.test(err?.message ?? '')) { missing = true; return null; }
|
|
383
|
+
throw err;
|
|
384
|
+
}) : null,
|
|
385
|
+
H.get(`${BASE}/posts`).catch(() => []),
|
|
386
|
+
H.get(`${BASE}/layouts`).catch(() => null)
|
|
387
|
+
]));
|
|
388
|
+
categories = catsRes?.data ?? (Array.isArray(catsRes) ? catsRes : []);
|
|
389
|
+
if (isEdit && !missing && /not found/i.test(String(postRes?.error ?? ''))) missing = true;
|
|
390
|
+
if (isEdit && !missing) {
|
|
391
|
+
if (!postRes || postRes.error) throw new Error(postRes?.error ?? 'no post');
|
|
392
|
+
post = postRes.data ?? postRes;
|
|
393
|
+
}
|
|
394
|
+
allPosts = postsRes?.data ?? (Array.isArray(postsRes) ? postsRes : []);
|
|
395
|
+
if (layouts?.postLayouts) catalogue = layouts;
|
|
355
396
|
} catch {
|
|
356
|
-
|
|
397
|
+
// No form is shown, so nothing can be saved over the post.
|
|
398
|
+
if (!here()) return;
|
|
399
|
+
E.toast('Failed to load the post.', {type: 'error'});
|
|
400
|
+
R.navigate('#/plugins/blog/posts');
|
|
401
|
+
return;
|
|
357
402
|
}
|
|
403
|
+
if (!here()) return;
|
|
358
404
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
post
|
|
364
|
-
} catch {
|
|
365
|
-
E.toast('Failed to load post.', { type: 'error' });
|
|
405
|
+
// Deleted since it was opened (or since a draft of it was kept).
|
|
406
|
+
if (missing) {
|
|
407
|
+
const draft = kit.drafts.read(draftKey(id));
|
|
408
|
+
if (!draft) {
|
|
409
|
+
E.toast('That post no longer exists.', {type: 'error'});
|
|
366
410
|
R.navigate('#/plugins/blog/posts');
|
|
367
411
|
return;
|
|
368
412
|
}
|
|
413
|
+
const throwAway = await kit.askThrowAway(restoreQuestion({what: 'post', name: draft.values?.title || draft.name || '', state: 'deleted'}));
|
|
414
|
+
// Left while asked: it stays for next time.
|
|
415
|
+
if (!here()) return;
|
|
416
|
+
if (!throwAway && kit.drafts.read(draftKey(null))) {
|
|
417
|
+
// There is already an unsaved NEW post: starting this one again would replace it.
|
|
418
|
+
const replace = await E.confirm('You also have an unsaved new post. Starting this one again replaces it. Replace it?',
|
|
419
|
+
{confirmText: 'Replace it', cancelText: 'Keep both for now'});
|
|
420
|
+
if (!here()) return;
|
|
421
|
+
if (!replace) { R.navigate('#/plugins/blog/posts'); return; } // both drafts stay
|
|
422
|
+
}
|
|
423
|
+
kit.drafts.drop(draftKey(id));
|
|
424
|
+
if (!throwAway) {
|
|
425
|
+
// Started again as new: no age, and the new editor takes it without asking again.
|
|
426
|
+
kit.drafts.keep(draftKey(null), {id: null, name: '', values: draft.values, was: ''});
|
|
427
|
+
kit.handOff('restore-new', '1');
|
|
428
|
+
R.navigate('#/plugins/blog/posts/new');
|
|
429
|
+
} else {
|
|
430
|
+
R.navigate('#/plugins/blog/posts');
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
369
433
|
}
|
|
434
|
+
if (slot) slot.hidden = true;
|
|
435
|
+
if (editorEl) editorEl.hidden = false;
|
|
370
436
|
|
|
371
437
|
const titleEl = $container.find('#editor-title').get(0);
|
|
372
438
|
const postTitleEl = $container.find('#post-title').get(0);
|
|
@@ -384,6 +450,7 @@ export const postEditorView = {
|
|
|
384
450
|
const imagePreviewEl = $container.find('#featured-image-preview').get(0);
|
|
385
451
|
const clearImageBtn = $container.find('#btn-clear-image').get(0);
|
|
386
452
|
|
|
453
|
+
let featuredImageUrl = '';
|
|
387
454
|
if (titleEl) titleEl.textContent = isEdit ? 'Editing post' : 'New post';
|
|
388
455
|
|
|
389
456
|
if (isEdit && post) {
|
|
@@ -395,8 +462,8 @@ export const postEditorView = {
|
|
|
395
462
|
if (seoDescEl) seoDescEl.value = post.seo?.description ?? '';
|
|
396
463
|
featuredImageUrl = post.featuredImage ?? '';
|
|
397
464
|
renderImagePreview();
|
|
398
|
-
} else {
|
|
399
|
-
|
|
465
|
+
} else if (slugAutoEl) {
|
|
466
|
+
slugAutoEl.checked = true;
|
|
400
467
|
}
|
|
401
468
|
|
|
402
469
|
function renderImagePreview() {
|
|
@@ -409,22 +476,21 @@ export const postEditorView = {
|
|
|
409
476
|
img.className = 'blog-image-preview';
|
|
410
477
|
imagePreviewEl.appendChild(img);
|
|
411
478
|
if (clearImageBtn) clearImageBtn.classList.remove('blog-hidden');
|
|
412
|
-
} else {
|
|
413
|
-
|
|
479
|
+
} else if (clearImageBtn) {
|
|
480
|
+
clearImageBtn.classList.add('blog-hidden');
|
|
414
481
|
}
|
|
415
482
|
}
|
|
416
483
|
|
|
417
484
|
// ---- Status, categories and tags (domma-reactive) ----
|
|
418
|
-
const disposers = [];
|
|
419
485
|
const stamps = mountStatusCard($container.find('#status-card').get(0));
|
|
420
486
|
stamps.set(post);
|
|
421
|
-
|
|
487
|
+
add(() => stamps.dispose());
|
|
422
488
|
|
|
423
489
|
// A category deleted since the post was saved has no name to show; it
|
|
424
490
|
// is kept as it was rather than silently dropped on the next save.
|
|
425
491
|
const knownIds = new Set(categories.map(c => c.id));
|
|
426
492
|
const postCats = Array.isArray(post?.categories) ? post.categories : [];
|
|
427
|
-
|
|
493
|
+
let unknownCats = postCats.filter(cid => !knownIds.has(cid));
|
|
428
494
|
const selectedCats = M.observable([]);
|
|
429
495
|
const cats = catsEl ? mountPills(catsEl, {
|
|
430
496
|
data: categories.map(c => ({value: c.id, label: c.name ?? c.slug ?? c.id})),
|
|
@@ -434,20 +500,15 @@ export const postEditorView = {
|
|
|
434
500
|
selected: selectedCats
|
|
435
501
|
}) : null;
|
|
436
502
|
if (!categories.length) $container.find('#categories-note').get(0)?.classList.remove('blog-hidden');
|
|
437
|
-
|
|
503
|
+
add(() => cats?.destroy());
|
|
438
504
|
|
|
439
505
|
// Suggestions: every tag already on a post, most used first.
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const k = String(t).trim();
|
|
447
|
-
if (k) counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
448
|
-
}
|
|
449
|
-
tagChoices = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t]) => ({value: t, label: t}));
|
|
450
|
-
} catch { /* free text still works */ }
|
|
506
|
+
const counts = new Map();
|
|
507
|
+
for (const p of allPosts) for (const t of (Array.isArray(p.tags) ? p.tags : [])) {
|
|
508
|
+
const k = String(t).trim();
|
|
509
|
+
if (k) counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
510
|
+
}
|
|
511
|
+
const tagChoices = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t]) => ({value: t, label: t}));
|
|
451
512
|
const postTags = Array.isArray(post?.tags) ? post.tags : String(post?.tags ?? '').split(',').map(t => t.trim()).filter(Boolean);
|
|
452
513
|
const selectedTags = M.observable([]);
|
|
453
514
|
const tags = tagsEl ? mountPills(tagsEl, {
|
|
@@ -458,11 +519,12 @@ export const postEditorView = {
|
|
|
458
519
|
empty: 'New tag - press Enter to add it',
|
|
459
520
|
selected: selectedTags
|
|
460
521
|
}) : null;
|
|
461
|
-
|
|
522
|
+
add(() => tags?.destroy());
|
|
462
523
|
|
|
463
|
-
const layoutCard =
|
|
524
|
+
const layoutCard = mountLayoutCard($container.find('#layout-card').get(0), {
|
|
464
525
|
current: post?.template ?? '',
|
|
465
|
-
collect: () => ({...collectValues(), id})
|
|
526
|
+
collect: () => ({...collectValues(), id}),
|
|
527
|
+
catalogue, kit, add
|
|
466
528
|
});
|
|
467
529
|
|
|
468
530
|
function collectValues() {
|
|
@@ -483,44 +545,17 @@ export const postEditorView = {
|
|
|
483
545
|
};
|
|
484
546
|
}
|
|
485
547
|
|
|
486
|
-
async function doSave(extra = {}) {
|
|
487
|
-
const body = { ...collectValues(), ...extra };
|
|
488
|
-
if (!body.title) {
|
|
489
|
-
E.toast('Title is required.', { type: 'error' });
|
|
490
|
-
postTitleEl?.focus();
|
|
491
|
-
return null;
|
|
492
|
-
}
|
|
493
|
-
try {
|
|
494
|
-
const res = isEdit
|
|
495
|
-
? await H.put(`${BASE}/posts/${id}`, body)
|
|
496
|
-
: await H.post(`${BASE}/posts`, body);
|
|
497
|
-
if (res.error) throw new Error(res.error);
|
|
498
|
-
const saved = res.data ?? res;
|
|
499
|
-
// A new post is an existing one from here on: later saves
|
|
500
|
-
// update it, and the address says which post this is (no
|
|
501
|
-
// hashchange, so the view is not re-mounted).
|
|
502
|
-
if (!isEdit && saved?.id) {
|
|
503
|
-
id = saved.id;
|
|
504
|
-
isEdit = true;
|
|
505
|
-
history.replaceState(history.state, '', `#/plugins/blog/posts/${encodeURIComponent(id)}`);
|
|
506
|
-
if (titleEl) titleEl.textContent = 'Editing post';
|
|
507
|
-
}
|
|
508
|
-
post = saved;
|
|
509
|
-
stamps.set(saved);
|
|
510
|
-
baseline = JSON.stringify(collectValues());
|
|
511
|
-
setStatus(saved.status ?? status);
|
|
512
|
-
showSaved();
|
|
513
|
-
return saved;
|
|
514
|
-
} catch (err) {
|
|
515
|
-
E.toast(err?.message ?? 'Failed to save post.', { type: 'error' });
|
|
516
|
-
return null;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
548
|
// ---- Status, and whether anything is unsaved ----
|
|
521
549
|
let status = post?.status ?? 'draft';
|
|
522
550
|
let baseline = JSON.stringify(collectValues());
|
|
523
551
|
let savedAt = null;
|
|
552
|
+
// The post as saved when editing began - a kept draft's age.
|
|
553
|
+
let was = post?.updatedAt ? `u:${post.updatedAt}` : '';
|
|
554
|
+
let discarded = false; // Back, "leave without saving": nothing to keep
|
|
555
|
+
// This editor's mark on the drafts it keeps: a new post's draft
|
|
556
|
+
// (post.new) is dropped or moved only while it is still this one's.
|
|
557
|
+
const owner = `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
|
|
558
|
+
let saving = null; // the save in flight - a second Save waits for it
|
|
524
559
|
const saveStateEl = $container.find('#save-state').get(0);
|
|
525
560
|
const draftsBtn = $container.find('#btn-drafts').get(0);
|
|
526
561
|
const isDirty = () => JSON.stringify(collectValues()) !== baseline;
|
|
@@ -534,7 +569,7 @@ export const postEditorView = {
|
|
|
534
569
|
const dirty = isDirty();
|
|
535
570
|
saveStateEl.classList.toggle('is-dirty', dirty);
|
|
536
571
|
saveStateEl.textContent = dirty
|
|
537
|
-
? 'Unsaved changes'
|
|
572
|
+
? (savedAt ? `Saved ${fmtTime(savedAt)} - changed since` : 'Unsaved changes')
|
|
538
573
|
: savedAt ? `Saved ${fmtTime(savedAt)}` : '';
|
|
539
574
|
}
|
|
540
575
|
function setStatus(next) {
|
|
@@ -547,38 +582,127 @@ export const postEditorView = {
|
|
|
547
582
|
}
|
|
548
583
|
setStatus(status);
|
|
549
584
|
|
|
585
|
+
// ---- Drafts: kept when the editor is left with changes, offered back next time ----
|
|
586
|
+
function keepDraft() {
|
|
587
|
+
if (discarded || !isDirty()) return false;
|
|
588
|
+
if (!id) {
|
|
589
|
+
// Never over another editor's unsaved new post.
|
|
590
|
+
const other = kit.drafts.read(draftKey(null));
|
|
591
|
+
if (other && other.owner !== owner) return false;
|
|
592
|
+
}
|
|
593
|
+
kit.drafts.keep(draftKey(id), {
|
|
594
|
+
id, owner, name: postTitleEl?.value?.trim() || post?.title || '', was,
|
|
595
|
+
values: {...collectValues(), slugAuto: Boolean(slugAutoEl?.checked)}
|
|
596
|
+
});
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
add(() => {
|
|
600
|
+
if (discarded || !isDirty()) return;
|
|
601
|
+
const kept = 'Your unsaved changes to the post are kept - open it again to pick them up.';
|
|
602
|
+
if (saving && !isEdit) {
|
|
603
|
+
// A new post's first save is still running: its draft is kept under the
|
|
604
|
+
// post's real id once it exists (doSave), so a New post opened meanwhile
|
|
605
|
+
// is never offered these values - saving them would make it twice.
|
|
606
|
+
const run = saving;
|
|
607
|
+
run.catch(() => null).then(() => {
|
|
608
|
+
if (isEdit) return; // saved: doSave kept it under the id
|
|
609
|
+
if (!keepDraft()) E.toast('Your unsaved new post could not be kept - another unsaved new post is waiting.', {type: 'warning'});
|
|
610
|
+
});
|
|
611
|
+
E.toast(kept, {type: 'info'});
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (keepDraft()) E.toast(kept, {type: 'info'});
|
|
615
|
+
else E.toast('Your unsaved new post could not be kept - another unsaved new post is waiting.', {type: 'warning'});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
async function doSave(extra = {}) {
|
|
619
|
+
if (saving) await saving.catch(() => null);
|
|
620
|
+
const values = collectValues();
|
|
621
|
+
const body = {...values, ...extra};
|
|
622
|
+
if (!body.title) {
|
|
623
|
+
if (here()) {
|
|
624
|
+
E.toast('Title is required.', {type: 'error'});
|
|
625
|
+
postTitleEl?.focus();
|
|
626
|
+
}
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
// What is sent: anything typed while it saves stays unsaved (the page stays open).
|
|
630
|
+
const sent = JSON.stringify(values);
|
|
631
|
+
const wasNew = !isEdit;
|
|
632
|
+
const run = (async () => (isEdit
|
|
633
|
+
? H.put(`${BASE}/posts/${encodeURIComponent(id)}`, body)
|
|
634
|
+
: H.post(`${BASE}/posts`, body)))();
|
|
635
|
+
saving = run;
|
|
636
|
+
try {
|
|
637
|
+
const res = await run;
|
|
638
|
+
if (res.error) throw new Error(res.error);
|
|
639
|
+
const saved = res.data ?? res;
|
|
640
|
+
kit.pokeSidebar();
|
|
641
|
+
// A new post is an existing one from here on: later saves
|
|
642
|
+
// update it, and the address says which post this is (no
|
|
643
|
+
// hashchange, so the view is not re-mounted).
|
|
644
|
+
if (wasNew && saved?.id) {
|
|
645
|
+
// Only this editor's draft of it: another New post may have kept its own since.
|
|
646
|
+
if (kit.drafts.read(draftKey(null))?.owner === owner) kit.drafts.drop(draftKey(null));
|
|
647
|
+
id = saved.id;
|
|
648
|
+
isEdit = true;
|
|
649
|
+
if (here()) history.replaceState(history.state, '', `#/plugins/blog/posts/${encodeURIComponent(id)}`);
|
|
650
|
+
if (titleEl) titleEl.textContent = 'Editing post';
|
|
651
|
+
}
|
|
652
|
+
post = saved;
|
|
653
|
+
if (saved?.updatedAt) was = `u:${saved.updatedAt}`;
|
|
654
|
+
baseline = sent;
|
|
655
|
+
if (!isDirty()) kit.drafts.drop(draftKey(id));
|
|
656
|
+
else if (!here()) keepDraft(); // left while it saved: later edits kept under its id, aged from this save
|
|
657
|
+
if (!here()) return saved;
|
|
658
|
+
stamps.set(saved);
|
|
659
|
+
setStatus(saved.status ?? status);
|
|
660
|
+
showSaved();
|
|
661
|
+
return saved;
|
|
662
|
+
} catch (err) {
|
|
663
|
+
if (here()) E.toast(err?.message ?? 'Failed to save post.', {type: 'error'});
|
|
664
|
+
return null;
|
|
665
|
+
} finally {
|
|
666
|
+
if (saving === run) saving = null;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
550
670
|
// Anything typed, ticked or picked may change what would be saved.
|
|
551
671
|
let queued = false;
|
|
552
672
|
const recheck = () => {
|
|
553
673
|
if (queued) return;
|
|
554
674
|
queued = true;
|
|
555
|
-
queueMicrotask(() => { queued = false; refreshState(); });
|
|
675
|
+
queueMicrotask(() => { queued = false; if (here()) refreshState(); });
|
|
556
676
|
};
|
|
557
|
-
['input', 'change', 'click'].forEach(ev =>
|
|
558
|
-
// A pill made with Enter fires none of those.
|
|
559
|
-
|
|
560
|
-
disposers.push(() => stopPillWatch?.());
|
|
677
|
+
['input', 'change', 'click'].forEach(ev => root.addEventListener(ev, recheck));
|
|
678
|
+
// A pill made with Enter fires none of those; nor does picking a layout's binding.
|
|
679
|
+
add(M.effect(() => { selectedCats.value; selectedTags.value; layoutCard.layout.value; recheck(); }));
|
|
561
680
|
|
|
562
681
|
async function save() {
|
|
563
|
-
|
|
682
|
+
const saved = await doSave();
|
|
683
|
+
if (saved && here()) E.toast(status === 'published' ? 'Saved - the live post is updated.' : 'Saved.', {type: 'success'});
|
|
564
684
|
}
|
|
565
685
|
|
|
566
686
|
async function goBack() {
|
|
567
|
-
if (isDirty()
|
|
568
|
-
|
|
687
|
+
if (isDirty()) {
|
|
688
|
+
const ok = await E.confirm('Leave without saving? Your changes to this post will be lost.',
|
|
689
|
+
{confirmText: 'Leave without saving', cancelText: 'Keep editing'});
|
|
690
|
+
if (!ok || !here()) return;
|
|
691
|
+
discarded = true; // asked and answered: no draft, no second question
|
|
692
|
+
kit.drafts.drop(draftKey(id));
|
|
693
|
+
}
|
|
569
694
|
R.navigate('#/plugins/blog/posts');
|
|
570
695
|
}
|
|
571
696
|
|
|
572
697
|
$container.find('#btn-save').get(0)?.addEventListener('click', save);
|
|
573
698
|
$container.find('#btn-back').get(0)?.addEventListener('click', goBack);
|
|
574
699
|
draftsBtn?.addEventListener('click', async () => {
|
|
575
|
-
const saved = await doSave({
|
|
576
|
-
if (saved) E.toast('Moved to drafts - it is no longer on the site.', {
|
|
700
|
+
const saved = await doSave({status: 'draft'});
|
|
701
|
+
if (saved && here()) E.toast('Moved to drafts - it is no longer on the site.', {type: 'success'});
|
|
577
702
|
});
|
|
578
703
|
|
|
579
|
-
// Ctrl/Cmd+S saves; a closing tab with unsaved work asks first
|
|
580
|
-
//
|
|
581
|
-
// The admin's own Ctrl+S (app.js) clicks the view's primary Save and
|
|
704
|
+
// Ctrl/Cmd+S saves; a closing tab with unsaved work asks first (and keeps
|
|
705
|
+
// a draft). The admin's own Ctrl+S (app.js) clicks the view's primary Save and
|
|
582
706
|
// marks the event handled; saving again here made two requests - and
|
|
583
707
|
// on a new post, two posts. This one is for an admin without it.
|
|
584
708
|
const onKey = (e) => {
|
|
@@ -589,18 +713,14 @@ export const postEditorView = {
|
|
|
589
713
|
}
|
|
590
714
|
};
|
|
591
715
|
const onUnload = (e) => {
|
|
592
|
-
if (isDirty()) { e.preventDefault(); e.returnValue = ''; }
|
|
716
|
+
if (isDirty() && !discarded) { keepDraft(); e.preventDefault(); e.returnValue = ''; }
|
|
593
717
|
};
|
|
594
718
|
document.addEventListener('keydown', onKey);
|
|
595
719
|
window.addEventListener('beforeunload', onUnload);
|
|
596
|
-
|
|
597
|
-
if (/^#\/plugins\/blog\/posts\/[^/]+$/.test(location.hash)) return;
|
|
720
|
+
add(() => {
|
|
598
721
|
document.removeEventListener('keydown', onKey);
|
|
599
722
|
window.removeEventListener('beforeunload', onUnload);
|
|
600
|
-
|
|
601
|
-
disposers.splice(0).forEach(fn => { try { fn(); } catch { /* already gone */ } });
|
|
602
|
-
};
|
|
603
|
-
window.addEventListener('hashchange', onHash);
|
|
723
|
+
});
|
|
604
724
|
|
|
605
725
|
if (postTitleEl) {
|
|
606
726
|
postTitleEl.addEventListener('input', () => {
|
|
@@ -614,8 +734,8 @@ export const postEditorView = {
|
|
|
614
734
|
slugAutoEl.addEventListener('change', () => {
|
|
615
735
|
if (!slugAutoEl.checked && isEdit && post?.status === 'published') {
|
|
616
736
|
if (slugWarnEl) slugWarnEl.classList.remove('blog-hidden');
|
|
617
|
-
} else {
|
|
618
|
-
|
|
737
|
+
} else if (slugWarnEl) {
|
|
738
|
+
slugWarnEl.classList.add('blog-hidden');
|
|
619
739
|
}
|
|
620
740
|
if (slugAutoEl.checked && postTitleEl && postSlugEl) {
|
|
621
741
|
postSlugEl.value = slugify(postTitleEl.value);
|
|
@@ -638,14 +758,17 @@ export const postEditorView = {
|
|
|
638
758
|
if (!saved) return;
|
|
639
759
|
const savedId = saved.id ?? id;
|
|
640
760
|
try {
|
|
641
|
-
const res = await H.post(`${BASE}/posts/${savedId}/publish`, {});
|
|
761
|
+
const res = await H.post(`${BASE}/posts/${encodeURIComponent(savedId)}/publish`, {});
|
|
642
762
|
if (res.error) throw new Error(res.error);
|
|
643
763
|
post = res.data ?? res;
|
|
764
|
+
if (post?.updatedAt) was = `u:${post.updatedAt}`;
|
|
765
|
+
kit.pokeSidebar();
|
|
766
|
+
if (!here()) return;
|
|
644
767
|
stamps.set(post);
|
|
645
768
|
setStatus('published');
|
|
646
|
-
E.toast('Published - the post is live.', {
|
|
769
|
+
E.toast('Published - the post is live.', {type: 'success'});
|
|
647
770
|
} catch (err) {
|
|
648
|
-
E.toast(err?.message ?? 'Failed to publish post.', {
|
|
771
|
+
if (here()) E.toast(err?.message ?? 'Failed to publish post.', {type: 'error'});
|
|
649
772
|
}
|
|
650
773
|
});
|
|
651
774
|
}
|
|
@@ -659,7 +782,7 @@ export const postEditorView = {
|
|
|
659
782
|
|
|
660
783
|
const publishedAt = schedAtEl?.value;
|
|
661
784
|
if (!publishedAt) {
|
|
662
|
-
E.toast('Please select a scheduled date/time.', {
|
|
785
|
+
E.toast('Please select a scheduled date/time.', {type: 'error'});
|
|
663
786
|
if (scheduleEl) scheduleEl.classList.remove('blog-hidden');
|
|
664
787
|
return;
|
|
665
788
|
}
|
|
@@ -668,49 +791,69 @@ export const postEditorView = {
|
|
|
668
791
|
if (!saved) return;
|
|
669
792
|
const savedId = saved.id ?? id;
|
|
670
793
|
try {
|
|
671
|
-
const res = await H.post(`${BASE}/posts/${savedId}/schedule`, {
|
|
794
|
+
const res = await H.post(`${BASE}/posts/${encodeURIComponent(savedId)}/schedule`, {publishedAt});
|
|
672
795
|
if (res.error) throw new Error(res.error);
|
|
673
796
|
post = res.data ?? res;
|
|
797
|
+
if (post?.updatedAt) was = `u:${post.updatedAt}`;
|
|
798
|
+
kit.pokeSidebar();
|
|
799
|
+
if (!here()) return;
|
|
674
800
|
stamps.set(post);
|
|
675
801
|
setStatus('scheduled');
|
|
676
802
|
if (scheduleEl) scheduleEl.classList.add('blog-hidden');
|
|
677
|
-
E.toast(`Scheduled for ${fmtDateTime(publishedAt)}.`, {
|
|
803
|
+
E.toast(`Scheduled for ${fmtDateTime(publishedAt)}.`, {type: 'success'});
|
|
678
804
|
} catch (err) {
|
|
679
|
-
E.toast(err?.message ?? 'Failed to schedule post.', {
|
|
805
|
+
if (here()) E.toast(err?.message ?? 'Failed to schedule post.', {type: 'error'});
|
|
680
806
|
}
|
|
681
807
|
});
|
|
682
808
|
}
|
|
683
809
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
featuredImageUrl = '';
|
|
691
|
-
renderImagePreview();
|
|
692
|
-
});
|
|
693
|
-
}
|
|
810
|
+
$container.find('#btn-pick-image').get(0)?.addEventListener('click', () => openMediaPicker());
|
|
811
|
+
clearImageBtn?.addEventListener('click', () => {
|
|
812
|
+
featuredImageUrl = '';
|
|
813
|
+
renderImagePreview();
|
|
814
|
+
recheck();
|
|
815
|
+
});
|
|
694
816
|
|
|
817
|
+
/**
|
|
818
|
+
* The Featured Image picker: one at a time, a skeleton while the media
|
|
819
|
+
* library loads, closed (choosing nothing) when the editor is left.
|
|
820
|
+
*/
|
|
695
821
|
async function openMediaPicker() {
|
|
696
|
-
|
|
822
|
+
const release = kit.claim('blog-media');
|
|
823
|
+
if (!release) return;
|
|
824
|
+
let so = null;
|
|
697
825
|
try {
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
826
|
+
const grid = document.createElement('div');
|
|
827
|
+
grid.className = 'blog-media-grid';
|
|
828
|
+
const wait = document.createElement('div');
|
|
829
|
+
wait.className = 'blog-loading';
|
|
830
|
+
wait.textContent = 'Loading the media library…';
|
|
831
|
+
const box = document.createElement('div');
|
|
832
|
+
box.append(wait, grid);
|
|
833
|
+
so = E.slideover({title: 'Pick Featured Image', size: 'lg', position: 'right'});
|
|
834
|
+
so.element.appendChild(box);
|
|
835
|
+
let open = true;
|
|
836
|
+
const unhook = kit.closeOnLeave(so);
|
|
837
|
+
kit.onceClosed(so, () => { open = false; unhook(); release(); });
|
|
838
|
+
so.open();
|
|
839
|
+
|
|
840
|
+
let media = [];
|
|
841
|
+
try {
|
|
842
|
+
const res = await kit.withSkeleton(wait, H.get('/api/media'), 'tiles');
|
|
843
|
+
media = (res?.data ?? res ?? []).filter((m) => /\.(png|jpe?g|gif|webp|svg)$/i.test(m.name || m.url || ''));
|
|
844
|
+
} catch {
|
|
845
|
+
if (open) { E.toast('Could not load media library.', {type: 'error'}); so.close(); }
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (!open || !here()) return;
|
|
849
|
+
wait.remove();
|
|
850
|
+
if (!media.length) {
|
|
851
|
+
const p = document.createElement('p');
|
|
852
|
+
p.className = 'text-muted p-3';
|
|
853
|
+
p.textContent = 'No images uploaded yet. Use the Media admin to upload one first.';
|
|
854
|
+
grid.appendChild(p);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
714
857
|
media.forEach((m) => {
|
|
715
858
|
const tile = document.createElement('button');
|
|
716
859
|
tile.type = 'button';
|
|
@@ -718,43 +861,70 @@ export const postEditorView = {
|
|
|
718
861
|
const img = document.createElement('img');
|
|
719
862
|
img.src = m.url;
|
|
720
863
|
img.alt = m.name;
|
|
721
|
-
|
|
722
864
|
const cap = document.createElement('span');
|
|
723
865
|
cap.textContent = m.name;
|
|
724
|
-
|
|
725
|
-
tile.appendChild(img);
|
|
726
|
-
tile.appendChild(cap);
|
|
866
|
+
tile.append(img, cap);
|
|
727
867
|
tile.addEventListener('click', () => {
|
|
868
|
+
if (!here()) return;
|
|
728
869
|
featuredImageUrl = m.url;
|
|
729
870
|
renderImagePreview();
|
|
730
|
-
|
|
871
|
+
recheck();
|
|
872
|
+
so.close();
|
|
731
873
|
});
|
|
732
874
|
grid.appendChild(tile);
|
|
733
875
|
});
|
|
876
|
+
} catch (err) {
|
|
877
|
+
if (!so) release();
|
|
878
|
+
throw err;
|
|
734
879
|
}
|
|
735
|
-
|
|
736
|
-
// close() only hides the panel and destroy() tears the component down
|
|
737
|
-
// without detaching its element, so every trip to the media library
|
|
738
|
-
// used to leave another slideover in the document.
|
|
739
|
-
const slideover = E.slideover({
|
|
740
|
-
title: 'Pick Featured Image',
|
|
741
|
-
size: 'lg',
|
|
742
|
-
position: 'right',
|
|
743
|
-
content: grid,
|
|
744
|
-
onClose: () => setTimeout(() => {
|
|
745
|
-
slideover.destroy();
|
|
746
|
-
slideover.element?.remove();
|
|
747
|
-
}, 350)
|
|
748
|
-
});
|
|
749
|
-
slideover.open();
|
|
750
880
|
}
|
|
751
881
|
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
882
|
+
// Help (?) popovers last: the plugin chrome rebuilds the header after the
|
|
883
|
+
// view's template is in, and markers already converted are skipped.
|
|
884
|
+
// Overtaken while it loaded: register nothing - the lifetime is the new mount's now.
|
|
885
|
+
const offHelp = await kit.scanHelp(root);
|
|
886
|
+
if (!here()) { offHelp(); return; }
|
|
887
|
+
add(offHelp);
|
|
758
888
|
Domma.icons.scan();
|
|
889
|
+
|
|
890
|
+
/** Put a kept draft's values back into the form. */
|
|
891
|
+
function restore(v) {
|
|
892
|
+
if (postTitleEl) postTitleEl.value = v.title ?? '';
|
|
893
|
+
if (postSlugEl) postSlugEl.value = v.slug ?? '';
|
|
894
|
+
if (slugAutoEl && 'slugAuto' in v) slugAutoEl.checked = Boolean(v.slugAuto);
|
|
895
|
+
if (excerptEl) excerptEl.value = v.excerpt ?? '';
|
|
896
|
+
if (contentEl) contentEl.value = v.content ?? '';
|
|
897
|
+
if (seoTitleEl) seoTitleEl.value = v.seo?.title ?? '';
|
|
898
|
+
if (seoDescEl) seoDescEl.value = v.seo?.description ?? '';
|
|
899
|
+
featuredImageUrl = v.featuredImage ?? '';
|
|
900
|
+
renderImagePreview();
|
|
901
|
+
layoutCard.set(v.template ?? '');
|
|
902
|
+
const vc = Array.isArray(v.categories) ? v.categories : [];
|
|
903
|
+
cats?.set(vc.filter(c => knownIds.has(c)));
|
|
904
|
+
unknownCats = vc.filter(c => !knownIds.has(c));
|
|
905
|
+
tags?.set(Array.isArray(v.tags) ? v.tags : []);
|
|
906
|
+
refreshState();
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// A draft from last time, for this same post: "Throw away" drops it;
|
|
910
|
+
// Keep (and Escape) put it back. Removed only once answered - leaving
|
|
911
|
+
// while asked keeps it.
|
|
912
|
+
const draft = kit.drafts.read(draftKey(id));
|
|
913
|
+
if (draft) {
|
|
914
|
+
const handedOver = !isEdit && kit.takeHandOff('restore-new') === '1';
|
|
915
|
+
let throwAway = false;
|
|
916
|
+
if (!handedOver) {
|
|
917
|
+
const state = draftState({...draft, id}, isEdit ? post : null);
|
|
918
|
+
throwAway = await kit.askThrowAway(restoreQuestion({what: 'post', name: isEdit ? (post?.title || draft.name || '') : '', state, isNew: !isEdit}));
|
|
919
|
+
if (!here()) return;
|
|
920
|
+
}
|
|
921
|
+
kit.drafts.drop(draftKey(id));
|
|
922
|
+
if (!throwAway) {
|
|
923
|
+
restore(draft.values ?? {});
|
|
924
|
+
// A picked-up draft keeps its age: its values are still the older edits.
|
|
925
|
+
if (draft.was) was = draft.was;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (here() && !isEdit) postTitleEl?.focus();
|
|
759
929
|
}
|
|
760
930
|
};
|