domma-cms 0.69.4 → 0.70.1
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/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/lib/shortcode-context-menu.js +1 -1
- package/admin/js/views/analytics.js +1 -1
- package/admin/js/views/contacts.js +1 -1
- package/admin/js/views/context-menu-editor.js +1 -1
- package/admin/js/views/notes.js +1 -1
- package/admin/js/views/todo.js +1 -1
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/reader-view.js +2 -0
- package/plugins/blog/CLAUDE.md +23 -2
- package/plugins/blog/admin/css/index.css +28 -0
- package/plugins/blog/admin/templates/post-editor.html +25 -9
- package/plugins/blog/admin/views/blog.js +2 -0
- package/plugins/blog/admin/views/categories.js +1 -0
- package/plugins/blog/admin/views/comments.js +1 -0
- package/plugins/blog/admin/views/kit.js +1 -1
- package/plugins/blog/admin/views/post-editor.js +228 -45
- package/plugins/blog/blocks/blog-post-essay.css +29 -0
- package/plugins/blog/blocks/blog-post-essay.html +28 -0
- package/plugins/blog/blocks/blog-post-split.css +22 -0
- package/plugins/blog/blocks/blog-post-split.html +32 -0
- package/plugins/blog/lib/layouts.js +4 -2
- package/plugins/blog/lib/nav-link.js +72 -0
- package/plugins/blog/plugin.js +40 -1
- package/plugins/blog/plugin.json +6 -6
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/layouts.test.js +1 -1
- package/plugins/blog/tests/nav-link.test.js +57 -0
- package/plugins/free-tier.lock.json +21 -15
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -1
|
@@ -67,7 +67,7 @@ async function mountLayoutCard(card, {current, collect}) {
|
|
|
67
67
|
try { catalogue = await H.get(`${BASE_URL}/layouts`); } catch { /* the card still offers Site default */ }
|
|
68
68
|
const known = catalogue.postLayouts ?? [];
|
|
69
69
|
const defaultLabel = known.find(l => l.id === catalogue.postLayout)?.label ?? 'Classic';
|
|
70
|
-
const thumbOf = (id) => (['blog-post-classic', 'blog-post-feature', 'blog-post-minimal', 'blog-post-sidebar'].includes(id) ? id.slice(10) : 'custom');
|
|
70
|
+
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');
|
|
71
71
|
|
|
72
72
|
const choices = [
|
|
73
73
|
{id: '', label: 'Site default', description: `Follows Blog settings - currently ${defaultLabel}.`, thumb: thumbOf(catalogue.postLayout)},
|
|
@@ -155,6 +155,173 @@ async function mountLayoutCard(card, {current, collect}) {
|
|
|
155
155
|
return {value: () => layout.peek()};
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
const STAMP_FORMAT = 'D MMM YYYY, HH:mm';
|
|
159
|
+
const STATUS_CLASS = {published: 'badge-success', scheduled: 'badge-warning'};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The Status card: the badge, and when the post went (or goes) live, was last
|
|
163
|
+
* saved and was created. Reactive: `set(post)` after any load, save, publish or
|
|
164
|
+
* schedule and the card follows. The "3 days ago" part ticks once a minute.
|
|
165
|
+
*
|
|
166
|
+
* Every computed reads the observables directly - a binding hung off a
|
|
167
|
+
* computed derived from another computed applies once and then freezes.
|
|
168
|
+
*
|
|
169
|
+
* @param {HTMLElement} card
|
|
170
|
+
* @returns {{set: (post: object|null) => void, status: object, dispose: () => void}}
|
|
171
|
+
*/
|
|
172
|
+
function mountStatusCard(card) {
|
|
173
|
+
const status = M.observable('draft');
|
|
174
|
+
const publishedAt = M.observable('');
|
|
175
|
+
const updatedAt = M.observable('');
|
|
176
|
+
const createdAt = M.observable('');
|
|
177
|
+
const now = M.observable(Date.now());
|
|
178
|
+
const tick = setInterval(() => { now.value = Date.now(); }, 60000);
|
|
179
|
+
|
|
180
|
+
const when = (iso) => (iso && !Number.isNaN(Date.parse(iso)) ? D(iso).format(STAMP_FORMAT) : '');
|
|
181
|
+
const ago = (iso, at) => (iso && !Number.isNaN(Date.parse(iso)) ? `(${D(iso).fromNow(at)})` : '');
|
|
182
|
+
|
|
183
|
+
const data = {
|
|
184
|
+
status,
|
|
185
|
+
statusClass: M.computed(() => STATUS_CLASS[status.value] ?? 'badge-secondary'),
|
|
186
|
+
liveLabel: M.computed(() => (status.value === 'scheduled' ? 'Goes live' : 'Published')),
|
|
187
|
+
liveWhen: M.computed(() => (status.value === 'draft' ? '' : when(publishedAt.value))),
|
|
188
|
+
liveAgo: M.computed(() => (status.value === 'draft' ? '' : ago(publishedAt.value, now.value))),
|
|
189
|
+
savedWhen: M.computed(() => when(updatedAt.value)),
|
|
190
|
+
savedAgo: M.computed(() => ago(updatedAt.value, now.value)),
|
|
191
|
+
createdWhen: M.computed(() => when(createdAt.value))
|
|
192
|
+
};
|
|
193
|
+
const handle = M.applyBindings(data, card.querySelector('.card-body'));
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
status,
|
|
197
|
+
set(post) {
|
|
198
|
+
publishedAt.value = post?.publishedAt ?? '';
|
|
199
|
+
updatedAt.value = post?.updatedAt ?? post?.createdAt ?? '';
|
|
200
|
+
createdAt.value = post?.createdAt ?? '';
|
|
201
|
+
now.value = Date.now();
|
|
202
|
+
},
|
|
203
|
+
dispose() { clearInterval(tick); handle?.dispose?.(); }
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A typeable pick-list with the choices shown as pills, each with an x -
|
|
209
|
+
* Domma's Pillbox over a plain input. Clicking the box (or ArrowDown in it)
|
|
210
|
+
* drops the whole list; typing narrows it.
|
|
211
|
+
*
|
|
212
|
+
* `creatable` also takes free text: Enter or a comma makes a pill of what was
|
|
213
|
+
* typed, a pasted "a, b, c" becomes three, and text left in the box when it
|
|
214
|
+
* loses focus is kept rather than lost. Matching ignores case, so "News" and
|
|
215
|
+
* "news" are one tag.
|
|
216
|
+
*
|
|
217
|
+
* Labels reach the page as text, never markup: category names and tags are
|
|
218
|
+
* typed by people.
|
|
219
|
+
*
|
|
220
|
+
* @param {HTMLInputElement} input
|
|
221
|
+
* @param {object} opts
|
|
222
|
+
* @param {{value: string, label: string}[]} opts.data - the choices
|
|
223
|
+
* @param {string[]} opts.value - selected values
|
|
224
|
+
* @param {boolean} [opts.creatable=false]
|
|
225
|
+
* @param {string} opts.placeholder
|
|
226
|
+
* @param {string} opts.empty - shown when nothing matches
|
|
227
|
+
* @param {object} opts.selected - an observable the values are written to
|
|
228
|
+
* @returns {{pending: () => string, destroy: () => void}}
|
|
229
|
+
*/
|
|
230
|
+
function mountPills(input, {data, value, creatable = false, placeholder, empty, selected}) {
|
|
231
|
+
const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
|
|
232
|
+
let box = null;
|
|
233
|
+
let field = null;
|
|
234
|
+
box = E.pillbox(input, {
|
|
235
|
+
data,
|
|
236
|
+
value,
|
|
237
|
+
searchable: true,
|
|
238
|
+
creatable,
|
|
239
|
+
clearable: false,
|
|
240
|
+
placeholder,
|
|
241
|
+
noResultsMessage: empty,
|
|
242
|
+
duplicateMessage: 'Already added',
|
|
243
|
+
renderPill: ({label}) => {
|
|
244
|
+
const span = document.createElement('span');
|
|
245
|
+
span.textContent = label;
|
|
246
|
+
return span;
|
|
247
|
+
},
|
|
248
|
+
validatePill: (v) => (box && box.getValue().some(x => same(x, v)) ? 'Already added' : true),
|
|
249
|
+
onChange: (values) => { selected.value = [...values]; },
|
|
250
|
+
onCreate: () => box?.close(),
|
|
251
|
+
// A repeat is dropped, and so is its text - left in the box it ran
|
|
252
|
+
// into the next word typed.
|
|
253
|
+
onValidationError: (msg, value) => {
|
|
254
|
+
if (field) field.value = '';
|
|
255
|
+
box?.close();
|
|
256
|
+
E.toast(msg === 'Already added' ? `"${value}" is already on this post.` : msg, {type: 'info'});
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
selected.value = box.getValue();
|
|
260
|
+
|
|
261
|
+
const wrapper = input.nextElementSibling;
|
|
262
|
+
field = wrapper?.querySelector('.dm-pillbox-input');
|
|
263
|
+
if (!field) return {pending: () => '', destroy: () => box.destroy?.()};
|
|
264
|
+
|
|
265
|
+
// Pillbox only opens once something is typed; a list you cannot see is a
|
|
266
|
+
// list you cannot pick from. _filterOptions is Pillbox's own "narrow to
|
|
267
|
+
// this" - with '' it is "everything not yet chosen".
|
|
268
|
+
const showAll = () => {
|
|
269
|
+
if (field.value || typeof box._filterOptions !== 'function') return;
|
|
270
|
+
if (!data.some(d => !box.getValue().includes(d.value))) return;
|
|
271
|
+
box._filterOptions('');
|
|
272
|
+
};
|
|
273
|
+
wrapper.querySelector('.dm-pillbox-container')?.addEventListener('click', showAll);
|
|
274
|
+
field.addEventListener('keydown', (e) => {
|
|
275
|
+
if (e.key === 'ArrowDown' && !box.isOpen()) { e.preventDefault(); showAll(); }
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const commit = (text) => {
|
|
279
|
+
const t = String(text).trim();
|
|
280
|
+
if (!t) return;
|
|
281
|
+
const known = data.find(d => same(d.label, t));
|
|
282
|
+
if (box.getValue().some(x => same(x, known?.value ?? t))) {
|
|
283
|
+
E.toast(`"${t}" is already on this post.`, {type: 'info'});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
box.addPill(known?.value ?? t, known?.label ?? t);
|
|
287
|
+
};
|
|
288
|
+
if (creatable) {
|
|
289
|
+
// Enter with no suggestion highlighted goes through commit() rather
|
|
290
|
+
// than Pillbox's own create, so "RELEASE" becomes the "release" the
|
|
291
|
+
// site already uses instead of a second spelling of it. Capture phase:
|
|
292
|
+
// this has to run before Pillbox's handler on the input.
|
|
293
|
+
wrapper.addEventListener('keydown', (e) => {
|
|
294
|
+
if (e.target !== field || e.key !== 'Enter' || !field.value.trim()) return;
|
|
295
|
+
if (wrapper.querySelector('.dm-pillbox-option.active')) return;
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
e.stopPropagation();
|
|
298
|
+
commit(field.value);
|
|
299
|
+
field.value = '';
|
|
300
|
+
box.close();
|
|
301
|
+
}, true);
|
|
302
|
+
field.addEventListener('input', () => {
|
|
303
|
+
if (!field.value.includes(',')) return;
|
|
304
|
+
const parts = field.value.split(',');
|
|
305
|
+
const rest = parts.pop();
|
|
306
|
+
parts.forEach(commit);
|
|
307
|
+
field.value = rest.trimStart();
|
|
308
|
+
if (!field.value) box.close();
|
|
309
|
+
});
|
|
310
|
+
// Later than Pillbox's own blur (200ms), so a click on a suggestion
|
|
311
|
+
// wins over the half-typed word it was chosen to replace.
|
|
312
|
+
field.addEventListener('blur', () => setTimeout(() => {
|
|
313
|
+
if (document.activeElement === field || !field.value.trim()) return;
|
|
314
|
+
commit(field.value);
|
|
315
|
+
field.value = '';
|
|
316
|
+
}, 250));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
pending: () => (creatable ? field.value.trim() : ''),
|
|
321
|
+
destroy: () => box.destroy?.()
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
158
325
|
export const postEditorView = {
|
|
159
326
|
templateUrl: '/plugins/blog/admin/templates/post-editor.html',
|
|
160
327
|
|
|
@@ -180,7 +347,6 @@ export const postEditorView = {
|
|
|
180
347
|
|
|
181
348
|
let post = null;
|
|
182
349
|
let categories = [];
|
|
183
|
-
let selectedCategoryIds = [];
|
|
184
350
|
let featuredImageUrl = '';
|
|
185
351
|
|
|
186
352
|
try {
|
|
@@ -210,12 +376,11 @@ export const postEditorView = {
|
|
|
210
376
|
const excerptEl = $container.find('#post-excerpt').get(0);
|
|
211
377
|
const contentEl = $container.find('#post-content').get(0);
|
|
212
378
|
const tagsEl = $container.find('#post-tags').get(0);
|
|
379
|
+
const catsEl = $container.find('#post-categories').get(0);
|
|
213
380
|
const seoTitleEl = $container.find('#seo-title').get(0);
|
|
214
381
|
const seoDescEl = $container.find('#seo-description').get(0);
|
|
215
|
-
const statusBadge = $container.find('#post-status-badge').get(0);
|
|
216
382
|
const scheduleEl = $container.find('#schedule-picker').get(0);
|
|
217
383
|
const schedAtEl = $container.find('#post-scheduled-at').get(0);
|
|
218
|
-
const catsListEl = $container.find('#categories-checklist').get(0);
|
|
219
384
|
const imagePreviewEl = $container.find('#featured-image-preview').get(0);
|
|
220
385
|
const clearImageBtn = $container.find('#btn-clear-image').get(0);
|
|
221
386
|
|
|
@@ -226,11 +391,8 @@ export const postEditorView = {
|
|
|
226
391
|
if (postSlugEl) postSlugEl.value = post.slug ?? '';
|
|
227
392
|
if (excerptEl) excerptEl.value = post.excerpt ?? '';
|
|
228
393
|
if (contentEl) contentEl.value = post.content ?? '';
|
|
229
|
-
if (tagsEl) tagsEl.value = Array.isArray(post.tags) ? post.tags.join(', ') : (post.tags ?? '');
|
|
230
394
|
if (seoTitleEl) seoTitleEl.value = post.seo?.title ?? '';
|
|
231
395
|
if (seoDescEl) seoDescEl.value = post.seo?.description ?? '';
|
|
232
|
-
if (statusBadge) statusBadge.textContent = post.status ?? 'draft';
|
|
233
|
-
selectedCategoryIds = Array.isArray(post.categories) ? [...post.categories] : [];
|
|
234
396
|
featuredImageUrl = post.featuredImage ?? '';
|
|
235
397
|
renderImagePreview();
|
|
236
398
|
} else {
|
|
@@ -252,39 +414,51 @@ export const postEditorView = {
|
|
|
252
414
|
}
|
|
253
415
|
}
|
|
254
416
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
417
|
+
// ---- Status, categories and tags (domma-reactive) ----
|
|
418
|
+
const disposers = [];
|
|
419
|
+
const stamps = mountStatusCard($container.find('#status-card').get(0));
|
|
420
|
+
stamps.set(post);
|
|
421
|
+
disposers.push(() => stamps.dispose());
|
|
422
|
+
|
|
423
|
+
// A category deleted since the post was saved has no name to show; it
|
|
424
|
+
// is kept as it was rather than silently dropped on the next save.
|
|
425
|
+
const knownIds = new Set(categories.map(c => c.id));
|
|
426
|
+
const postCats = Array.isArray(post?.categories) ? post.categories : [];
|
|
427
|
+
const unknownCats = postCats.filter(cid => !knownIds.has(cid));
|
|
428
|
+
const selectedCats = M.observable([]);
|
|
429
|
+
const cats = catsEl ? mountPills(catsEl, {
|
|
430
|
+
data: categories.map(c => ({value: c.id, label: c.name ?? c.slug ?? c.id})),
|
|
431
|
+
value: postCats.filter(cid => knownIds.has(cid)),
|
|
432
|
+
placeholder: categories.length ? 'Type or click to choose…' : 'No categories yet',
|
|
433
|
+
empty: 'No category by that name',
|
|
434
|
+
selected: selectedCats
|
|
435
|
+
}) : null;
|
|
436
|
+
if (!categories.length) $container.find('#categories-note').get(0)?.classList.remove('blog-hidden');
|
|
437
|
+
disposers.push(() => cats?.destroy());
|
|
438
|
+
|
|
439
|
+
// Suggestions: every tag already on a post, most used first.
|
|
440
|
+
let tagChoices = [];
|
|
441
|
+
try {
|
|
442
|
+
const res = await H.get(`${BASE}/posts`);
|
|
443
|
+
const all = res?.data ?? (Array.isArray(res) ? res : []);
|
|
444
|
+
const counts = new Map();
|
|
445
|
+
for (const p of all) for (const t of (Array.isArray(p.tags) ? p.tags : [])) {
|
|
446
|
+
const k = String(t).trim();
|
|
447
|
+
if (k) counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
286
448
|
}
|
|
287
|
-
|
|
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 */ }
|
|
451
|
+
const postTags = Array.isArray(post?.tags) ? post.tags : String(post?.tags ?? '').split(',').map(t => t.trim()).filter(Boolean);
|
|
452
|
+
const selectedTags = M.observable([]);
|
|
453
|
+
const tags = tagsEl ? mountPills(tagsEl, {
|
|
454
|
+
data: tagChoices,
|
|
455
|
+
value: postTags,
|
|
456
|
+
creatable: true,
|
|
457
|
+
placeholder: 'Add a tag…',
|
|
458
|
+
empty: 'New tag - press Enter to add it',
|
|
459
|
+
selected: selectedTags
|
|
460
|
+
}) : null;
|
|
461
|
+
disposers.push(() => tags?.destroy());
|
|
288
462
|
|
|
289
463
|
const layoutCard = await mountLayoutCard($container.find('#layout-card').get(0), {
|
|
290
464
|
current: post?.template ?? '',
|
|
@@ -298,8 +472,9 @@ export const postEditorView = {
|
|
|
298
472
|
slug: postSlugEl?.value?.trim() ?? '',
|
|
299
473
|
excerpt: excerptEl?.value?.trim() ?? '',
|
|
300
474
|
content: contentEl?.value ?? '',
|
|
301
|
-
|
|
302
|
-
|
|
475
|
+
// Text still in the tag box counts: Ctrl+S from inside it saves it.
|
|
476
|
+
tags: [...new Set([...selectedTags.peek(), ...(tags?.pending() ? [tags.pending()] : [])])],
|
|
477
|
+
categories: [...selectedCats.peek(), ...unknownCats],
|
|
303
478
|
featuredImage: featuredImageUrl || null,
|
|
304
479
|
seo: {
|
|
305
480
|
title: seoTitleEl?.value?.trim() ?? '',
|
|
@@ -331,6 +506,7 @@ export const postEditorView = {
|
|
|
331
506
|
if (titleEl) titleEl.textContent = 'Editing post';
|
|
332
507
|
}
|
|
333
508
|
post = saved;
|
|
509
|
+
stamps.set(saved);
|
|
334
510
|
baseline = JSON.stringify(collectValues());
|
|
335
511
|
setStatus(saved.status ?? status);
|
|
336
512
|
showSaved();
|
|
@@ -363,10 +539,7 @@ export const postEditorView = {
|
|
|
363
539
|
}
|
|
364
540
|
function setStatus(next) {
|
|
365
541
|
status = next || 'draft';
|
|
366
|
-
|
|
367
|
-
statusBadge.textContent = status;
|
|
368
|
-
statusBadge.className = `badge ${({ published: 'badge-success', scheduled: 'badge-warning' })[status] ?? 'badge-secondary'}`;
|
|
369
|
-
}
|
|
542
|
+
stamps.status.value = status;
|
|
370
543
|
const live = status === 'published';
|
|
371
544
|
$container.find('#btn-publish').get(0)?.classList.toggle('blog-hidden', live);
|
|
372
545
|
$container.find('#btn-schedule').get(0)?.classList.toggle('blog-hidden', live);
|
|
@@ -382,6 +555,9 @@ export const postEditorView = {
|
|
|
382
555
|
queueMicrotask(() => { queued = false; refreshState(); });
|
|
383
556
|
};
|
|
384
557
|
['input', 'change', 'click'].forEach(ev => $container.get(0).addEventListener(ev, recheck));
|
|
558
|
+
// A pill made with Enter fires none of those.
|
|
559
|
+
const stopPillWatch = M.effect(() => { selectedCats.value; selectedTags.value; recheck(); });
|
|
560
|
+
disposers.push(() => stopPillWatch?.());
|
|
385
561
|
|
|
386
562
|
async function save() {
|
|
387
563
|
if (await doSave()) E.toast(status === 'published' ? 'Saved - the live post is updated.' : 'Saved.', { type: 'success' });
|
|
@@ -402,7 +578,11 @@ export const postEditorView = {
|
|
|
402
578
|
|
|
403
579
|
// Ctrl/Cmd+S saves; a closing tab with unsaved work asks first. Both
|
|
404
580
|
// come off when the hash leaves this editor (the router has no unmount).
|
|
581
|
+
// The admin's own Ctrl+S (app.js) clicks the view's primary Save and
|
|
582
|
+
// marks the event handled; saving again here made two requests - and
|
|
583
|
+
// on a new post, two posts. This one is for an admin without it.
|
|
405
584
|
const onKey = (e) => {
|
|
585
|
+
if (e.defaultPrevented) return;
|
|
406
586
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
|
407
587
|
e.preventDefault();
|
|
408
588
|
save();
|
|
@@ -418,6 +598,7 @@ export const postEditorView = {
|
|
|
418
598
|
document.removeEventListener('keydown', onKey);
|
|
419
599
|
window.removeEventListener('beforeunload', onUnload);
|
|
420
600
|
window.removeEventListener('hashchange', onHash);
|
|
601
|
+
disposers.splice(0).forEach(fn => { try { fn(); } catch { /* already gone */ } });
|
|
421
602
|
};
|
|
422
603
|
window.addEventListener('hashchange', onHash);
|
|
423
604
|
|
|
@@ -460,6 +641,7 @@ export const postEditorView = {
|
|
|
460
641
|
const res = await H.post(`${BASE}/posts/${savedId}/publish`, {});
|
|
461
642
|
if (res.error) throw new Error(res.error);
|
|
462
643
|
post = res.data ?? res;
|
|
644
|
+
stamps.set(post);
|
|
463
645
|
setStatus('published');
|
|
464
646
|
E.toast('Published - the post is live.', { type: 'success' });
|
|
465
647
|
} catch (err) {
|
|
@@ -489,6 +671,7 @@ export const postEditorView = {
|
|
|
489
671
|
const res = await H.post(`${BASE}/posts/${savedId}/schedule`, { publishedAt });
|
|
490
672
|
if (res.error) throw new Error(res.error);
|
|
491
673
|
post = res.data ?? res;
|
|
674
|
+
stamps.set(post);
|
|
492
675
|
setStatus('scheduled');
|
|
493
676
|
if (scheduleEl) scheduleEl.classList.add('blog-hidden');
|
|
494
677
|
E.toast(`Scheduled for ${new Date(publishedAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}.`, { type: 'success' });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
.pe { --pe-serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif; max-width: 40rem; margin: 0 auto; }
|
|
2
|
+
.pe-head { text-align: center; margin: 2rem 0 2.5rem; }
|
|
3
|
+
.pe-kicker { display: flex; justify-content: center; gap: .4rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
|
|
4
|
+
.pe-title { font-family: var(--pe-serif); font-weight: 400; font-size: clamp(2.4rem, 6vw, 4rem); line-height: 1.04; margin: 0 0 1.1rem; letter-spacing: -.01em; }
|
|
5
|
+
.pe-standfirst { font-family: var(--pe-serif); font-style: italic; font-size: 1.3rem; line-height: 1.5; color: var(--dm-text-muted); margin: 0 auto 1.5rem; max-width: 34rem; }
|
|
6
|
+
.pe-standfirst:empty { display: none; }
|
|
7
|
+
.pe-byline { display: inline-block; margin: 0; padding: .6rem 1.2rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.3)); border-bottom: 1px solid var(--dm-border, rgba(127,127,127,.3));
|
|
8
|
+
font-size: .78rem; letter-spacing: .12em; text-transform: uppercase; color: var(--dm-text-muted); }
|
|
9
|
+
.pe-byline a { color: var(--dm-text); font-weight: 600; }
|
|
10
|
+
.pe-figure { margin: 0 -4rem 3rem; }
|
|
11
|
+
.pe-figure img { width: 100%; display: block; border-radius: var(--dm-radius, 6px); }
|
|
12
|
+
.pe-figure.no-image { display: none; }
|
|
13
|
+
.pe-body { font-family: var(--pe-serif); font-size: 1.2rem; line-height: 1.75; }
|
|
14
|
+
.pe-body > p:first-of-type::first-letter { float: left; font-size: 4.4em; line-height: .8; padding: .06em .1em 0 0; color: var(--dm-primary); }
|
|
15
|
+
.pe-body > p:last-of-type::after { content: "\220E"; margin-left: .35em; color: var(--dm-primary); }
|
|
16
|
+
.pe-body h2, .pe-body h3 { font-family: var(--pe-serif); font-weight: 400; text-align: center; margin: 2.5rem 0 1rem; }
|
|
17
|
+
.pe-body h2 { font-size: 1.7rem; }
|
|
18
|
+
.pe-body blockquote { margin: 2.5rem -1.5rem; padding: 1.25rem 0; border: 0; border-top: 2px solid var(--dm-primary); border-bottom: 2px solid var(--dm-primary);
|
|
19
|
+
background: none; text-align: center; font-size: 1.55rem; line-height: 1.4; font-style: italic; color: var(--dm-text); }
|
|
20
|
+
.pe-body blockquote p { margin: 0; }
|
|
21
|
+
.pe-body hr { border: 0; margin: 2.5rem 0; text-align: center; }
|
|
22
|
+
.pe-body hr::before { content: "\2022\2003\2022\2003\2022"; color: var(--dm-text-muted); }
|
|
23
|
+
.pe-foot { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap;
|
|
24
|
+
margin: 3rem 0 0; padding-top: 1.25rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25)); }
|
|
25
|
+
.pe-tags { display: flex; gap: .4rem; flex-wrap: wrap; }
|
|
26
|
+
@media (max-width: 900px) {
|
|
27
|
+
.pe-figure { margin-left: 0; margin-right: 0; }
|
|
28
|
+
.pe-body blockquote { margin-left: 0; margin-right: 0; }
|
|
29
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
blog-post-essay - a long read set like a printed essay: a serif reading column,
|
|
3
|
+
a drop cap, quotations as pull quotes and an end mark. For the pieces people
|
|
4
|
+
sit down with.
|
|
5
|
+
|
|
6
|
+
{{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
|
|
7
|
+
{{publishedAt:D MMMM YYYY}} any date format
|
|
8
|
+
{{imageUrl}} {{imageClass}} (has-image | no-image)
|
|
9
|
+
Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
|
|
10
|
+
{{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
|
|
11
|
+
-->
|
|
12
|
+
<article class="pe">
|
|
13
|
+
<header class="pe-head">
|
|
14
|
+
<div class="pe-kicker">{{{categories}}}</div>
|
|
15
|
+
<h1 class="pe-title">{{title}}</h1>
|
|
16
|
+
<p class="pe-standfirst">{{excerpt}}</p>
|
|
17
|
+
<p class="pe-byline">By <a href="{{authorUrl}}">{{authorName}}</a> <span aria-hidden="true">/</span> <time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> <span aria-hidden="true">/</span> {{readingTime}}</p>
|
|
18
|
+
</header>
|
|
19
|
+
<figure class="pe-figure {{imageClass}}">{{{image}}}</figure>
|
|
20
|
+
<div class="pe-body blog-content">{{{content}}}</div>
|
|
21
|
+
<footer class="pe-foot">
|
|
22
|
+
<div class="pe-tags">{{{tags}}}</div>
|
|
23
|
+
{{{share}}}
|
|
24
|
+
</footer>
|
|
25
|
+
{{{prevNext}}}
|
|
26
|
+
{{{related}}}
|
|
27
|
+
{{{comments}}}
|
|
28
|
+
</article>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
.pt-head { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: clamp(1.5rem, 4vw, 3.5rem);
|
|
2
|
+
align-items: center; margin: 1rem 0 3rem; }
|
|
3
|
+
.pt-picture { aspect-ratio: 4 / 5; max-height: 36rem; width: 100%; border-radius: var(--dm-radius-lg, 12px);
|
|
4
|
+
background: var(--pt-image) center / cover no-repeat, var(--dm-primary, #334155); }
|
|
5
|
+
.pt-head.no-image .pt-picture { aspect-ratio: 4 / 3;
|
|
6
|
+
background: radial-gradient(circle at 30% 25%, color-mix(in srgb, var(--dm-primary, #334155) 55%, #fff), transparent 60%),
|
|
7
|
+
linear-gradient(135deg, var(--dm-primary, #334155), color-mix(in srgb, var(--dm-primary, #334155) 50%, #000)); }
|
|
8
|
+
.pt-kicker { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
|
9
|
+
.pt-title { font-size: clamp(2.1rem, 4.4vw, 3.4rem); line-height: 1.06; margin: 0 0 1rem; letter-spacing: -.02em; }
|
|
10
|
+
.pt-standfirst { font-size: 1.2rem; line-height: 1.55; color: var(--dm-text-muted); margin: 0 0 1.5rem; }
|
|
11
|
+
.pt-standfirst:empty { display: none; }
|
|
12
|
+
.pt-meta { margin: 0; padding-left: .9rem; border-left: 3px solid var(--dm-primary, #334155); font-size: .92rem; line-height: 1.6; color: var(--dm-text-muted); }
|
|
13
|
+
.pt-meta a { color: var(--dm-text); font-weight: 600; }
|
|
14
|
+
.pt-wrap { max-width: 44rem; margin: 0 auto; }
|
|
15
|
+
.pt-foot { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap;
|
|
16
|
+
margin-top: 2.5rem; padding-top: 1.25rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25)); }
|
|
17
|
+
.pt-tags { display: flex; gap: .4rem; flex-wrap: wrap; }
|
|
18
|
+
@media (max-width: 800px) {
|
|
19
|
+
.pt-head { grid-template-columns: 1fr; gap: 1.5rem; }
|
|
20
|
+
.pt-picture, .pt-head.no-image .pt-picture { aspect-ratio: 16 / 9; }
|
|
21
|
+
.pt-head.no-image .pt-picture { display: none; }
|
|
22
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
blog-post-split - the picture on one half, the headline on the other, then the
|
|
3
|
+
story in a centred column. Without a featured image the picture half becomes a
|
|
4
|
+
panel in the theme's primary colour.
|
|
5
|
+
|
|
6
|
+
{{title}} {{excerpt}} {{url}} {{authorName}} {{authorUrl}} {{readingTime}} {{date}}
|
|
7
|
+
{{publishedAt:D MMMM YYYY}} any date format
|
|
8
|
+
{{imageUrl}} {{imageClass}} (has-image | no-image)
|
|
9
|
+
Ready-made markup: {{{content}}} {{{image}}} {{{categories}}} {{{tags}}} {{{toc}}}
|
|
10
|
+
{{{share}}} {{{prevNext}}} {{{related}}} {{{comments}}}
|
|
11
|
+
-->
|
|
12
|
+
<article class="pt">
|
|
13
|
+
<header class="pt-head {{imageClass}}">
|
|
14
|
+
<div class="pt-picture" style="--pt-image: url('{{imageUrl}}')" aria-hidden="true"></div>
|
|
15
|
+
<div class="pt-words">
|
|
16
|
+
<div class="pt-kicker">{{{categories}}}</div>
|
|
17
|
+
<h1 class="pt-title">{{title}}</h1>
|
|
18
|
+
<p class="pt-standfirst">{{excerpt}}</p>
|
|
19
|
+
<p class="pt-meta"><a href="{{authorUrl}}">{{authorName}}</a><br><time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
|
|
20
|
+
</div>
|
|
21
|
+
</header>
|
|
22
|
+
<div class="pt-wrap">
|
|
23
|
+
<div class="pt-body blog-content">{{{content}}}</div>
|
|
24
|
+
<footer class="pt-foot">
|
|
25
|
+
<div class="pt-tags">{{{tags}}}</div>
|
|
26
|
+
{{{share}}}
|
|
27
|
+
</footer>
|
|
28
|
+
{{{prevNext}}}
|
|
29
|
+
{{{related}}}
|
|
30
|
+
{{{comments}}}
|
|
31
|
+
</div>
|
|
32
|
+
</article>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* A post page and every listing card are BLOCKS (content/blocks/*.html), so a
|
|
5
5
|
* site restyles its blog in the Blocks tool rather than in this plugin:
|
|
6
6
|
*
|
|
7
|
-
* blog-post-<name> a whole post page (Classic, Feature, Minimal, Sidebar…)
|
|
7
|
+
* blog-post-<name> a whole post page (Classic, Feature, Minimal, Sidebar, Split, Essay…)
|
|
8
8
|
* blog-card-<name> one post in a listing (Cards, List, Compact…)
|
|
9
9
|
*
|
|
10
10
|
* Any block a site adds with one of those prefixes becomes a further choice -
|
|
@@ -19,7 +19,9 @@ export const POST_LAYOUTS = [
|
|
|
19
19
|
{id: 'blog-post-classic', label: 'Classic', description: 'Title, image, then the story in one centred column.'},
|
|
20
20
|
{id: 'blog-post-feature', label: 'Feature', description: 'A full-width hero image with the title over it - for the big pieces.'},
|
|
21
21
|
{id: 'blog-post-minimal', label: 'Minimal', description: 'Just the words: a narrow reading column, no image.'},
|
|
22
|
-
{id: 'blog-post-sidebar', label: 'Sidebar', description: 'The story beside a sticky contents list, details and share links.'}
|
|
22
|
+
{id: 'blog-post-sidebar', label: 'Sidebar', description: 'The story beside a sticky contents list, details and share links.'},
|
|
23
|
+
{id: 'blog-post-split', label: 'Split', description: 'The picture on one half, the headline on the other - a cover for the story.'},
|
|
24
|
+
{id: 'blog-post-essay', label: 'Essay', description: 'A long read set like print: serif type, a drop cap and pull quotes.'}
|
|
23
25
|
];
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The navbar link to the blog: added when the plugin is enabled, taken away
|
|
3
|
+
* again when it is disabled - but only if this plugin put it there.
|
|
4
|
+
*
|
|
5
|
+
* The Menus editor rewrites items with the keys it knows and drops the rest,
|
|
6
|
+
* so a marker on the item itself would not survive the site's first edit.
|
|
7
|
+
* What was added is remembered in the plugin's own state instead
|
|
8
|
+
* (`navLink: {menu, url}` in config/plugins.json). A link the site already had
|
|
9
|
+
* - or makes itself - is never removed.
|
|
10
|
+
*
|
|
11
|
+
* @module blog/lib/nav-link
|
|
12
|
+
*/
|
|
13
|
+
import * as menuService from '../../../server/services/menus.js';
|
|
14
|
+
|
|
15
|
+
const trim = (url) => String(url ?? '').trim().replace(/\/+$/, '') || '/';
|
|
16
|
+
|
|
17
|
+
/** True when any item, at any depth, already links to `url`. */
|
|
18
|
+
export function hasLink(items, url) {
|
|
19
|
+
const want = trim(url);
|
|
20
|
+
return (items || []).some(it => (it && typeof it.url === 'string' && trim(it.url) === want) || hasLink(it?.items, url));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The item to add. It carries an icon only when most of the navbar's other
|
|
25
|
+
* links do, so it looks like the rest of the menu either way.
|
|
26
|
+
*/
|
|
27
|
+
export function blogItem(items, url) {
|
|
28
|
+
const links = (items || []).filter(it => it && it.type !== 'separator' && it.type !== 'spacer');
|
|
29
|
+
const withIcons = links.filter(it => it.icon).length;
|
|
30
|
+
return {text: 'Blog', url: trim(url), ...(links.length && withIcons * 2 >= links.length && {icon: 'file-text'})};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Top-level items without the one linking to `url`. Nested copies are the site's own. */
|
|
34
|
+
export function withoutLink(items, url) {
|
|
35
|
+
const want = trim(url);
|
|
36
|
+
return (items || []).filter(it => !(it && typeof it.url === 'string' && trim(it.url) === want));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Add the blog to the menu in the `navbar` slot, unless it already links there.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} url - the blog's base path
|
|
43
|
+
* @param {object} [menus] - the menus service (a test passes a fake)
|
|
44
|
+
* @returns {Promise<{menu: string, url: string}|null>} what was added, or null
|
|
45
|
+
*/
|
|
46
|
+
export async function addNavLink(url, menus = menuService) {
|
|
47
|
+
const slug = (await menus.getLocations())?.navbar;
|
|
48
|
+
if (!slug) return null;
|
|
49
|
+
const menu = await menus.getMenu(slug);
|
|
50
|
+
if (!menu || hasLink(menu.items, url)) return null;
|
|
51
|
+
const items = Array.isArray(menu.items) ? menu.items : [];
|
|
52
|
+
await menus.updateMenu(slug, {...menu, items: [...items, blogItem(items, url)]});
|
|
53
|
+
return {menu: slug, url: trim(url)};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Take away a link this plugin added. Anything else in the menu is left alone.
|
|
58
|
+
*
|
|
59
|
+
* @param {{menu: string, url: string}|null} added - from addNavLink()
|
|
60
|
+
* @param {object} [menus]
|
|
61
|
+
* @returns {Promise<boolean>} whether a link was removed
|
|
62
|
+
*/
|
|
63
|
+
export async function removeNavLink(added, menus = menuService) {
|
|
64
|
+
if (!added?.menu || !added?.url) return false;
|
|
65
|
+
const menu = await menus.getMenu(added.menu);
|
|
66
|
+
if (!menu) return false;
|
|
67
|
+
const items = Array.isArray(menu.items) ? menu.items : [];
|
|
68
|
+
const kept = withoutLink(items, added.url);
|
|
69
|
+
if (kept.length === items.length) return false;
|
|
70
|
+
await menus.updateMenu(added.menu, {...menu, items: kept});
|
|
71
|
+
return true;
|
|
72
|
+
}
|
package/plugins/blog/plugin.js
CHANGED
|
@@ -6,6 +6,7 @@ import {renderBlogPage} from '../../server/services/renderer.js';
|
|
|
6
6
|
import {DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
|
|
7
7
|
import {catalogue, seedBlocks} from './lib/render.js';
|
|
8
8
|
import {liveSettings, loadEntries as loadAll, renderPostPage} from './lib/page.js';
|
|
9
|
+
import {addNavLink, removeNavLink} from './lib/nav-link.js';
|
|
9
10
|
import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
|
|
10
11
|
|
|
11
12
|
const POSTS_SLUG = 'blog-posts';
|
|
@@ -295,7 +296,7 @@ export default async function blogPlugin(fastify, options) {
|
|
|
295
296
|
});
|
|
296
297
|
const html = await renderBlogPage(
|
|
297
298
|
fileURLToPath(new URL('./templates/post.html', import.meta.url)),
|
|
298
|
-
{ post: page.html, assetV: '1.
|
|
299
|
+
{ post: page.html, assetV: '1.6.0' },
|
|
299
300
|
{ title: post.title || 'Preview', description: post.excerpt || '', ogImage: safeImageUrl(post.featuredImage),
|
|
300
301
|
usedComponents: page.usedComponents, urlPath: `${basePath}/${post.slug}`, noindex: true }
|
|
301
302
|
);
|
|
@@ -716,3 +717,41 @@ export default async function blogPlugin(fastify, options) {
|
|
|
716
717
|
return reply.send({ ok: true, settings: merged });
|
|
717
718
|
});
|
|
718
719
|
}
|
|
720
|
+
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
// Lifecycle: the navbar link
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
/** The blog's base path from its saved settings, as the routes will use it. */
|
|
726
|
+
function currentBasePath() {
|
|
727
|
+
const saved = getPluginStates()?.blog?.settings ?? {};
|
|
728
|
+
return String(saved.basePath ?? defaultConfig.basePath ?? '/blog').replace(/\/+$/, '') || '/blog';
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Enabling the blog puts a Blog link in the navbar, so the listing is not a
|
|
733
|
+
* page nobody can reach. Skipped when the navbar already links there; what
|
|
734
|
+
* was added is remembered so disabling can take it away again (lib/nav-link.js).
|
|
735
|
+
*/
|
|
736
|
+
export async function onEnable({fastify}) {
|
|
737
|
+
try {
|
|
738
|
+
const added = await addNavLink(currentBasePath());
|
|
739
|
+
if (added) {
|
|
740
|
+
savePluginState('blog', {navLink: added});
|
|
741
|
+
fastify?.log?.info(`[blog] added a Blog link to the "${added.menu}" menu`);
|
|
742
|
+
}
|
|
743
|
+
} catch (err) {
|
|
744
|
+
fastify?.log?.warn(`[blog] could not add the navbar link: ${err.message}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Disabling takes the link away - only the one enabling added, never the site's own. */
|
|
749
|
+
export async function onDisable({fastify}) {
|
|
750
|
+
try {
|
|
751
|
+
const added = getPluginStates()?.blog?.navLink ?? null;
|
|
752
|
+
if (await removeNavLink(added)) fastify?.log?.info(`[blog] removed the Blog link from the "${added.menu}" menu`);
|
|
753
|
+
if (added) savePluginState('blog', {navLink: null});
|
|
754
|
+
} catch (err) {
|
|
755
|
+
fastify?.log?.warn(`[blog] could not remove the navbar link: ${err.message}`);
|
|
756
|
+
}
|
|
757
|
+
}
|