domma-cms 0.66.0 → 0.69.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +64 -1
- package/README.md +14 -10
- package/admin/css/admin.css +1 -1
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -1
- package/admin/js/lib/plugin-chrome.js +1 -1
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/admin/js/templates/theme.html +38 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +24 -22
- package/admin/js/views/theme.js +1 -1
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +4 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +14 -0
- package/config/menus/admin-sidebar.json +1 -1
- package/config/server.json +3 -1
- package/package.json +3 -7
- package/plugins/_lib/admin/mail/reader-view.js +20 -0
- package/plugins/_lib/mail/store.js +21 -1
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -1
- package/plugins/blog/admin/templates/blog.html +35 -53
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +36 -13
- package/plugins/blog/admin/templates/settings.html +2 -11
- package/plugins/blog/admin/views/blog.js +288 -162
- package/plugins/blog/admin/views/categories.js +212 -201
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +269 -21
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -86
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/posts/schema.json +3 -1
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +225 -15
- package/plugins/blog/plugin.json +21 -81
- package/plugins/blog/plugin.public.js +115 -210
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/plugin.json +2 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +41 -2
- package/scripts/run-tests.mjs +92 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
- package/server/routes/api/dashboard.js +8 -2
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +148 -3
- package/server/routes/api/todo.js +178 -0
- package/server/server.js +61 -1
- package/server/services/collections.js +13 -3
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +54 -1
- package/server/services/permissionRegistry.js +50 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +117 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +83 -3
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +39 -0
- package/config/theme.json +0 -18
- package/plugins/analytics/admin/css/index.css +0 -1
- package/plugins/analytics/admin/templates/analytics.html +0 -70
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -60
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -254
- package/plugins/contacts/admin/views/contacts.js +0 -943
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -179
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/plugin.json +0 -36
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/css/index.css +0 -1
- package/plugins/invoice/admin/templates/editor.html +0 -220
- package/plugins/invoice/admin/templates/index.html +0 -173
- package/plugins/invoice/admin/templates/issuers.html +0 -2
- package/plugins/invoice/admin/templates/receivers.html +0 -2
- package/plugins/invoice/admin/views/contacts-source.js +0 -266
- package/plugins/invoice/admin/views/editor.js +0 -434
- package/plugins/invoice/admin/views/export.js +0 -199
- package/plugins/invoice/admin/views/help-content.js +0 -61
- package/plugins/invoice/admin/views/index.js +0 -643
- package/plugins/invoice/admin/views/issuers.js +0 -30
- package/plugins/invoice/admin/views/media.js +0 -172
- package/plugins/invoice/admin/views/party-view.js +0 -386
- package/plugins/invoice/admin/views/payments.js +0 -127
- package/plugins/invoice/admin/views/print.js +0 -130
- package/plugins/invoice/admin/views/receivers.js +0 -55
- package/plugins/invoice/admin/views/send.js +0 -212
- package/plugins/invoice/admin/views/settings.js +0 -594
- package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
- package/plugins/invoice/collections/invoices/schema.json +0 -33
- package/plugins/invoice/config.js +0 -37
- package/plugins/invoice/pdf.js +0 -164
- package/plugins/invoice/plugin.js +0 -1456
- package/plugins/invoice/plugin.json +0 -86
- package/plugins/invoice/templates/_base.css +0 -1
- package/plugins/invoice/templates/classic-nologo.html +0 -100
- package/plugins/invoice/templates/classic.html +0 -91
- package/plugins/invoice/templates/invoice-print.html +0 -237
- package/plugins/invoice/templates/minimal.html +0 -99
- package/plugins/invoice/templates/modern-nologo.html +0 -114
- package/plugins/invoice/templates/modern.html +0 -113
- package/plugins/invoice/templates/templates.json +0 -11
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/plugin.json +0 -33
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/lib/audience.js +0 -37
- package/plugins/surveys/lib/campaigns.js +0 -43
- package/plugins/surveys/lib/ledger.js +0 -110
- package/plugins/surveys/lib/sending.js +0 -106
- package/plugins/surveys/lib/stats.js +0 -62
- package/plugins/surveys/lib/submit.js +0 -95
- package/plugins/surveys/lib/tokens.js +0 -28
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/plugin.public.js +0 -149
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
- /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog - the browser half.
|
|
3
|
+
*
|
|
4
|
+
* The server renders every listing and post in full, so all of this is an
|
|
5
|
+
* enhancement: without it the blog still reads, pages and links.
|
|
6
|
+
*
|
|
7
|
+
* On a listing, the cards become a model (each carries `data-post`: plain
|
|
8
|
+
* values, never markup) and the filters become domma-reactive observables -
|
|
9
|
+
* search text, category, tag, author, sort, display, how many are shown. What
|
|
10
|
+
* is visible is a computed of those; one effect applies it to the cards. The
|
|
11
|
+
* toolbar, the category pills and the active-filter chips are bindings over the
|
|
12
|
+
* same observables, and so is the right-click menu: "More in Design" just sets
|
|
13
|
+
* the category.
|
|
14
|
+
*
|
|
15
|
+
* On a post: a reading-progress bar, a contents list that follows the reader,
|
|
16
|
+
* copy / native share, and a comment form that posts in place.
|
|
17
|
+
*
|
|
18
|
+
* domma-reactive (`M`) ships in domma.min.js on every public page.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const M = window.M;
|
|
22
|
+
const PREF_KEY = 'blog.display';
|
|
23
|
+
|
|
24
|
+
const store = {
|
|
25
|
+
get(k) { try { return localStorage.getItem(k); } catch { return null; } },
|
|
26
|
+
set(k, v) { try { localStorage.setItem(k, v); } catch { /* storage blocked */ } }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function decodeMeta(el) {
|
|
30
|
+
try {
|
|
31
|
+
const bytes = Uint8Array.from(atob(el.dataset.post || ''), c => c.charCodeAt(0));
|
|
32
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toast(text) {
|
|
39
|
+
const t = document.createElement('div');
|
|
40
|
+
t.className = 'blog-toast';
|
|
41
|
+
t.setAttribute('role', 'status');
|
|
42
|
+
t.textContent = text;
|
|
43
|
+
document.body.appendChild(t);
|
|
44
|
+
setTimeout(() => t.remove(), 1800);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function copyText(text) {
|
|
48
|
+
try {
|
|
49
|
+
await navigator.clipboard.writeText(text);
|
|
50
|
+
toast('Link copied');
|
|
51
|
+
} catch {
|
|
52
|
+
window.prompt('Copy this link:', text);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function el(html) {
|
|
57
|
+
const t = document.createElement('template');
|
|
58
|
+
t.innerHTML = html.trim();
|
|
59
|
+
return t.content.firstElementChild;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// The right-click menu - one for the whole page, built per click
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
const menu = (() => {
|
|
67
|
+
let node = null;
|
|
68
|
+
let lastFocus = null;
|
|
69
|
+
|
|
70
|
+
function close() {
|
|
71
|
+
if (!node) return;
|
|
72
|
+
node.remove();
|
|
73
|
+
node = null;
|
|
74
|
+
document.removeEventListener('pointerdown', onOutside, true);
|
|
75
|
+
document.removeEventListener('keydown', onKey, true);
|
|
76
|
+
window.removeEventListener('scroll', close, true);
|
|
77
|
+
window.removeEventListener('resize', close);
|
|
78
|
+
lastFocus?.focus?.({preventScroll: true});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function onOutside(e) { if (node && !node.contains(e.target)) close(); }
|
|
82
|
+
|
|
83
|
+
function onKey(e) {
|
|
84
|
+
if (!node) return;
|
|
85
|
+
const items = [...node.querySelectorAll('button')];
|
|
86
|
+
const at = items.indexOf(document.activeElement);
|
|
87
|
+
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
|
88
|
+
else if (e.key === 'ArrowDown') { e.preventDefault(); items[(at + 1) % items.length]?.focus(); }
|
|
89
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(at - 1 + items.length) % items.length]?.focus(); }
|
|
90
|
+
else if (e.key === 'Tab') close();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {number} x
|
|
95
|
+
* @param {number} y
|
|
96
|
+
* @param {string} heading
|
|
97
|
+
* @param {({label: string, hint?: string, run: Function}|'-')[]} entries
|
|
98
|
+
*/
|
|
99
|
+
function open(x, y, heading, entries) {
|
|
100
|
+
close();
|
|
101
|
+
lastFocus = document.activeElement;
|
|
102
|
+
node = document.createElement('div');
|
|
103
|
+
node.className = 'blog-menu';
|
|
104
|
+
node.setAttribute('role', 'menu');
|
|
105
|
+
const head = document.createElement('div');
|
|
106
|
+
head.className = 'blog-menu-head';
|
|
107
|
+
head.textContent = heading;
|
|
108
|
+
node.appendChild(head);
|
|
109
|
+
const list = entries.filter((e, i, all) => e !== '-' || (i > 0 && i < all.length - 1 && all[i - 1] !== '-'));
|
|
110
|
+
for (const entry of list) {
|
|
111
|
+
if (entry === '-') { node.appendChild(document.createElement('hr')); continue; }
|
|
112
|
+
const b = document.createElement('button');
|
|
113
|
+
b.type = 'button';
|
|
114
|
+
b.setAttribute('role', 'menuitem');
|
|
115
|
+
b.textContent = entry.label;
|
|
116
|
+
if (entry.hint) {
|
|
117
|
+
const k = document.createElement('span');
|
|
118
|
+
k.className = 'k';
|
|
119
|
+
k.textContent = entry.hint;
|
|
120
|
+
b.appendChild(k);
|
|
121
|
+
}
|
|
122
|
+
b.addEventListener('click', () => { close(); entry.run(); });
|
|
123
|
+
node.appendChild(b);
|
|
124
|
+
}
|
|
125
|
+
document.body.appendChild(node);
|
|
126
|
+
const r = node.getBoundingClientRect();
|
|
127
|
+
node.style.left = `${Math.max(8, Math.min(x, innerWidth - r.width - 8))}px`;
|
|
128
|
+
node.style.top = `${Math.max(8, Math.min(y, innerHeight - r.height - 8))}px`;
|
|
129
|
+
node.querySelector('button')?.focus({preventScroll: true});
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
document.addEventListener('pointerdown', onOutside, true);
|
|
132
|
+
document.addEventListener('keydown', onKey, true);
|
|
133
|
+
window.addEventListener('scroll', close, true);
|
|
134
|
+
window.addEventListener('resize', close);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {open, close};
|
|
139
|
+
})();
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Menu entries for one card. `filters` is the live listing when there is one
|
|
143
|
+
* on the page; without it, each "More…" goes to that archive page instead.
|
|
144
|
+
*/
|
|
145
|
+
function cardEntries(meta, link, base, filters) {
|
|
146
|
+
const go = (href) => { location.href = href; };
|
|
147
|
+
const narrow = (key, value, href) => filters ? () => filters.set(key, value) : () => go(href);
|
|
148
|
+
const entries = [
|
|
149
|
+
{label: 'Open post', run: () => go(link)},
|
|
150
|
+
{label: 'Open in new tab', run: () => window.open(link, '_blank', 'noopener')},
|
|
151
|
+
{label: 'Copy link', run: () => copyText(new URL(link, location.href).href)},
|
|
152
|
+
'-',
|
|
153
|
+
...meta.cats.map(c => ({label: `More in ${c.name}`, run: narrow('cat', c.slug, `${base}/category/${encodeURIComponent(c.slug)}`)})),
|
|
154
|
+
...meta.tags.slice(0, 4).map(t => ({label: `More tagged #${t}`, run: narrow('tag', t, `${base}/tag/${encodeURIComponent(t)}`)})),
|
|
155
|
+
...(meta.author?.id ? [{label: `More by ${meta.author.name || 'this author'}`, run: narrow('author', meta.author.id, `${base}/author/${encodeURIComponent(meta.author.id)}`)}] : [])
|
|
156
|
+
];
|
|
157
|
+
if (filters?.any()) entries.push('-', {label: 'Clear filters', run: () => filters.clear()});
|
|
158
|
+
return entries;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function wireCardMenu(scope, base, filters) {
|
|
162
|
+
scope.addEventListener('contextmenu', (e) => {
|
|
163
|
+
const card = e.target.closest('.blog-item[data-post]');
|
|
164
|
+
if (!card || !scope.contains(card) || e.shiftKey) return; // Shift+right-click: the browser's own menu
|
|
165
|
+
const meta = decodeMeta(card);
|
|
166
|
+
const link = card.querySelector('a[href]')?.getAttribute('href');
|
|
167
|
+
if (!meta || !link) return;
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
e.stopPropagation();
|
|
170
|
+
let {clientX: x, clientY: y} = e;
|
|
171
|
+
if (!x && !y) { // opened from the keyboard
|
|
172
|
+
const r = (document.activeElement?.closest('.blog-item') === card ? document.activeElement : card).getBoundingClientRect();
|
|
173
|
+
x = r.left + 12;
|
|
174
|
+
y = r.top + 12;
|
|
175
|
+
}
|
|
176
|
+
menu.open(x, y, meta.title, cardEntries(meta, link, base, filters));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// A listing
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
const TOOLBAR = `
|
|
185
|
+
<label class="blog-search">
|
|
186
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
|
187
|
+
<input type="search" class="blog-search-input" placeholder="Search posts…" aria-label="Search posts" data-model="q.value">
|
|
188
|
+
</label>
|
|
189
|
+
<select class="blog-sort" aria-label="Sort" data-model="sort.value">
|
|
190
|
+
<option value="newest">Newest first</option>
|
|
191
|
+
<option value="oldest">Oldest first</option>
|
|
192
|
+
<option value="title">Title A–Z</option>
|
|
193
|
+
<option value="quick">Quickest read</option>
|
|
194
|
+
</select>`;
|
|
195
|
+
|
|
196
|
+
const PILL = `<button type="button" class="blog-pill {{cls}}" data-pill="{{slug}}" title="{{title}}">{{name}} <span class="n">{{count}}</span></button>`;
|
|
197
|
+
const CHIP = `<span class="blog-filter-chip">{{label}} <button type="button" data-unset="{{key}}" aria-label="Remove {{label}}">×</button></span>`;
|
|
198
|
+
|
|
199
|
+
function initArchive() {
|
|
200
|
+
const root = document.querySelector('[data-blog-archive]');
|
|
201
|
+
if (!root) return;
|
|
202
|
+
let cfg;
|
|
203
|
+
try { cfg = JSON.parse(root.querySelector('[data-blog-config]')?.textContent || '{}'); } catch { cfg = {}; }
|
|
204
|
+
const base = cfg.base || '/blog';
|
|
205
|
+
|
|
206
|
+
if (!M || !cfg.interactive) {
|
|
207
|
+
// Paged on the server: the menu still works, and "More…" opens the archive.
|
|
208
|
+
wireCardMenu(root, base, null);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
root.classList.add('is-live');
|
|
212
|
+
|
|
213
|
+
const bar = root.querySelector('.blog-archive-bar');
|
|
214
|
+
const readCards = (container) => [...container.querySelectorAll(':scope > .blog-item[data-post]')]
|
|
215
|
+
.map(node => ({node, meta: decodeMeta(node)}))
|
|
216
|
+
.filter(c => c.meta);
|
|
217
|
+
|
|
218
|
+
const params = new URLSearchParams(location.search);
|
|
219
|
+
const perPage = Math.max(1, Number(cfg.perPage) || 10);
|
|
220
|
+
const state = {
|
|
221
|
+
q: M.observable(params.get('q') ?? ''),
|
|
222
|
+
cat: M.observable(params.get('category') ?? ''),
|
|
223
|
+
tag: M.observable(params.get('tag') ?? ''),
|
|
224
|
+
author: M.observable(params.get('author') ?? ''),
|
|
225
|
+
sort: M.observable(['newest', 'oldest', 'title', 'quick'].includes(params.get('sort')) ? params.get('sort') : 'newest'),
|
|
226
|
+
limit: M.observable(perPage),
|
|
227
|
+
display: M.observable(cfg.display),
|
|
228
|
+
items: M.observable(root.querySelector('.blog-items')),
|
|
229
|
+
cards: M.observable(readCards(root.querySelector('.blog-items')))
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// The same filters, as an object the right-click menu can drive.
|
|
233
|
+
const filters = {
|
|
234
|
+
set(key, value) {
|
|
235
|
+
state[key].value = value;
|
|
236
|
+
root.scrollIntoView({behavior: 'smooth', block: 'start'});
|
|
237
|
+
},
|
|
238
|
+
any: () => Boolean(state.q.value || state.cat.value || state.tag.value || state.author.value),
|
|
239
|
+
clear() { state.q.value = ''; state.cat.value = ''; state.tag.value = ''; state.author.value = ''; }
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const words = (s) => String(s).toLowerCase();
|
|
243
|
+
const matches = (m, {skipCat = false} = {}) => {
|
|
244
|
+
const q = words(state.q.value).trim();
|
|
245
|
+
if (q && !words([m.title, m.excerpt, m.author?.name, ...m.tags, ...m.cats.map(c => c.name)].join(' ')).includes(q)) return false;
|
|
246
|
+
if (!skipCat && state.cat.value && !m.cats.some(c => c.slug === state.cat.value)) return false;
|
|
247
|
+
if (state.tag.value && !m.tags.includes(state.tag.value)) return false;
|
|
248
|
+
if (state.author.value && m.author?.id !== state.author.value) return false;
|
|
249
|
+
return true;
|
|
250
|
+
};
|
|
251
|
+
const SORTS = {
|
|
252
|
+
newest: (a, b) => (b.meta.date || '').localeCompare(a.meta.date || ''),
|
|
253
|
+
oldest: (a, b) => (a.meta.date || '').localeCompare(b.meta.date || ''),
|
|
254
|
+
title: (a, b) => a.meta.title.localeCompare(b.meta.title),
|
|
255
|
+
quick: (a, b) => (a.meta.minutes - b.meta.minutes) || (b.meta.date || '').localeCompare(a.meta.date || '')
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const filtered = M.computed(() => state.cards.value.filter(c => matches(c.meta)).sort(SORTS[state.sort.value] ?? SORTS.newest));
|
|
259
|
+
const displayInfo = M.computed(() => (cfg.displays || []).find(d => d.id === state.display.value) ?? {lead: false});
|
|
260
|
+
|
|
261
|
+
// Lists for data-each: observables fed by effects (a data-each over a
|
|
262
|
+
// computed renders once and never again).
|
|
263
|
+
const pills = M.observable([]);
|
|
264
|
+
const chips = M.observable([]);
|
|
265
|
+
|
|
266
|
+
const catName = (slug) => (cfg.categories || []).find(c => c.slug === slug)?.name ?? slug;
|
|
267
|
+
const authorName = (id) => state.cards.value.find(c => c.meta.author?.id === id)?.meta.author.name || 'this author';
|
|
268
|
+
|
|
269
|
+
const data = {
|
|
270
|
+
...state, pills, chips,
|
|
271
|
+
countText: M.computed(() => {
|
|
272
|
+
const all = state.cards.value.length, n = filtered.value.length;
|
|
273
|
+
return n === all ? `${all} post${all === 1 ? '' : 's'}` : `${n} of ${all} posts`;
|
|
274
|
+
}),
|
|
275
|
+
hasMore: M.computed(() => filtered.value.length > state.limit.value),
|
|
276
|
+
moreText: M.computed(() => `Showing ${Math.min(state.limit.value, filtered.value.length)} of ${filtered.value.length}`),
|
|
277
|
+
hasChips: M.computed(() => chips.value.length > 0)
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// ── Build the toolbar, pills, chips and "Show more"
|
|
281
|
+
const toolbar = document.createElement('div');
|
|
282
|
+
toolbar.className = 'blog-toolbar';
|
|
283
|
+
toolbar.style.display = 'contents';
|
|
284
|
+
toolbar.innerHTML = TOOLBAR;
|
|
285
|
+
bar.prepend(toolbar);
|
|
286
|
+
|
|
287
|
+
const count = el('<span class="blog-more-count" data-bind-text="countText.value" aria-live="polite"></span>');
|
|
288
|
+
bar.insertBefore(count, bar.querySelector('.blog-displays'));
|
|
289
|
+
|
|
290
|
+
const pillRow = el('<div class="blog-pills" role="group" aria-label="Categories" data-each="pills.value key=k"></div>');
|
|
291
|
+
pillRow.innerHTML = PILL;
|
|
292
|
+
bar.after(pillRow);
|
|
293
|
+
|
|
294
|
+
const active = el('<div class="blog-active" data-if="hasChips.value"><span>Showing</span><span class="blog-active-list" data-each="chips.value key=key"></span><button type="button" class="blog-clear" data-on-click="clearAll">Clear all</button></div>');
|
|
295
|
+
active.querySelector('.blog-active-list').innerHTML = CHIP;
|
|
296
|
+
pillRow.after(active);
|
|
297
|
+
|
|
298
|
+
const more = el('<div class="blog-more" data-if="hasMore.value"><span class="blog-more-count" data-bind-text="moreText.value"></span><button type="button" class="btn btn-secondary" data-on-click="showMore">Show more</button></div>');
|
|
299
|
+
root.querySelector('.blog-empty').after(more);
|
|
300
|
+
|
|
301
|
+
const handle = M.applyBindings(data, root, {
|
|
302
|
+
methods: {
|
|
303
|
+
showMore: () => { state.limit.value += perPage; },
|
|
304
|
+
clearAll: () => filters.clear()
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Pills: "All" plus every category with a post that the OTHER filters let through.
|
|
309
|
+
M.effect(() => {
|
|
310
|
+
const counts = new Map();
|
|
311
|
+
let total = 0;
|
|
312
|
+
for (const c of state.cards.value) {
|
|
313
|
+
if (!matches(c.meta, {skipCat: true})) continue;
|
|
314
|
+
total++;
|
|
315
|
+
for (const cat of c.meta.cats) counts.set(cat.slug, (counts.get(cat.slug) ?? 0) + 1);
|
|
316
|
+
}
|
|
317
|
+
const current = state.cat.value;
|
|
318
|
+
const list = [{k: '*', slug: '', name: 'All', count: total, cls: current ? '' : 'is-active', title: 'Every category'}];
|
|
319
|
+
for (const cat of cfg.categories || []) {
|
|
320
|
+
const n = counts.get(cat.slug) ?? 0;
|
|
321
|
+
if (!n && cat.slug !== current) continue;
|
|
322
|
+
list.push({k: cat.slug, slug: cat.slug, name: cat.name, count: n, cls: cat.slug === current ? 'is-active' : '', title: `Only ${cat.name}`});
|
|
323
|
+
}
|
|
324
|
+
pills.value = list.length > 2 || current ? list : [];
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
M.effect(() => {
|
|
328
|
+
const list = [];
|
|
329
|
+
if (state.q.value.trim()) list.push({key: 'q', label: `“${state.q.value.trim()}”`});
|
|
330
|
+
if (state.cat.value) list.push({key: 'cat', label: catName(state.cat.value)});
|
|
331
|
+
if (state.tag.value) list.push({key: 'tag', label: `#${state.tag.value}`});
|
|
332
|
+
if (state.author.value) list.push({key: 'author', label: `by ${authorName(state.author.value)}`});
|
|
333
|
+
chips.value = list;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// A new filter starts from the first page again. Writes `limit` without
|
|
337
|
+
// reading it, so "Show more" is not undone the moment it is pressed.
|
|
338
|
+
let lastFilter = null;
|
|
339
|
+
M.effect(() => {
|
|
340
|
+
const key = JSON.stringify([state.q.value, state.cat.value, state.tag.value, state.author.value, state.sort.value]);
|
|
341
|
+
if (lastFilter !== null && key !== lastFilter) state.limit.value = perPage;
|
|
342
|
+
lastFilter = key;
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Apply: order, visibility, the Magazine lead, the empty note.
|
|
346
|
+
M.effect(() => {
|
|
347
|
+
const container = state.items.value;
|
|
348
|
+
const shown = filtered.value.slice(0, state.limit.value);
|
|
349
|
+
const visible = new Set(shown.map(c => c.node));
|
|
350
|
+
for (const c of filtered.value) container.appendChild(c.node); // sorted order
|
|
351
|
+
for (const c of state.cards.value) {
|
|
352
|
+
c.node.hidden = !visible.has(c.node);
|
|
353
|
+
c.node.classList.remove('is-lead');
|
|
354
|
+
}
|
|
355
|
+
if (displayInfo.value.lead && shown[0] && state.sort.value === 'newest' && !filters.any()) shown[0].node.classList.add('is-lead');
|
|
356
|
+
root.querySelector('.blog-empty').hidden = shown.length > 0;
|
|
357
|
+
root.querySelector('.blog-empty').textContent = state.cards.value.length ? 'No posts match - try clearing a filter.' : 'No posts here yet.';
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// The address follows the filters, so a filtered view is a link.
|
|
361
|
+
M.effect(() => {
|
|
362
|
+
const p = new URLSearchParams(location.search);
|
|
363
|
+
const put = (k, v, dflt = '') => (v && v !== dflt ? p.set(k, v) : p.delete(k));
|
|
364
|
+
put('q', state.q.value.trim());
|
|
365
|
+
put('category', state.cat.value);
|
|
366
|
+
put('tag', state.tag.value);
|
|
367
|
+
put('author', state.author.value);
|
|
368
|
+
put('sort', state.sort.value, 'newest');
|
|
369
|
+
put('display', state.display.value, cfg.display);
|
|
370
|
+
const qs = p.toString();
|
|
371
|
+
history.replaceState(history.state, '', `${location.pathname}${qs ? `?${qs}` : ''}${location.hash}`);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// ── Clicks: pills, chip ×, category / tag chips inside cards, display switcher
|
|
375
|
+
root.addEventListener('click', (e) => {
|
|
376
|
+
const pill = e.target.closest('[data-pill]');
|
|
377
|
+
if (pill) { state.cat.value = pill.dataset.pill; return; }
|
|
378
|
+
const unset = e.target.closest('[data-unset]');
|
|
379
|
+
if (unset) { state[unset.dataset.unset].value = ''; return; }
|
|
380
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
|
381
|
+
const cat = e.target.closest('.blog-item [data-blog-cat]');
|
|
382
|
+
if (cat) { e.preventDefault(); filters.set('cat', cat.dataset.blogCat); return; }
|
|
383
|
+
const tag = e.target.closest('.blog-item [data-blog-tag]');
|
|
384
|
+
if (tag) { e.preventDefault(); filters.set('tag', tag.dataset.blogTag); return; }
|
|
385
|
+
const disp = e.target.closest('[data-blog-display]');
|
|
386
|
+
if (disp) { e.preventDefault(); swapDisplay(disp.dataset.blogDisplay, true); }
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Switch display without leaving the page: fetch the same listing in the
|
|
391
|
+
* other display and take its cards (each display is a different BLOCK, so
|
|
392
|
+
* the markup genuinely differs), keeping every filter as it is.
|
|
393
|
+
*/
|
|
394
|
+
async function swapDisplay(id, remember) {
|
|
395
|
+
const info = (cfg.displays || []).find(d => d.id === id);
|
|
396
|
+
if (!info || id === state.display.value) return;
|
|
397
|
+
const current = state.items.value;
|
|
398
|
+
current.classList.add('is-swapping');
|
|
399
|
+
try {
|
|
400
|
+
const url = new URL(location.href);
|
|
401
|
+
url.search = `?display=${encodeURIComponent(id)}`;
|
|
402
|
+
const res = await fetch(url, {headers: {Accept: 'text/html'}});
|
|
403
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
404
|
+
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
|
|
405
|
+
const fresh = doc.querySelector('[data-blog-archive] .blog-items');
|
|
406
|
+
if (!fresh) throw new Error('no listing');
|
|
407
|
+
// The block's CSS rides along as a <style> just before the listing.
|
|
408
|
+
for (const style of doc.querySelectorAll('style')) {
|
|
409
|
+
const m = /\[data-block="([^"]+)"\]/.exec(style.textContent);
|
|
410
|
+
if (m && !document.querySelector(`style[data-blog-style="${m[1]}"]`) && !document.querySelector(`[data-block="${m[1]}"]`)) {
|
|
411
|
+
style.dataset.blogStyle = m[1];
|
|
412
|
+
document.head.appendChild(document.importNode(style, true));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const node = document.importNode(fresh, true);
|
|
416
|
+
current.replaceWith(node);
|
|
417
|
+
state.items.value = node;
|
|
418
|
+
state.cards.value = readCards(node);
|
|
419
|
+
state.display.value = id;
|
|
420
|
+
root.querySelectorAll('[data-blog-display]').forEach(a => {
|
|
421
|
+
if (a.dataset.blogDisplay === id) a.setAttribute('aria-current', 'true');
|
|
422
|
+
else a.removeAttribute('aria-current');
|
|
423
|
+
});
|
|
424
|
+
if (remember) store.set(PREF_KEY, id);
|
|
425
|
+
} catch {
|
|
426
|
+
location.href = `?display=${encodeURIComponent(id)}`;
|
|
427
|
+
} finally {
|
|
428
|
+
current.classList.remove('is-swapping');
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
wireCardMenu(root, base, filters);
|
|
433
|
+
|
|
434
|
+
// A reader who chose a display before gets it again - unless this address names one.
|
|
435
|
+
const pref = store.get(PREF_KEY);
|
|
436
|
+
if (pref && !params.get('display') && pref !== cfg.display && root.querySelector(`[data-blog-display="${CSS.escape(pref)}"]`)) {
|
|
437
|
+
swapDisplay(pref, false);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
window.addEventListener('pagehide', () => handle?.dispose?.(), {once: true});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// A post
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* CSS `position: sticky`, done by hand. Site themes put `overflow: hidden` on
|
|
449
|
+
* the page wrapper, which makes that wrapper the sticky element's scroll
|
|
450
|
+
* container - one that never scrolls - so a sticky contents list just scrolls
|
|
451
|
+
* away. Only applied where a layout asked for sticky; the element is nudged
|
|
452
|
+
* down within its parent and never past the parent's end.
|
|
453
|
+
*/
|
|
454
|
+
function keepInView(node) {
|
|
455
|
+
const cs = getComputedStyle(node);
|
|
456
|
+
if (cs.position !== 'sticky') return;
|
|
457
|
+
const offset = parseFloat(cs.top) || 80;
|
|
458
|
+
node.style.position = 'relative';
|
|
459
|
+
node.style.top = '0';
|
|
460
|
+
const box = node.parentElement;
|
|
461
|
+
let queued = false;
|
|
462
|
+
const place = () => {
|
|
463
|
+
queued = false;
|
|
464
|
+
node.style.transform = '';
|
|
465
|
+
if (matchMedia('(max-width: 960px)').matches) return;
|
|
466
|
+
const r = node.getBoundingClientRect();
|
|
467
|
+
const b = box.getBoundingClientRect();
|
|
468
|
+
const shift = Math.max(0, Math.min(offset - r.top, b.bottom - r.bottom));
|
|
469
|
+
if (shift) node.style.transform = `translateY(${shift}px)`;
|
|
470
|
+
};
|
|
471
|
+
const queue = () => { if (!queued) { queued = true; requestAnimationFrame(place); } };
|
|
472
|
+
addEventListener('scroll', queue, {passive: true, capture: true});
|
|
473
|
+
addEventListener('resize', queue);
|
|
474
|
+
place();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function initPost() {
|
|
478
|
+
const page = document.querySelector('[data-blog-post]');
|
|
479
|
+
if (!page) return;
|
|
480
|
+
const base = (location.pathname.replace(/\/[^/]*$/, '') || '/blog');
|
|
481
|
+
|
|
482
|
+
wireCardMenu(page, base, null); // the related cards
|
|
483
|
+
|
|
484
|
+
// Share: copy needs the clipboard, Share… needs the platform's sheet.
|
|
485
|
+
page.querySelectorAll('[data-blog-share]').forEach(box => {
|
|
486
|
+
const copy = box.querySelector('[data-blog-copy]');
|
|
487
|
+
const native = box.querySelector('[data-blog-native]');
|
|
488
|
+
if (copy && navigator.clipboard) {
|
|
489
|
+
copy.hidden = false;
|
|
490
|
+
copy.addEventListener('click', () => copyText(location.href.split('#')[0]));
|
|
491
|
+
}
|
|
492
|
+
if (native && navigator.share) {
|
|
493
|
+
native.hidden = false;
|
|
494
|
+
native.addEventListener('click', () => navigator.share({title: document.title, url: location.href.split('#')[0]}).catch(() => {}));
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
if (!M) return;
|
|
499
|
+
|
|
500
|
+
// Reading progress, over the article rather than the whole page.
|
|
501
|
+
const article = page.querySelector('.blog-content') ?? page;
|
|
502
|
+
const bar = el('<div class="blog-progress" aria-hidden="true"><span></span></div>');
|
|
503
|
+
document.body.appendChild(bar);
|
|
504
|
+
const progress = M.observable(0);
|
|
505
|
+
M.effect(() => { bar.firstElementChild.style.transform = `scaleX(${progress.value})`; });
|
|
506
|
+
let ticking = false;
|
|
507
|
+
const measure = () => {
|
|
508
|
+
ticking = false;
|
|
509
|
+
const r = article.getBoundingClientRect();
|
|
510
|
+
const span = r.height - innerHeight * 0.6;
|
|
511
|
+
progress.value = Math.max(0, Math.min(1, span > 0 ? -r.top / span : (r.top < 0 ? 1 : 0)));
|
|
512
|
+
};
|
|
513
|
+
addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(measure); } }, {passive: true});
|
|
514
|
+
measure();
|
|
515
|
+
|
|
516
|
+
// The contents list follows the heading being read.
|
|
517
|
+
const toc = page.querySelector('[data-blog-toc]');
|
|
518
|
+
if (toc) keepInView(toc);
|
|
519
|
+
if (toc) {
|
|
520
|
+
const links = new Map([...toc.querySelectorAll('a[href^="#"]')].map(a => [decodeURIComponent(a.hash.slice(1)), a]));
|
|
521
|
+
const heads = [...links.keys()].map(id => document.getElementById(id)).filter(Boolean);
|
|
522
|
+
const current = M.observable(heads[0]?.id ?? '');
|
|
523
|
+
M.effect(() => links.forEach((a, id) => a.classList.toggle('is-active', id === current.value)));
|
|
524
|
+
const onScroll = () => {
|
|
525
|
+
const line = 120;
|
|
526
|
+
let at = heads[0]?.id ?? '';
|
|
527
|
+
for (const h of heads) { if (h.getBoundingClientRect().top - line <= 0) at = h.id; else break; }
|
|
528
|
+
current.value = at;
|
|
529
|
+
};
|
|
530
|
+
addEventListener('scroll', onScroll, {passive: true});
|
|
531
|
+
onScroll();
|
|
532
|
+
toc.addEventListener('click', (e) => {
|
|
533
|
+
const a = e.target.closest('a[href^="#"]');
|
|
534
|
+
if (!a) return;
|
|
535
|
+
e.preventDefault();
|
|
536
|
+
document.getElementById(decodeURIComponent(a.hash.slice(1)))?.scrollIntoView({behavior: 'smooth', block: 'start'});
|
|
537
|
+
history.replaceState(history.state, '', a.hash);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Comments, posted in place.
|
|
542
|
+
const form = page.querySelector('[data-blog-comment-form]');
|
|
543
|
+
if (form) {
|
|
544
|
+
const status = M.observable({kind: '', text: ''});
|
|
545
|
+
const sending = M.observable(false);
|
|
546
|
+
const out = form.querySelector('[data-blog-comment-status]');
|
|
547
|
+
const button = form.querySelector('button[type="submit"]');
|
|
548
|
+
M.effect(() => {
|
|
549
|
+
out.textContent = status.value.text;
|
|
550
|
+
out.className = `blog-comment-status${status.value.kind ? ` is-${status.value.kind}` : ''}`;
|
|
551
|
+
button.disabled = sending.value;
|
|
552
|
+
button.textContent = sending.value ? 'Posting…' : 'Post comment';
|
|
553
|
+
});
|
|
554
|
+
form.addEventListener('submit', async (e) => {
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
if (sending.value) return;
|
|
557
|
+
const body = Object.fromEntries(new FormData(form).entries());
|
|
558
|
+
sending.value = true;
|
|
559
|
+
status.value = {kind: '', text: ''};
|
|
560
|
+
try {
|
|
561
|
+
const res = await fetch(form.action, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
|
|
562
|
+
const json = await res.json().catch(() => ({}));
|
|
563
|
+
if (!res.ok) throw new Error(json.error || 'Your comment could not be posted.');
|
|
564
|
+
if (json.status === 'approved') {
|
|
565
|
+
const li = el('<li class="blog-comment is-new"><p class="blog-comment-head"><strong></strong> <time>just now</time></p><p class="blog-comment-body"></p></li>');
|
|
566
|
+
li.querySelector('strong').textContent = json.authorName ?? body.authorName;
|
|
567
|
+
li.querySelector('.blog-comment-body').textContent = json.body ?? body.body;
|
|
568
|
+
page.querySelector('[data-blog-comment-list]')?.appendChild(li);
|
|
569
|
+
const n = page.querySelector('[data-blog-comment-count]');
|
|
570
|
+
if (n) n.textContent = String((Number(n.textContent) || 0) + 1);
|
|
571
|
+
status.value = {kind: 'ok', text: 'Thanks - your comment is live.'};
|
|
572
|
+
} else {
|
|
573
|
+
status.value = {kind: 'ok', text: 'Thanks - your comment will appear once it has been approved.'};
|
|
574
|
+
}
|
|
575
|
+
form.querySelector('textarea[name="body"]').value = '';
|
|
576
|
+
} catch (err) {
|
|
577
|
+
status.value = {kind: 'error', text: err.message};
|
|
578
|
+
} finally {
|
|
579
|
+
sending.value = false;
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
initArchive();
|
|
586
|
+
initPost();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
|
|
2
|
+
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0f766e"/><stop offset="1" stop-color="#22d3ee"/></linearGradient>
|
|
3
|
+
<filter id="blur"><feGaussianBlur stdDeviation="40"/></filter></defs>
|
|
4
|
+
<rect width="1600" height="900" fill="url(#g)"/>
|
|
5
|
+
<g filter="url(#blur)"><circle cx="487" cy="606" r="398" fill="#ffffff" opacity="0.14"/><circle cx="1236" cy="485" r="417" fill="#22d3ee" opacity="0.1"/><circle cx="26" cy="857" r="360" fill="#22d3ee" opacity="0.18"/><circle cx="479" cy="196" r="360" fill="#ffffff" opacity="0.22"/><circle cx="1308" cy="881" r="197" fill="#22d3ee" opacity="0.14"/><circle cx="310" cy="888" r="387" fill="#22d3ee" opacity="0.22"/><circle cx="31" cy="687" r="152" fill="#22d3ee" opacity="0.14"/></g>
|
|
6
|
+
<path d="M0 520 C400 400 1200 640 1600 520" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 580 C400 460 1200 700 1600 580" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 640 C400 520 1200 760 1600 640" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 700 C400 580 1200 820 1600 700" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 760 C400 640 1200 880 1600 760" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 820 C400 700 1200 940 1600 820" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 880 C400 760 1200 1000 1600 880" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
|
|
2
|
+
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#312e81"/><stop offset="1" stop-color="#8b5cf6"/></linearGradient>
|
|
3
|
+
<filter id="blur"><feGaussianBlur stdDeviation="40"/></filter></defs>
|
|
4
|
+
<rect width="1600" height="900" fill="url(#g)"/>
|
|
5
|
+
<g filter="url(#blur)"><circle cx="1069" cy="424" r="275" fill="#ffffff" opacity="0.18"/><circle cx="357" cy="784" r="396" fill="#f0abfc" opacity="0.18"/><circle cx="55" cy="254" r="316" fill="#ffffff" opacity="0.22"/><circle cx="1027" cy="821" r="282" fill="#f0abfc" opacity="0.22"/><circle cx="1128" cy="63" r="191" fill="#f0abfc" opacity="0.14"/><circle cx="1445" cy="545" r="406" fill="#ffffff" opacity="0.14"/><circle cx="1105" cy="126" r="155" fill="#ffffff" opacity="0.18"/></g>
|
|
6
|
+
<path d="M0 520 C400 400 1200 640 1600 520" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 580 C400 460 1200 700 1600 580" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 640 C400 520 1200 760 1600 640" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 700 C400 580 1200 820 1600 700" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 760 C400 640 1200 880 1600 760" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 820 C400 700 1200 940 1600 820" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 880 C400 760 1200 1000 1600 880" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
|
|
2
|
+
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#9a3412"/><stop offset="1" stop-color="#f97316"/></linearGradient>
|
|
3
|
+
<filter id="blur"><feGaussianBlur stdDeviation="40"/></filter></defs>
|
|
4
|
+
<rect width="1600" height="900" fill="url(#g)"/>
|
|
5
|
+
<g filter="url(#blur)"><circle cx="1170" cy="33" r="339" fill="#f97316" opacity="0.22"/><circle cx="30" cy="211" r="356" fill="#ffffff" opacity="0.22"/><circle cx="1338" cy="830" r="202" fill="#f97316" opacity="0.1"/><circle cx="1003" cy="335" r="158" fill="#f97316" opacity="0.14"/><circle cx="739" cy="45" r="335" fill="#f97316" opacity="0.14"/><circle cx="727" cy="390" r="335" fill="#f97316" opacity="0.18"/><circle cx="537" cy="467" r="209" fill="#f97316" opacity="0.18"/></g>
|
|
6
|
+
<path d="M0 520 C400 400 1200 640 1600 520" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 580 C400 460 1200 700 1600 580" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 640 C400 520 1200 760 1600 640" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 700 C400 580 1200 820 1600 700" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 760 C400 640 1200 880 1600 760" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 820 C400 700 1200 940 1600 820" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 880 C400 760 1200 1000 1600 880" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
|
|
2
|
+
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0c4a6e"/><stop offset="1" stop-color="#3b82f6"/></linearGradient>
|
|
3
|
+
<filter id="blur"><feGaussianBlur stdDeviation="40"/></filter></defs>
|
|
4
|
+
<rect width="1600" height="900" fill="url(#g)"/>
|
|
5
|
+
<g filter="url(#blur)"><circle cx="25" cy="480" r="177" fill="#bae6fd" opacity="0.22"/><circle cx="1400" cy="44" r="191" fill="#3b82f6" opacity="0.1"/><circle cx="474" cy="728" r="191" fill="#3b82f6" opacity="0.14"/><circle cx="67" cy="678" r="151" fill="#bae6fd" opacity="0.14"/><circle cx="1097" cy="749" r="349" fill="#bae6fd" opacity="0.22"/><circle cx="1214" cy="95" r="180" fill="#ffffff" opacity="0.1"/><circle cx="699" cy="203" r="225" fill="#ffffff" opacity="0.18"/></g>
|
|
6
|
+
<path d="M0 520 C400 400 1200 640 1600 520" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 580 C400 460 1200 700 1600 580" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 640 C400 520 1200 760 1600 640" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 700 C400 580 1200 820 1600 700" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 760 C400 640 1200 880 1600 760" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 820 C400 700 1200 940 1600 820" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 880 C400 760 1200 1000 1600 880" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
|
|
2
|
+
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#365314"/><stop offset="1" stop-color="#84cc16"/></linearGradient>
|
|
3
|
+
<filter id="blur"><feGaussianBlur stdDeviation="40"/></filter></defs>
|
|
4
|
+
<rect width="1600" height="900" fill="url(#g)"/>
|
|
5
|
+
<g filter="url(#blur)"><circle cx="1458" cy="392" r="418" fill="#fef08a" opacity="0.14"/><circle cx="342" cy="198" r="206" fill="#84cc16" opacity="0.1"/><circle cx="1551" cy="154" r="265" fill="#ffffff" opacity="0.1"/><circle cx="957" cy="886" r="179" fill="#84cc16" opacity="0.1"/><circle cx="345" cy="503" r="347" fill="#ffffff" opacity="0.18"/><circle cx="174" cy="703" r="250" fill="#84cc16" opacity="0.14"/><circle cx="669" cy="312" r="158" fill="#84cc16" opacity="0.18"/></g>
|
|
6
|
+
<path d="M0 520 C400 400 1200 640 1600 520" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 580 C400 460 1200 700 1600 580" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 640 C400 520 1200 760 1600 640" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 700 C400 580 1200 820 1600 700" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 760 C400 640 1200 880 1600 760" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 820 C400 700 1200 940 1600 820" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 880 C400 760 1200 1000 1600 880" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/>
|
|
7
|
+
</svg>
|