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
|
@@ -5,9 +5,19 @@
|
|
|
5
5
|
* `options[event]` when that happens to be a function, so it silently does
|
|
6
6
|
* nothing - every "on close" cleanup in the mail client written that way
|
|
7
7
|
* (editor teardown, resize handles, address suggesters, settling a dialog's
|
|
8
|
-
* promise when it was closed by its X) never ran.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* promise when it was closed by its X) never ran.
|
|
9
|
+
*
|
|
10
|
+
* Two kinds of component, two hooks:
|
|
11
|
+
*
|
|
12
|
+
* - A slideover is a class whose close() reads `options.onClose` at the moment
|
|
13
|
+
* it closes - so chaining that fires only when a close really happens, after
|
|
14
|
+
* any "discard changes?" guard has let it through.
|
|
15
|
+
* - E.modal is a `<domma-modal>` web component. Its handle's `options` is the
|
|
16
|
+
* element's `_options` - usually undefined, so the old code returned without
|
|
17
|
+
* hooking anything - and close() never reads it: it dispatches a `close` DOM
|
|
18
|
+
* event instead - for the X, the backdrop, Escape and close() alike. Chaining
|
|
19
|
+
* `onClose` there did nothing at all, which is how a modal shut with Escape
|
|
20
|
+
* left its promise unsettled (Email Pro saw it). The event is what fires.
|
|
11
21
|
*
|
|
12
22
|
* @module _lib/admin/mail/when-closed
|
|
13
23
|
*
|
|
@@ -17,6 +27,26 @@
|
|
|
17
27
|
*/
|
|
18
28
|
export function whenClosed(component, fn) {
|
|
19
29
|
const opts = component?.options;
|
|
30
|
+
const el = component?.element;
|
|
31
|
+
// The web component (`<domma-modal>` - a custom element's tag has a
|
|
32
|
+
// hyphen). Its handle's `options` is the element's `_options`, which is
|
|
33
|
+
// often not even set, so it cannot be what decides.
|
|
34
|
+
const custom = typeof el?.tagName === 'string' && el.tagName.includes('-');
|
|
35
|
+
if (el && typeof el.addEventListener === 'function' && (custom || !opts || el._options === opts)) {
|
|
36
|
+
// Once per close: the X sits in the modal's shadow root, so its click
|
|
37
|
+
// also reaches the host's backdrop handler and close() emits twice.
|
|
38
|
+
// Re-armed when the modal opens again.
|
|
39
|
+
let fired = false;
|
|
40
|
+
el.addEventListener('open', (event) => { if (event.target === el) fired = false; });
|
|
41
|
+
el.addEventListener('close', (event) => {
|
|
42
|
+
// `close` bubbles and is composed: a modal opened INSIDE this one
|
|
43
|
+
// closing must not count as this one closing.
|
|
44
|
+
if (event.target !== el || fired) return;
|
|
45
|
+
fired = true;
|
|
46
|
+
try { fn(component); } catch { /* one cleanup failing must not stop the next */ }
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
20
50
|
if (!opts) return;
|
|
21
51
|
const previous = opts.onClose;
|
|
22
52
|
opts.onClose = (instance) => {
|
|
@@ -151,7 +151,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
/**
|
|
154
|
-
* Run an IMAP operation, turning transport failures into tidy 502s
|
|
154
|
+
* Run an IMAP operation, turning transport failures into tidy 502s
|
|
155
|
+
* (resolving to undefined after replying).
|
|
155
156
|
*
|
|
156
157
|
* @param {object} reply
|
|
157
158
|
* @param {() => Promise<any>} fn
|
|
@@ -163,7 +164,11 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
163
164
|
} catch (err) {
|
|
164
165
|
fastify.log.error({err}, `[${pluginName}] IMAP operation failed`);
|
|
165
166
|
reply.code(502).send({error: describeError(err)});
|
|
166
|
-
|
|
167
|
+
// undefined, NOT null: an async handler that resolves to anything
|
|
168
|
+
// but undefined has it sent as well, and Fastify logs "Reply was
|
|
169
|
+
// already sent" over the 502 just sent (every IMAP failure did it,
|
|
170
|
+
// /folders?counts=1 most visibly).
|
|
171
|
+
return undefined;
|
|
167
172
|
}
|
|
168
173
|
}
|
|
169
174
|
|
|
@@ -177,19 +182,40 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
177
182
|
* STATUS (no select, so nothing can be marked read), and kept a minute
|
|
178
183
|
* per user - the sidebar asks every minute from every open admin tab.
|
|
179
184
|
*/
|
|
185
|
+
// The badge's minute-long cache, per user. Forgotten whenever that user's
|
|
186
|
+
// mailboxes change or a read goes to the server anyway (a Refresh, the
|
|
187
|
+
// folder counts), so the screen's `dm:sidebar-count` after it gets the
|
|
188
|
+
// new number rather than the one from before.
|
|
189
|
+
//
|
|
190
|
+
// Each forget also bumps the user's generation: a badge request that was
|
|
191
|
+
// already on its way (asking IMAP) when the forget happened carries the
|
|
192
|
+
// old one, and must not write its now-stale count back into the cache.
|
|
193
|
+
const badgeCache = new Map();
|
|
194
|
+
const badgeGen = new Map();
|
|
195
|
+
const forgetBadge = (uid) => {
|
|
196
|
+
if (!uid) return;
|
|
197
|
+
badgeCache.delete(uid);
|
|
198
|
+
badgeGen.set(uid, (badgeGen.get(uid) ?? 0) + 1);
|
|
199
|
+
};
|
|
200
|
+
// A mailbox that failed (a changed password, a server down) is not asked
|
|
201
|
+
// again every minute: that is a failed IMAP login per minute per open tab,
|
|
202
|
+
// enough to trip fail2ban or a provider lockout. It backs off 5 min, doubling
|
|
203
|
+
// to an hour; saving the account (a new updatedAt) retries straight away, and
|
|
204
|
+
// so does the mailbox answering anywhere else (a Refresh, a folder listing) -
|
|
205
|
+
// or a working Refresh would leave the badge red for up to an hour.
|
|
206
|
+
const failures = new Map();
|
|
207
|
+
const failKey = (uid, account) => `${uid}:${account.id}:${account.updatedAt || account.createdAt || ''}`;
|
|
208
|
+
const clearFailures = (uid, accountId) => {
|
|
209
|
+
const prefix = `${uid}:${accountId}:`;
|
|
210
|
+
for (const key of failures.keys()) if (key.startsWith(prefix)) failures.delete(key);
|
|
211
|
+
};
|
|
180
212
|
if (withUnreadBadge) {
|
|
181
|
-
const badgeCache = new Map();
|
|
182
|
-
// A mailbox that failed (a changed password, a server down) is not asked
|
|
183
|
-
// again every minute: that is a failed IMAP login per minute per open tab,
|
|
184
|
-
// enough to trip fail2ban or a provider lockout. It backs off 5 min, doubling
|
|
185
|
-
// to an hour; saving the account (a new updatedAt) retries straight away.
|
|
186
|
-
const failures = new Map();
|
|
187
|
-
const failKey = (uid, account) => `${uid}:${account.id}:${account.updatedAt || account.createdAt || ''}`;
|
|
188
213
|
fastify.get('/unread', {preHandler: [authenticate]}, async (request, reply) => {
|
|
189
214
|
const uid = userId(request);
|
|
190
215
|
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
191
216
|
const hit = badgeCache.get(uid);
|
|
192
217
|
if (hit && Date.now() - hit.at < 60_000) return hit.value;
|
|
218
|
+
const gen = badgeGen.get(uid) ?? 0;
|
|
193
219
|
const boxes = [];
|
|
194
220
|
for (const account of await accounts.listAccounts(uid)) {
|
|
195
221
|
const key = failKey(uid, account);
|
|
@@ -207,7 +233,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
207
233
|
}
|
|
208
234
|
}
|
|
209
235
|
const value = unreadBadge(boxes);
|
|
210
|
-
|
|
236
|
+
// Only if nothing changed while IMAP was being asked.
|
|
237
|
+
if ((badgeGen.get(uid) ?? 0) === gen) badgeCache.set(uid, {at: Date.now(), value});
|
|
211
238
|
return value;
|
|
212
239
|
});
|
|
213
240
|
}
|
|
@@ -235,6 +262,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
235
262
|
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
236
263
|
|
|
237
264
|
const saved = await accounts.saveAccount(uid, null, result.value);
|
|
265
|
+
forgetBadge(uid);
|
|
238
266
|
return {account: accounts.toPublic(saved)};
|
|
239
267
|
});
|
|
240
268
|
|
|
@@ -255,6 +283,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
255
283
|
const saved = await accounts.saveAccount(uid, request.params.id, result.value);
|
|
256
284
|
// The pooled connection was opened with the old details.
|
|
257
285
|
pool.release(`${uid}:${request.params.id}`);
|
|
286
|
+
forgetBadge(uid);
|
|
258
287
|
return {account: accounts.toPublic(saved)};
|
|
259
288
|
});
|
|
260
289
|
|
|
@@ -277,6 +306,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
277
306
|
const existing = await accounts.getAccount(uid, request.params.id);
|
|
278
307
|
const deleted = await accounts.deleteAccount(uid, request.params.id);
|
|
279
308
|
if (!deleted) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
309
|
+
forgetBadge(uid);
|
|
280
310
|
// A signed-in mailbox hands its sign-in back where the provider allows
|
|
281
311
|
// it (Google). Best-effort and after the delete: forgetting it here
|
|
282
312
|
// must not wait on, or fail because of, the provider.
|
|
@@ -366,7 +396,10 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
366
396
|
// folder listing pays.
|
|
367
397
|
if (request.query.counts === '1') {
|
|
368
398
|
await withUnreadCounts(ctx, folders);
|
|
399
|
+
forgetBadge(ctx.uid);
|
|
369
400
|
}
|
|
401
|
+
// The mailbox answered: it is not failing any more.
|
|
402
|
+
clearFailures(ctx.uid, ctx.account.id);
|
|
370
403
|
|
|
371
404
|
return {accountId: ctx.account.id, folders};
|
|
372
405
|
});
|
|
@@ -429,6 +462,11 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
429
462
|
// answered from the store - instant, and no connection needed. It
|
|
430
463
|
// returns null for anything not mirrored, which falls through to the
|
|
431
464
|
// wire exactly as the free edition always does.
|
|
465
|
+
if (request.query.fresh === '1') {
|
|
466
|
+
// A Refresh: ask the server again for the badge, back-off or not.
|
|
467
|
+
forgetBadge(ctx.uid);
|
|
468
|
+
clearFailures(ctx.uid, ctx.account.id);
|
|
469
|
+
}
|
|
432
470
|
if (cachedList && request.query.fresh !== '1') {
|
|
433
471
|
try {
|
|
434
472
|
const cached = await cachedList({
|
|
@@ -27,7 +27,10 @@ export function unreadBadge(boxes = []) {
|
|
|
27
27
|
label: boxes.length
|
|
28
28
|
? `${plural(count, 'unread message')} in the inbox${boxes.length > 1 ? 'es' : ''}${failed ? ` (${plural(failed, 'mailbox', 'mailboxes')} could not be checked)` : ''}`
|
|
29
29
|
: 'No mailbox set up',
|
|
30
|
-
|
|
30
|
+
// A mailbox that cannot be reached is the thing worth seeing: red, and
|
|
31
|
+
// shown even at 0 - otherwise a broken password reads as "all read".
|
|
32
|
+
tone: failed ? 'danger' : 'info',
|
|
33
|
+
...(failed ? {showZero: true} : {}),
|
|
31
34
|
details: {
|
|
32
35
|
title: 'Mail',
|
|
33
36
|
rows: boxes.map(b => ({
|
package/plugins/blog/CLAUDE.md
CHANGED
|
@@ -339,3 +339,17 @@ Declared in plugin.json `permissions` (granted once to `admin`), checked on EVER
|
|
|
339
339
|
read. **Every non-GET route of this plugin forgets it in an `onSend` hook** - before the writer's response leaves -
|
|
340
340
|
so the admin's own next read, and the public POST /comments, are fresh. The TTL only covers writes made outside
|
|
341
341
|
the plugin (Collections tool, Blog Pro). Callers share the cached array: filter/sort copies, never mutate it.
|
|
342
|
+
|
|
343
|
+
## 1.10.0 (2026-10-15 Tool Upgrade, second pass)
|
|
344
|
+
|
|
345
|
+
- **Drafts**: pure rules in `admin/lib/drafts.js` (tests/drafts.test.js); storage via `drafts.read/keep/drop` in
|
|
346
|
+
views/kit.js, one key per draft (`blog.draft.<key>`), never a map. A kept draft carries `was`; the restore question
|
|
347
|
+
puts "Throw away" on OK, so Cancel/Escape keep it, and a draft is removed only after an answer.
|
|
348
|
+
- **`post.new` is owned**: each editor mount stamps an `owner` token. A new post's first save drops `post.new` only
|
|
349
|
+
when it is this editor's; leaving during that save keeps the draft under the post's real id (back to `post.new`
|
|
350
|
+
if the save fails); `keepDraft()` never overwrites another editor's `post.new` (warning toast instead).
|
|
351
|
+
- `viewScope()` returns `add` with `add.here()` - check it after EVERY await, including right after `scanHelp`
|
|
352
|
+
(an overtaken mount disposes its popovers and returns before binding).
|
|
353
|
+
- `menuKeys(list, rowSel)`: Shift+F10 on a focused row opens its menu. `pokeSidebar()` after any change that moves
|
|
354
|
+
the badge. `withSkeleton(slot, promise, shape)` wraps core's loading.js (slot must be a block element).
|
|
355
|
+
- Settings: leaving always keeps the draft (the old `unloading` flag is gone).
|
|
@@ -204,3 +204,8 @@
|
|
|
204
204
|
.blog-save-state.is-dirty { color: var(--dm-warning, #d97706); }
|
|
205
205
|
.blog-save-state.is-dirty::before { content: '●'; margin-right: .3rem; font-size: .7em; vertical-align: .1em; }
|
|
206
206
|
.blog-editor-bar .btn { display: inline-flex; align-items: center; gap: .35rem; }
|
|
207
|
+
|
|
208
|
+
/* The first-load skeleton's slot (admin/js/lib/loading.js draws into it). No
|
|
209
|
+
display rule here: one would beat the [hidden] the binding sets. */
|
|
210
|
+
.blog-loading { color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
|
|
211
|
+
.blog-editor[hidden] { display: none !important; }
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kept drafts - what an editor left unsaved when its screen (or panel) was
|
|
3
|
+
* left, and how to tell, when it is offered back, whether the record moved on
|
|
4
|
+
* meanwhile.
|
|
5
|
+
*
|
|
6
|
+
* PURE: no DOM, no storage - the views keep each draft under its own key (one
|
|
7
|
+
* per editor, never a map of drafts) and tests/drafts.test.js covers this.
|
|
8
|
+
*
|
|
9
|
+
* A draft is `{id, name, values, was, at}`: `id` the record (null for a new
|
|
10
|
+
* one), `was` the record's version when editing BEGAN (its updatedAt, or a
|
|
11
|
+
* fingerprint of the fields that matter when it has none - a category, the
|
|
12
|
+
* settings), `at` when it was kept.
|
|
13
|
+
*
|
|
14
|
+
* @module blog/admin/lib/drafts
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** JSON with sorted keys, so {a, b} and {b, a} are one value. */
|
|
18
|
+
export function stableJson(value) {
|
|
19
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
20
|
+
if (value && typeof value === 'object') {
|
|
21
|
+
return `{${Object.keys(value).sort().filter(k => value[k] !== undefined)
|
|
22
|
+
.map(k => `${JSON.stringify(k)}:${stableJson(value[k])}`).join(',')}}`;
|
|
23
|
+
}
|
|
24
|
+
return JSON.stringify(value ?? null);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A short fingerprint of a value (FNV-1a over its stable JSON). */
|
|
28
|
+
export function fingerprint(value) {
|
|
29
|
+
const s = stableJson(value);
|
|
30
|
+
let h = 0x811c9dc5;
|
|
31
|
+
for (let i = 0; i < s.length; i++) {
|
|
32
|
+
h ^= s.charCodeAt(i);
|
|
33
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
34
|
+
}
|
|
35
|
+
return `f:${h.toString(36)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A record's version: its updatedAt when it has one, else a fingerprint of
|
|
40
|
+
* `keys` (or of the whole record). '' for no record.
|
|
41
|
+
*
|
|
42
|
+
* @param {object|null} record
|
|
43
|
+
* @param {string[]} [keys] - the fields that matter
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function versionOf(record, keys = null) {
|
|
47
|
+
if (!record || typeof record !== 'object') return '';
|
|
48
|
+
if (record.updatedAt) return `u:${record.updatedAt}`;
|
|
49
|
+
const picked = keys ? Object.fromEntries(keys.map(k => [k, record[k] ?? null])) : record;
|
|
50
|
+
return fingerprint(picked);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How a kept draft stands against the record as it is now.
|
|
55
|
+
*
|
|
56
|
+
* @param {{id?: string|null, values?: object, was?: string, at?: string}|null} draft
|
|
57
|
+
* @param {object|null} record - null when it no longer exists
|
|
58
|
+
* @param {string[]} [keys] - as versionOf
|
|
59
|
+
* @returns {'none'|'same'|'changed'|'deleted'}
|
|
60
|
+
*/
|
|
61
|
+
export function draftState(draft, record, keys = null) {
|
|
62
|
+
if (!draft || !draft.values) return 'none';
|
|
63
|
+
if (!draft.id) return 'same';
|
|
64
|
+
if (!record) return 'deleted';
|
|
65
|
+
if (draft.was) return draft.was === versionOf(record, keys) ? 'same' : 'changed';
|
|
66
|
+
// A draft kept before `was` existed: by the clock, when the record says when it was saved.
|
|
67
|
+
return draft.at && record.updatedAt && String(record.updatedAt) > String(draft.at) ? 'changed' : 'same';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The question asked when a kept draft is found, as plain text (the caller
|
|
72
|
+
* escapes it). Asked so that OK is the DESTRUCTIVE answer - "Throw away" -
|
|
73
|
+
* because Domma's E.confirm resolves false for Cancel, Escape and a backdrop
|
|
74
|
+
* click alike, and dismissing must never lose someone's work: Cancel keeps
|
|
75
|
+
* the changes (puts them back in the form).
|
|
76
|
+
*
|
|
77
|
+
* @param {object} o
|
|
78
|
+
* @param {string} o.what - 'post', 'category', 'settings'…
|
|
79
|
+
* @param {string} [o.name] - the record's name, when it has one
|
|
80
|
+
* @param {'same'|'changed'|'deleted'} o.state
|
|
81
|
+
* @param {boolean} [o.isNew] - the draft is of a record not yet saved
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function restoreQuestion({what, name = '', state, isNew = false}) {
|
|
85
|
+
const which = isNew ? `a new ${what}` : name ? `the ${what} "${name}"` : `the ${what}`;
|
|
86
|
+
const note = state === 'changed'
|
|
87
|
+
? ` It has been saved since you left them - keeping yours puts your older changes over it once you save.`
|
|
88
|
+
: state === 'deleted'
|
|
89
|
+
? ` It has been deleted since - keeping yours starts it again as a new ${what}.`
|
|
90
|
+
: '';
|
|
91
|
+
return `Throw away the changes you left unsaved to ${which}?${note}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The answers that go with restoreQuestion: OK throws away, Cancel (and Escape) keeps. */
|
|
95
|
+
export const RESTORE_ANSWERS = Object.freeze({confirmText: 'Throw away', cancelText: 'Keep them'});
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
<span data-help="Search matches titles, excerpts and slugs. Right-click a post, or use its ⋮, to publish, unpublish, duplicate or delete it."></span>
|
|
31
31
|
</div>
|
|
32
32
|
|
|
33
|
-
<
|
|
33
|
+
<div class="blog-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading posts…</div>
|
|
34
34
|
<p class="blog-state" data-if="isEmpty.value" data-bind-text="emptyText.value"></p>
|
|
35
35
|
|
|
36
36
|
<div class="blog-list">
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
</span>
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
|
-
<
|
|
14
|
+
<div class="blog-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading categories…</div>
|
|
15
15
|
<p class="blog-state" data-if="isEmpty.value">No categories yet - New category adds one.</p>
|
|
16
16
|
|
|
17
17
|
<div class="blog-list">
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
</p>
|
|
20
20
|
<p class="blog-note blog-note--quiet" data-if="!commentsOff.value" data-bind-text="moderationText.value"></p>
|
|
21
21
|
|
|
22
|
-
<
|
|
22
|
+
<div class="blog-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading comments…</div>
|
|
23
23
|
<p class="blog-state" data-if="isEmpty.value" data-bind-text="emptyText.value"></p>
|
|
24
24
|
|
|
25
25
|
<div class="blog-list">
|
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
</div>
|
|
17
17
|
</div>
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
<!-- The skeleton's slot while the post loads; the form stays hidden until it is in
|
|
20
|
+
(typed into before the post arrived, it was overwritten). -->
|
|
21
|
+
<div class="blog-loading dm-loading-slot">Loading the post…</div>
|
|
22
|
+
|
|
23
|
+
<div class="flex gap-4 blog-editor" hidden>
|
|
20
24
|
<!-- Main content area -->
|
|
21
25
|
<div class="blog-editor-main">
|
|
22
26
|
<div class="card mb-3">
|
|
@@ -54,7 +54,9 @@ export const blogView = {
|
|
|
54
54
|
const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
|
|
55
55
|
const {BASE} = kit;
|
|
56
56
|
const add = kit.viewScope('posts', /^#\/plugins\/blog(\/posts)?\/?$/);
|
|
57
|
+
const here = add.here;
|
|
57
58
|
const root = $container.get(0);
|
|
59
|
+
const slot = root.querySelector('.blog-loading');
|
|
58
60
|
|
|
59
61
|
// ---- State ----
|
|
60
62
|
const posts = M.observable([]);
|
|
@@ -129,21 +131,26 @@ export const blogView = {
|
|
|
129
131
|
// ---- Server ----
|
|
130
132
|
const byId = (id) => posts.peek().find(p => p.id === id);
|
|
131
133
|
|
|
134
|
+
// The first load draws a skeleton; a reload keeps the rows on screen.
|
|
135
|
+
let loaded = false;
|
|
132
136
|
async function load() {
|
|
133
|
-
|
|
137
|
+
const work = Promise.all([
|
|
138
|
+
H.get(`${BASE}/posts`),
|
|
139
|
+
H.get(`${BASE}/categories`).catch(() => []),
|
|
140
|
+
kit.sitePath()
|
|
141
|
+
]);
|
|
134
142
|
try {
|
|
135
|
-
const [postsRes, catsRes] = await
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
]);
|
|
139
|
-
site = await kit.sitePath();
|
|
143
|
+
const [postsRes, catsRes, base] = loaded ? await work : await kit.withSkeleton(slot, work);
|
|
144
|
+
if (!here()) return;
|
|
145
|
+
site = base;
|
|
140
146
|
// GET /posts sends a BARE ARRAY; `.data` is only for a wrapped reply.
|
|
141
147
|
posts.value = postsRes?.data ?? (Array.isArray(postsRes) ? postsRes : []);
|
|
142
148
|
categories.value = catsRes?.data ?? (Array.isArray(catsRes) ? catsRes : []);
|
|
149
|
+
loaded = true;
|
|
143
150
|
} catch {
|
|
144
|
-
E.toast('Failed to load posts.', {type: 'error'});
|
|
151
|
+
if (here()) E.toast('Failed to load posts.', {type: 'error'});
|
|
145
152
|
} finally {
|
|
146
|
-
loading.value = false;
|
|
153
|
+
if (here()) loading.value = false;
|
|
147
154
|
}
|
|
148
155
|
}
|
|
149
156
|
|
|
@@ -155,19 +162,25 @@ export const blogView = {
|
|
|
155
162
|
|
|
156
163
|
async function publish(id) {
|
|
157
164
|
try {
|
|
158
|
-
|
|
165
|
+
const res = await H.post(`${BASE}/posts/${id}/publish`, {});
|
|
166
|
+
kit.pokeSidebar();
|
|
167
|
+
if (!here()) return;
|
|
168
|
+
upsert(res);
|
|
159
169
|
E.toast('Post published.', {type: 'success'});
|
|
160
170
|
} catch (err) {
|
|
161
|
-
E.toast(err?.message ?? 'Failed to publish.', {type: 'error'});
|
|
171
|
+
if (here()) E.toast(err?.message ?? 'Failed to publish.', {type: 'error'});
|
|
162
172
|
}
|
|
163
173
|
}
|
|
164
174
|
|
|
165
175
|
async function unpublish(id) {
|
|
166
176
|
try {
|
|
167
|
-
|
|
177
|
+
const res = await H.put(`${BASE}/posts/${id}`, {status: 'draft'});
|
|
178
|
+
kit.pokeSidebar();
|
|
179
|
+
if (!here()) return;
|
|
180
|
+
upsert(res);
|
|
168
181
|
E.toast('Post moved back to drafts.', {type: 'success'});
|
|
169
182
|
} catch (err) {
|
|
170
|
-
E.toast(err?.message ?? 'Failed to unpublish.', {type: 'error'});
|
|
183
|
+
if (here()) E.toast(err?.message ?? 'Failed to unpublish.', {type: 'error'});
|
|
171
184
|
}
|
|
172
185
|
}
|
|
173
186
|
|
|
@@ -181,10 +194,12 @@ export const blogView = {
|
|
|
181
194
|
content: p.content ?? '', excerpt: p.excerpt ?? '', featuredImage: p.featuredImage ?? null,
|
|
182
195
|
categories: p.categories ?? [], tags: p.tags ?? [], template: p.template ?? '', status: 'draft'
|
|
183
196
|
});
|
|
197
|
+
kit.pokeSidebar();
|
|
198
|
+
if (!here()) return;
|
|
184
199
|
upsert(copy);
|
|
185
200
|
E.toast('Copied as a draft.', {type: 'success'});
|
|
186
201
|
} catch (err) {
|
|
187
|
-
E.toast(err?.message ?? 'Failed to duplicate.', {type: 'error'});
|
|
202
|
+
if (here()) E.toast(err?.message ?? 'Failed to duplicate.', {type: 'error'});
|
|
188
203
|
}
|
|
189
204
|
}
|
|
190
205
|
|
|
@@ -194,13 +209,15 @@ export const blogView = {
|
|
|
194
209
|
const ok = await E.confirm(p?.status === 'published'
|
|
195
210
|
? 'Delete this published post? Its page goes offline and links to it will break.'
|
|
196
211
|
: 'Delete this post? This cannot be undone.');
|
|
197
|
-
if (!ok) return;
|
|
212
|
+
if (!ok || !here()) return;
|
|
198
213
|
try {
|
|
199
214
|
await H.delete(`${BASE}/posts/${id}`);
|
|
215
|
+
kit.pokeSidebar();
|
|
216
|
+
if (!here()) return;
|
|
200
217
|
posts.value = posts.peek().filter(x => x.id !== id);
|
|
201
218
|
E.toast('Post deleted.', {type: 'success'});
|
|
202
219
|
} catch {
|
|
203
|
-
E.toast('Failed to delete post.', {type: 'error'});
|
|
220
|
+
if (here()) E.toast('Failed to delete post.', {type: 'error'});
|
|
204
221
|
}
|
|
205
222
|
}
|
|
206
223
|
|
|
@@ -218,7 +235,10 @@ export const blogView = {
|
|
|
218
235
|
const list = root.querySelector('.blog-list');
|
|
219
236
|
root.querySelector('.blog-rows').innerHTML = ROW_TEMPLATE;
|
|
220
237
|
root.querySelector('.blog-chips').innerHTML = CHIP_TEMPLATE;
|
|
221
|
-
|
|
238
|
+
// Overtaken while it loaded: register nothing - the lifetime is the new mount's now.
|
|
239
|
+
const offHelp = await kit.scanHelp(root);
|
|
240
|
+
if (!here()) { offHelp(); return; }
|
|
241
|
+
add(offHelp);
|
|
222
242
|
const handle = M.applyBindings(data, root, {
|
|
223
243
|
methods: {
|
|
224
244
|
create: () => R.navigate('#/plugins/blog/posts/new'),
|
|
@@ -294,22 +314,26 @@ export const blogView = {
|
|
|
294
314
|
try {
|
|
295
315
|
const res = await H.post(`${BASE}/samples`, {});
|
|
296
316
|
if (res?.error) throw new Error(res.error);
|
|
317
|
+
kit.pokeSidebar();
|
|
318
|
+
if (!here()) return;
|
|
297
319
|
E.toast(res.added ? `Added ${res.added} sample posts. Remove them from ⋮ whenever you like.` : 'The sample posts are already here.', {type: 'success'});
|
|
298
320
|
await load();
|
|
299
321
|
} catch (err) {
|
|
300
|
-
E.toast(err?.message ?? 'Could not add the sample posts.', {type: 'error'});
|
|
322
|
+
if (here()) E.toast(err?.message ?? 'Could not add the sample posts.', {type: 'error'});
|
|
301
323
|
}
|
|
302
324
|
}
|
|
303
325
|
|
|
304
326
|
async function removeSamples() {
|
|
305
|
-
if (!(await E.confirm('Remove the sample posts, and any sample category nothing else uses? Your own posts are not touched.'))) return;
|
|
327
|
+
if (!(await E.confirm('Remove the sample posts, and any sample category nothing else uses? Your own posts are not touched.')) || !here()) return;
|
|
306
328
|
try {
|
|
307
329
|
const res = await H.delete(`${BASE}/samples`);
|
|
308
330
|
if (res?.error) throw new Error(res.error);
|
|
331
|
+
kit.pokeSidebar();
|
|
332
|
+
if (!here()) return;
|
|
309
333
|
E.toast(`Removed ${res.posts} sample posts.`, {type: 'success'});
|
|
310
334
|
await load();
|
|
311
335
|
} catch (err) {
|
|
312
|
-
E.toast(err?.message ?? 'Could not remove the sample posts.', {type: 'error'});
|
|
336
|
+
if (here()) E.toast(err?.message ?? 'Could not remove the sample posts.', {type: 'error'});
|
|
313
337
|
}
|
|
314
338
|
}
|
|
315
339
|
|
|
@@ -324,6 +348,7 @@ export const blogView = {
|
|
|
324
348
|
const row = e.target.closest?.('.blog-row');
|
|
325
349
|
if (row && e.key === 'Enter' && e.target === row) R.navigate(`#/plugins/blog/posts/${row.dataset.id}`);
|
|
326
350
|
});
|
|
351
|
+
add(kit.menuKeys(list, '.blog-row'));
|
|
327
352
|
|
|
328
353
|
if (typeof I !== 'undefined' && I.scan) I.scan(root);
|
|
329
354
|
await load();
|