domma-cms 0.66.0 → 0.69.4
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 +94 -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 +310 -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
|
@@ -14,10 +14,159 @@ function slugify(s) {
|
|
|
14
14
|
|
|
15
15
|
const BASE = '/api/plugins/blog';
|
|
16
16
|
|
|
17
|
+
// The loader's cache-busting token, passed on so helpers are refetched with this view.
|
|
18
|
+
const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
|
|
19
|
+
|
|
20
|
+
/** One layout choice - the body of the picker's data-each (see mountLayoutCard). */
|
|
21
|
+
const LAYOUT_CHOICE = `
|
|
22
|
+
<button type="button" class="blog-layout {{cls}}" role="radio" aria-checked="{{checked}}" data-layout="{{id}}" title="{{description}}">
|
|
23
|
+
<span class="blog-thumb" data-thumb="{{thumb}}" aria-hidden="true"></span>
|
|
24
|
+
<span class="blog-layout-name">{{label}}</span>
|
|
25
|
+
</button>`;
|
|
26
|
+
|
|
27
|
+
const PREVIEW_PANEL = `
|
|
28
|
+
<div class="blog-preview">
|
|
29
|
+
<div class="blog-preview-bar">
|
|
30
|
+
<select class="form-select form-select-sm" aria-label="Layout" data-model="layout.value"
|
|
31
|
+
data-options="options.value" data-options-text="label" data-options-value="id"></select>
|
|
32
|
+
<div class="blog-seg" role="group" aria-label="Width">
|
|
33
|
+
<button type="button" data-on-click="desktop" data-bind-class="width.value === 'desktop' && 'is-on'" title="Desktop">
|
|
34
|
+
<span data-icon="monitor" data-icon-size="14"></span></button>
|
|
35
|
+
<button type="button" data-on-click="tablet" data-bind-class="width.value === 'tablet' && 'is-on'" title="Tablet">
|
|
36
|
+
<span data-icon="tablet" data-icon-size="14"></span></button>
|
|
37
|
+
<button type="button" data-on-click="phone" data-bind-class="width.value === 'phone' && 'is-on'" title="Phone">
|
|
38
|
+
<span data-icon="smartphone" data-icon-size="14"></span></button>
|
|
39
|
+
</div>
|
|
40
|
+
<span data-help-title="Preview"
|
|
41
|
+
data-help="The post as it will look on the site, from what is in the editor now - nothing is saved. Try another layout here; Use this layout keeps it for the post. Links are switched off inside the preview."></span>
|
|
42
|
+
<span class="blog-spacer"></span>
|
|
43
|
+
<span class="blog-preview-state" data-bind-text="state.value"></span>
|
|
44
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="use" data-bind-disabled="!changed.value">Use this layout</button>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="blog-preview-stage" data-bind-class="'is-' + width.value">
|
|
47
|
+
<iframe title="Post preview"></iframe>
|
|
48
|
+
</div>
|
|
49
|
+
</div>`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The Layout card: a picker over every post layout on offer (the shipped four
|
|
53
|
+
* and any blog-post-* block the site added), and a Preview panel that renders
|
|
54
|
+
* the UNSAVED post in any of them.
|
|
55
|
+
*
|
|
56
|
+
* domma-reactive, like the rest of the Tool upgrade path; the item template is
|
|
57
|
+
* inserted here because the router renders the page template through _.render,
|
|
58
|
+
* which eats {{…}}.
|
|
59
|
+
*
|
|
60
|
+
* @param {HTMLElement} card
|
|
61
|
+
* @param {{current: string, collect: () => object}} opts
|
|
62
|
+
* @returns {Promise<{value: () => string}>}
|
|
63
|
+
*/
|
|
64
|
+
async function mountLayoutCard(card, {current, collect}) {
|
|
65
|
+
const BASE_URL = '/api/plugins/blog';
|
|
66
|
+
let catalogue = {postLayouts: [], postLayout: 'blog-post-classic'};
|
|
67
|
+
try { catalogue = await H.get(`${BASE_URL}/layouts`); } catch { /* the card still offers Site default */ }
|
|
68
|
+
const known = catalogue.postLayouts ?? [];
|
|
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');
|
|
71
|
+
|
|
72
|
+
const choices = [
|
|
73
|
+
{id: '', label: 'Site default', description: `Follows Blog settings - currently ${defaultLabel}.`, thumb: thumbOf(catalogue.postLayout)},
|
|
74
|
+
...known.map(l => ({id: l.id, label: l.label, description: l.description, thumb: thumbOf(l.id)})),
|
|
75
|
+
...(current && !known.some(l => l.id === current) ? [{id: current, label: `${current} (missing)`, description: 'This block no longer exists; the site default is used.', thumb: 'custom'}] : [])
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const layout = M.observable(current || '');
|
|
79
|
+
const layoutChoices = M.observable([]);
|
|
80
|
+
M.effect(() => {
|
|
81
|
+
layoutChoices.value = choices.map(c => ({...c, id: c.id || 'default', checked: String(c.id === layout.value), cls: c.id === layout.value ? 'is-on' : ''}));
|
|
82
|
+
});
|
|
83
|
+
const data = {
|
|
84
|
+
layoutChoices,
|
|
85
|
+
layoutDesc: M.computed(() => choices.find(c => c.id === layout.value)?.description ?? '')
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
card.querySelector('.blog-layouts').innerHTML = LAYOUT_CHOICE;
|
|
89
|
+
M.applyBindings(data, card.querySelector('.card-body'));
|
|
90
|
+
card.querySelector('.blog-layouts').addEventListener('click', (e) => {
|
|
91
|
+
const b = e.target.closest('[data-layout]');
|
|
92
|
+
if (b) layout.value = b.dataset.layout === 'default' ? '' : b.dataset.layout;
|
|
93
|
+
});
|
|
94
|
+
card.querySelector('#btn-preview').addEventListener('click', () => openPreview());
|
|
95
|
+
|
|
96
|
+
async function openPreview() {
|
|
97
|
+
const kit = await import('./kit.js?v=' + (new URL(import.meta.url).searchParams.get('v') ?? '1'));
|
|
98
|
+
const trial = M.observable(layout.peek() || catalogue.postLayout || 'blog-post-classic');
|
|
99
|
+
const width = M.observable('desktop');
|
|
100
|
+
const state = M.observable('');
|
|
101
|
+
const pdata = {
|
|
102
|
+
layout: trial, width, state,
|
|
103
|
+
options: M.observable(known.map(l => ({id: l.id, label: l.label}))),
|
|
104
|
+
changed: M.computed(() => trial.value !== (layout.value || catalogue.postLayout))
|
|
105
|
+
};
|
|
106
|
+
const so = E.slideover({title: 'Preview', size: 'xl', position: 'right'});
|
|
107
|
+
const el = kit.fragment(PREVIEW_PANEL);
|
|
108
|
+
so.element.appendChild(el);
|
|
109
|
+
const disposeHelp = await kit.scanHelp(el);
|
|
110
|
+
const frame = el.querySelector('iframe');
|
|
111
|
+
const handle = M.applyBindings(pdata, el, {
|
|
112
|
+
methods: {
|
|
113
|
+
desktop: () => { width.value = 'desktop'; },
|
|
114
|
+
tablet: () => { width.value = 'tablet'; },
|
|
115
|
+
phone: () => { width.value = 'phone'; },
|
|
116
|
+
use() {
|
|
117
|
+
layout.value = trial.peek() === catalogue.postLayout && !layout.peek() ? '' : trial.peek();
|
|
118
|
+
E.toast('Layout chosen - Save to keep it.', {type: 'success'});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
if (typeof I !== 'undefined' && I.scan) I.scan(el);
|
|
123
|
+
|
|
124
|
+
let seq = 0;
|
|
125
|
+
const stop = M.effect(async () => {
|
|
126
|
+
const chosen = trial.value;
|
|
127
|
+
const mine = ++seq;
|
|
128
|
+
state.value = 'Rendering…';
|
|
129
|
+
try {
|
|
130
|
+
const res = await H.post(`${BASE_URL}/preview`, {...collect(), layout: chosen});
|
|
131
|
+
if (mine !== seq) return;
|
|
132
|
+
if (res?.error) throw new Error(res.error);
|
|
133
|
+
// The page's own addresses (/dist/…, /plugins/…) resolve against the site.
|
|
134
|
+
frame.srcdoc = String(res.html).replace(/<head>/i, `<head><base href="${location.origin}/">`);
|
|
135
|
+
state.value = '';
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (mine === seq) state.value = err?.message ?? 'Preview failed.';
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
// Links inside the preview would navigate the frame away from the post.
|
|
141
|
+
frame.addEventListener('load', () => {
|
|
142
|
+
try {
|
|
143
|
+
frame.contentDocument.addEventListener('click', (e) => {
|
|
144
|
+
if (e.target.closest('a[href]') && !e.target.closest('a[href^="#"]')) {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
E.toast('Links are switched off in the preview.', {type: 'info'});
|
|
147
|
+
}
|
|
148
|
+
}, true);
|
|
149
|
+
} catch { /* not ours to reach */ }
|
|
150
|
+
});
|
|
151
|
+
kit.onceClosed(so, () => { stop?.(); handle.dispose(); disposeHelp(); });
|
|
152
|
+
so.open();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {value: () => layout.peek()};
|
|
156
|
+
}
|
|
157
|
+
|
|
17
158
|
export const postEditorView = {
|
|
18
159
|
templateUrl: '/plugins/blog/admin/templates/post-editor.html',
|
|
19
160
|
|
|
20
161
|
async onMount($container) {
|
|
162
|
+
// Help (?) popovers, loaded at run time - a plugin must not import admin/.
|
|
163
|
+
// Scanned now, before anything below reshapes the form.
|
|
164
|
+
import('/admin/js/lib/help-popover.js')
|
|
165
|
+
.then(m => m.helpPopovers?.($container, {position: 'bottom'}))
|
|
166
|
+
.catch(() => null);
|
|
167
|
+
import(`./settings-cog.js?v=${ASSET_VERSION}`)
|
|
168
|
+
.then(m => m.addSettingsCog($container, '.view-header-actions'))
|
|
169
|
+
.catch(() => { /* no cog is better than no screen */ });
|
|
21
170
|
// Domma's router hands onMount ONE argument and never the route params,
|
|
22
171
|
// and `R.current` is a METHOD - `R.current?.params` is undefined on the
|
|
23
172
|
// function object, so this view opened blank on every edit and Save
|
|
@@ -25,8 +174,9 @@ export const postEditorView = {
|
|
|
25
174
|
// view does.
|
|
26
175
|
const routeMatch = location.hash.match(/^#\/plugins\/blog\/posts\/([^?#]+)/);
|
|
27
176
|
const routeId = routeMatch ? decodeURIComponent(routeMatch[1]) : null;
|
|
28
|
-
|
|
29
|
-
|
|
177
|
+
// `let`: a new post becomes an existing one on its first Save.
|
|
178
|
+
let id = routeId && routeId !== 'new' ? routeId : null;
|
|
179
|
+
let isEdit = Boolean(id);
|
|
30
180
|
|
|
31
181
|
let post = null;
|
|
32
182
|
let categories = [];
|
|
@@ -69,7 +219,7 @@ export const postEditorView = {
|
|
|
69
219
|
const imagePreviewEl = $container.find('#featured-image-preview').get(0);
|
|
70
220
|
const clearImageBtn = $container.find('#btn-clear-image').get(0);
|
|
71
221
|
|
|
72
|
-
if (titleEl) titleEl.textContent = isEdit ? '
|
|
222
|
+
if (titleEl) titleEl.textContent = isEdit ? 'Editing post' : 'New post';
|
|
73
223
|
|
|
74
224
|
if (isEdit && post) {
|
|
75
225
|
if (postTitleEl) postTitleEl.value = post.title ?? '';
|
|
@@ -136,8 +286,14 @@ export const postEditorView = {
|
|
|
136
286
|
}
|
|
137
287
|
}
|
|
138
288
|
|
|
289
|
+
const layoutCard = await mountLayoutCard($container.find('#layout-card').get(0), {
|
|
290
|
+
current: post?.template ?? '',
|
|
291
|
+
collect: () => ({...collectValues(), id})
|
|
292
|
+
});
|
|
293
|
+
|
|
139
294
|
function collectValues() {
|
|
140
295
|
return {
|
|
296
|
+
template: layoutCard.value(),
|
|
141
297
|
title: postTitleEl?.value?.trim() ?? '',
|
|
142
298
|
slug: postSlugEl?.value?.trim() ?? '',
|
|
143
299
|
excerpt: excerptEl?.value?.trim() ?? '',
|
|
@@ -156,6 +312,7 @@ export const postEditorView = {
|
|
|
156
312
|
const body = { ...collectValues(), ...extra };
|
|
157
313
|
if (!body.title) {
|
|
158
314
|
E.toast('Title is required.', { type: 'error' });
|
|
315
|
+
postTitleEl?.focus();
|
|
159
316
|
return null;
|
|
160
317
|
}
|
|
161
318
|
try {
|
|
@@ -163,13 +320,107 @@ export const postEditorView = {
|
|
|
163
320
|
? await H.put(`${BASE}/posts/${id}`, body)
|
|
164
321
|
: await H.post(`${BASE}/posts`, body);
|
|
165
322
|
if (res.error) throw new Error(res.error);
|
|
166
|
-
|
|
323
|
+
const saved = res.data ?? res;
|
|
324
|
+
// A new post is an existing one from here on: later saves
|
|
325
|
+
// update it, and the address says which post this is (no
|
|
326
|
+
// hashchange, so the view is not re-mounted).
|
|
327
|
+
if (!isEdit && saved?.id) {
|
|
328
|
+
id = saved.id;
|
|
329
|
+
isEdit = true;
|
|
330
|
+
history.replaceState(history.state, '', `#/plugins/blog/posts/${encodeURIComponent(id)}`);
|
|
331
|
+
if (titleEl) titleEl.textContent = 'Editing post';
|
|
332
|
+
}
|
|
333
|
+
post = saved;
|
|
334
|
+
baseline = JSON.stringify(collectValues());
|
|
335
|
+
setStatus(saved.status ?? status);
|
|
336
|
+
showSaved();
|
|
337
|
+
return saved;
|
|
167
338
|
} catch (err) {
|
|
168
339
|
E.toast(err?.message ?? 'Failed to save post.', { type: 'error' });
|
|
169
340
|
return null;
|
|
170
341
|
}
|
|
171
342
|
}
|
|
172
343
|
|
|
344
|
+
// ---- Status, and whether anything is unsaved ----
|
|
345
|
+
let status = post?.status ?? 'draft';
|
|
346
|
+
let baseline = JSON.stringify(collectValues());
|
|
347
|
+
let savedAt = null;
|
|
348
|
+
const saveStateEl = $container.find('#save-state').get(0);
|
|
349
|
+
const draftsBtn = $container.find('#btn-drafts').get(0);
|
|
350
|
+
const isDirty = () => JSON.stringify(collectValues()) !== baseline;
|
|
351
|
+
|
|
352
|
+
function showSaved() {
|
|
353
|
+
savedAt = new Date();
|
|
354
|
+
refreshState();
|
|
355
|
+
}
|
|
356
|
+
function refreshState() {
|
|
357
|
+
if (!saveStateEl) return;
|
|
358
|
+
const dirty = isDirty();
|
|
359
|
+
saveStateEl.classList.toggle('is-dirty', dirty);
|
|
360
|
+
saveStateEl.textContent = dirty
|
|
361
|
+
? 'Unsaved changes'
|
|
362
|
+
: savedAt ? `Saved ${savedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : '';
|
|
363
|
+
}
|
|
364
|
+
function setStatus(next) {
|
|
365
|
+
status = next || 'draft';
|
|
366
|
+
if (statusBadge) {
|
|
367
|
+
statusBadge.textContent = status;
|
|
368
|
+
statusBadge.className = `badge ${({ published: 'badge-success', scheduled: 'badge-warning' })[status] ?? 'badge-secondary'}`;
|
|
369
|
+
}
|
|
370
|
+
const live = status === 'published';
|
|
371
|
+
$container.find('#btn-publish').get(0)?.classList.toggle('blog-hidden', live);
|
|
372
|
+
$container.find('#btn-schedule').get(0)?.classList.toggle('blog-hidden', live);
|
|
373
|
+
draftsBtn?.classList.toggle('blog-hidden', status === 'draft');
|
|
374
|
+
}
|
|
375
|
+
setStatus(status);
|
|
376
|
+
|
|
377
|
+
// Anything typed, ticked or picked may change what would be saved.
|
|
378
|
+
let queued = false;
|
|
379
|
+
const recheck = () => {
|
|
380
|
+
if (queued) return;
|
|
381
|
+
queued = true;
|
|
382
|
+
queueMicrotask(() => { queued = false; refreshState(); });
|
|
383
|
+
};
|
|
384
|
+
['input', 'change', 'click'].forEach(ev => $container.get(0).addEventListener(ev, recheck));
|
|
385
|
+
|
|
386
|
+
async function save() {
|
|
387
|
+
if (await doSave()) E.toast(status === 'published' ? 'Saved - the live post is updated.' : 'Saved.', { type: 'success' });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function goBack() {
|
|
391
|
+
if (isDirty() && !(await E.confirm('Leave without saving? Your changes to this post will be lost.'))) return;
|
|
392
|
+
baseline = JSON.stringify(collectValues()); // do not ask twice (beforeunload)
|
|
393
|
+
R.navigate('#/plugins/blog/posts');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
$container.find('#btn-save').get(0)?.addEventListener('click', save);
|
|
397
|
+
$container.find('#btn-back').get(0)?.addEventListener('click', goBack);
|
|
398
|
+
draftsBtn?.addEventListener('click', async () => {
|
|
399
|
+
const saved = await doSave({ status: 'draft' });
|
|
400
|
+
if (saved) E.toast('Moved to drafts - it is no longer on the site.', { type: 'success' });
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Ctrl/Cmd+S saves; a closing tab with unsaved work asks first. Both
|
|
404
|
+
// come off when the hash leaves this editor (the router has no unmount).
|
|
405
|
+
const onKey = (e) => {
|
|
406
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
|
407
|
+
e.preventDefault();
|
|
408
|
+
save();
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const onUnload = (e) => {
|
|
412
|
+
if (isDirty()) { e.preventDefault(); e.returnValue = ''; }
|
|
413
|
+
};
|
|
414
|
+
document.addEventListener('keydown', onKey);
|
|
415
|
+
window.addEventListener('beforeunload', onUnload);
|
|
416
|
+
const onHash = () => {
|
|
417
|
+
if (/^#\/plugins\/blog\/posts\/[^/]+$/.test(location.hash)) return;
|
|
418
|
+
document.removeEventListener('keydown', onKey);
|
|
419
|
+
window.removeEventListener('beforeunload', onUnload);
|
|
420
|
+
window.removeEventListener('hashchange', onHash);
|
|
421
|
+
};
|
|
422
|
+
window.addEventListener('hashchange', onHash);
|
|
423
|
+
|
|
173
424
|
if (postTitleEl) {
|
|
174
425
|
postTitleEl.addEventListener('input', () => {
|
|
175
426
|
if (slugAutoEl?.checked && postSlugEl) {
|
|
@@ -199,17 +450,6 @@ export const postEditorView = {
|
|
|
199
450
|
});
|
|
200
451
|
}
|
|
201
452
|
|
|
202
|
-
const saveDraftBtn = $container.find('#btn-save-draft').get(0);
|
|
203
|
-
if (saveDraftBtn) {
|
|
204
|
-
saveDraftBtn.addEventListener('click', async () => {
|
|
205
|
-
const saved = await doSave({ status: 'draft' });
|
|
206
|
-
if (saved) {
|
|
207
|
-
E.toast('Draft saved.', { type: 'success' });
|
|
208
|
-
R.navigate('#/plugins/blog/posts');
|
|
209
|
-
}
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
453
|
const publishBtn = $container.find('#btn-publish').get(0);
|
|
214
454
|
if (publishBtn) {
|
|
215
455
|
publishBtn.addEventListener('click', async () => {
|
|
@@ -217,10 +457,11 @@ export const postEditorView = {
|
|
|
217
457
|
if (!saved) return;
|
|
218
458
|
const savedId = saved.id ?? id;
|
|
219
459
|
try {
|
|
220
|
-
const res = await H.post(`${BASE}/posts/${savedId}/publish
|
|
460
|
+
const res = await H.post(`${BASE}/posts/${savedId}/publish`, {});
|
|
221
461
|
if (res.error) throw new Error(res.error);
|
|
222
|
-
|
|
223
|
-
|
|
462
|
+
post = res.data ?? res;
|
|
463
|
+
setStatus('published');
|
|
464
|
+
E.toast('Published - the post is live.', { type: 'success' });
|
|
224
465
|
} catch (err) {
|
|
225
466
|
E.toast(err?.message ?? 'Failed to publish post.', { type: 'error' });
|
|
226
467
|
}
|
|
@@ -229,7 +470,6 @@ export const postEditorView = {
|
|
|
229
470
|
|
|
230
471
|
const scheduleBtn = $container.find('#btn-schedule').get(0);
|
|
231
472
|
if (scheduleBtn) {
|
|
232
|
-
scheduleBtn.classList.remove('blog-hidden');
|
|
233
473
|
scheduleBtn.addEventListener('click', async () => {
|
|
234
474
|
// First click reveals the picker; the second one schedules.
|
|
235
475
|
if (scheduleEl) scheduleEl.classList.toggle('blog-hidden');
|
|
@@ -248,8 +488,10 @@ export const postEditorView = {
|
|
|
248
488
|
try {
|
|
249
489
|
const res = await H.post(`${BASE}/posts/${savedId}/schedule`, { publishedAt });
|
|
250
490
|
if (res.error) throw new Error(res.error);
|
|
251
|
-
|
|
252
|
-
|
|
491
|
+
post = res.data ?? res;
|
|
492
|
+
setStatus('scheduled');
|
|
493
|
+
if (scheduleEl) scheduleEl.classList.add('blog-hidden');
|
|
494
|
+
E.toast(`Scheduled for ${new Date(publishedAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}.`, { type: 'success' });
|
|
253
495
|
} catch (err) {
|
|
254
496
|
E.toast(err?.message ?? 'Failed to schedule post.', { type: 'error' });
|
|
255
497
|
}
|
|
@@ -324,6 +566,12 @@ export const postEditorView = {
|
|
|
324
566
|
slideover.open();
|
|
325
567
|
}
|
|
326
568
|
|
|
569
|
+
// Again at the end: the plugin chrome rebuilds the header after the
|
|
570
|
+
// scan at the top, so its (?) would otherwise never appear. Markers
|
|
571
|
+
// already converted are skipped.
|
|
572
|
+
import('/admin/js/lib/help-popover.js')
|
|
573
|
+
.then(m => m.helpPopovers?.($container, {position: 'bottom'}))
|
|
574
|
+
.catch(() => null);
|
|
327
575
|
Domma.icons.scan();
|
|
328
576
|
}
|
|
329
577
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings cog in the admin's plugin banner, to the left of the licence.
|
|
3
|
+
* It opens the settings in a (resizable) slideover rather than leaving the
|
|
4
|
+
* screen; #/plugins/blog/settings still works for anything that links there.
|
|
5
|
+
*
|
|
6
|
+
* Every plugin puts its settings in the same place, so it is not a sidebar item
|
|
7
|
+
* here any more. The banner belongs to the admin, and addBannerAction() is its
|
|
8
|
+
* door - imported at RUN time, because a static import would take the view
|
|
9
|
+
* down on a CMS whose admin predates it. There, the cog goes at the end of the
|
|
10
|
+
* view's own header row instead.
|
|
11
|
+
*
|
|
12
|
+
* @module blog/admin/views/settings-cog
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// The loader's cache-busting token, passed on so the kit is refetched with this file.
|
|
16
|
+
const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
|
|
17
|
+
|
|
18
|
+
/** Settings open in a slideover over whichever Blog screen you are on. */
|
|
19
|
+
async function openSettings() {
|
|
20
|
+
try {
|
|
21
|
+
const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
|
|
22
|
+
await kit.openSettings();
|
|
23
|
+
} catch {
|
|
24
|
+
// The slideover is the better place, not the only one.
|
|
25
|
+
R.navigate('#/plugins/blog/settings');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} $container - the view container onMount was given
|
|
31
|
+
* @param {string} fallback - selector for the header row to use without a banner
|
|
32
|
+
*/
|
|
33
|
+
export async function addSettingsCog($container, fallback) {
|
|
34
|
+
const onClick = () => openSettings();
|
|
35
|
+
try {
|
|
36
|
+
const chrome = await import('/admin/js/lib/plugin-chrome.js');
|
|
37
|
+
const placed = chrome.addBannerAction?.($container, {
|
|
38
|
+
id: 'blog-settings', icon: 'settings', title: 'Blog settings', onClick
|
|
39
|
+
});
|
|
40
|
+
if (placed) return;
|
|
41
|
+
} catch { /* older admin: fall through */ }
|
|
42
|
+
|
|
43
|
+
const row = $container.find(fallback).get(0);
|
|
44
|
+
if (!row) return;
|
|
45
|
+
const cog = document.createElement('button');
|
|
46
|
+
cog.type = 'button';
|
|
47
|
+
cog.className = 'btn btn-sm btn-ghost';
|
|
48
|
+
cog.title = 'Settings';
|
|
49
|
+
const glyph = document.createElement('span');
|
|
50
|
+
glyph.setAttribute('data-icon', 'settings');
|
|
51
|
+
glyph.setAttribute('data-icon-size', '16');
|
|
52
|
+
cog.appendChild(glyph);
|
|
53
|
+
cog.addEventListener('click', onClick);
|
|
54
|
+
row.appendChild(cog);
|
|
55
|
+
if (typeof I !== 'undefined' && I.scan) I.scan(cog);
|
|
56
|
+
}
|
|
@@ -1,102 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Blog settings
|
|
2
|
+
* Blog settings as a page - the old #/plugins/blog/settings route.
|
|
3
|
+
*
|
|
4
|
+
* Settings live behind the banner cog now, in a slideover over whichever Blog
|
|
5
|
+
* screen you are on. This route stays so a bookmark or a link from elsewhere
|
|
6
|
+
* still lands somewhere useful: it renders the SAME panel (kit.settingsPanel),
|
|
7
|
+
* so the page and the slideover cannot drift apart.
|
|
3
8
|
*
|
|
4
9
|
* @module blog/admin/views/settings
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
// The loader's cache-busting token, passed on so the kit is refetched with this view.
|
|
13
|
+
const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
|
|
8
14
|
|
|
9
15
|
export const settingsView = {
|
|
10
16
|
templateUrl: '/plugins/blog/admin/templates/settings.html',
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
|
-
* Mount the blog settings view.
|
|
14
|
-
*
|
|
15
19
|
* @param {object} $container - Domma-wrapped container element
|
|
16
20
|
* @returns {Promise<void>}
|
|
17
21
|
*/
|
|
18
22
|
async onMount($container) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (res.error) throw new Error(res.error);
|
|
28
|
-
settings = res.data ?? res ?? {};
|
|
29
|
-
} catch {
|
|
30
|
-
E.toast('Failed to load settings.', { type: 'error' });
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ---------------------------------------------------------------
|
|
34
|
-
// Render form using F.create
|
|
35
|
-
// ---------------------------------------------------------------
|
|
36
|
-
|
|
37
|
-
const formEl = $container.find('#settings-form').get(0);
|
|
38
|
-
if (!formEl) return;
|
|
39
|
-
|
|
40
|
-
// F.create takes a SCHEMA, initial data and options, and renders with
|
|
41
|
-
// .renderTo() - it does not take a container. Passing the element as the
|
|
42
|
-
// first argument made it the schema, so this form rendered nothing at
|
|
43
|
-
// all. See admin/js/views/plugins.js for the house pattern.
|
|
44
|
-
const form = F.create(
|
|
45
|
-
{
|
|
46
|
-
basePath: {
|
|
47
|
-
type: 'string', label: 'Base path', default: '/blog',
|
|
48
|
-
// placeholder and hint live under formConfig - Forma reads
|
|
49
|
-
// fieldDef.formConfig, and a top-level `placeholder` or
|
|
50
|
-
// `help` is silently ignored.
|
|
51
|
-
formConfig: {placeholder: '/blog', hint: 'URL prefix for all blog pages (e.g. /blog).'}
|
|
52
|
-
},
|
|
53
|
-
postsPerPage: {
|
|
54
|
-
type: 'number', label: 'Posts per page', default: 10,
|
|
55
|
-
formConfig: {placeholder: '10', hint: 'Number of posts shown per listing page.'}
|
|
56
|
-
},
|
|
57
|
-
commentsEnabled: {
|
|
58
|
-
type: 'boolean', label: 'Enable comments',
|
|
59
|
-
formConfig: {hint: 'Allow readers to submit comments on posts.'}
|
|
60
|
-
},
|
|
61
|
-
commentModeration: {
|
|
62
|
-
type: 'boolean', label: 'Moderate comments',
|
|
63
|
-
formConfig: {hint: 'Hold new comments for approval before they appear.'}
|
|
64
|
-
},
|
|
65
|
-
feedLength: {
|
|
66
|
-
type: 'number', label: 'RSS feed length', default: 20,
|
|
67
|
-
formConfig: {placeholder: '20', hint: 'Maximum number of items in the RSS/Atom feed.'}
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
basePath: settings.basePath ?? '/blog',
|
|
72
|
-
postsPerPage: settings.postsPerPage ?? 10,
|
|
73
|
-
commentsEnabled: settings.commentsEnabled ?? true,
|
|
74
|
-
commentModeration: settings.commentModeration ?? true,
|
|
75
|
-
feedLength: settings.feedLength ?? 20
|
|
76
|
-
},
|
|
77
|
-
{ showSubmitButton: false }
|
|
78
|
-
);
|
|
79
|
-
form.renderTo(formEl);
|
|
80
|
-
|
|
81
|
-
const saveBtn = $container.find('#btn-save-settings').get(0);
|
|
82
|
-
saveBtn?.addEventListener('click', async () => {
|
|
83
|
-
if (!form.validate()) return;
|
|
84
|
-
const values = form.getData();
|
|
85
|
-
try {
|
|
86
|
-
const res = await H.put(`${BASE}/settings`, {
|
|
87
|
-
basePath: values.basePath,
|
|
88
|
-
postsPerPage: Number(values.postsPerPage) || 10,
|
|
89
|
-
commentsEnabled: Boolean(values.commentsEnabled),
|
|
90
|
-
commentModeration: Boolean(values.commentModeration),
|
|
91
|
-
feedLength: Number(values.feedLength) || 20
|
|
92
|
-
});
|
|
93
|
-
if (res.error) throw new Error(res.error);
|
|
94
|
-
E.toast('Settings saved.', { type: 'success' });
|
|
95
|
-
} catch (err) {
|
|
96
|
-
E.toast(err?.message ?? 'Failed to save settings.', { type: 'error' });
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
Domma.icons.scan();
|
|
23
|
+
const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
|
|
24
|
+
const add = kit.viewScope('settings', /^#\/plugins\/blog\/settings\b/);
|
|
25
|
+
|
|
26
|
+
const host = $container.find('#settings-form').get(0);
|
|
27
|
+
if (!host) return;
|
|
28
|
+
const {el, dispose} = await kit.settingsPanel();
|
|
29
|
+
host.appendChild(el);
|
|
30
|
+
add(dispose);
|
|
101
31
|
}
|
|
102
32
|
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
display: grid;
|
|
2
|
+
grid-template-columns: 7.5rem 1fr auto auto;
|
|
3
|
+
align-items: baseline;
|
|
4
|
+
gap: 1rem;
|
|
5
|
+
padding: .6rem .25rem;
|
|
6
|
+
border-bottom: 1px solid var(--dm-border, rgba(127,127,127,.2));
|
|
7
|
+
&:hover { background: rgba(127,127,127,.06); }
|
|
8
|
+
.bk-date { font-variant-numeric: tabular-nums; color: var(--dm-text-muted); font-size: .85rem; }
|
|
9
|
+
.bk-title { font-weight: 600; color: inherit; text-decoration: none; min-width: 0; }
|
|
10
|
+
.bk-title:hover { color: var(--dm-primary); }
|
|
11
|
+
.bk-cats { display: inline-flex; gap: .3rem; }
|
|
12
|
+
.bk-time { font-size: .8rem; color: var(--dm-text-muted); white-space: nowrap; }
|
|
13
|
+
@media (max-width: 640px) {
|
|
14
|
+
grid-template-columns: 1fr auto;
|
|
15
|
+
.bk-date { grid-column: 1 / -1; }
|
|
16
|
+
.bk-cats { display: none; }
|
|
17
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
blog-card-compact - a title and a date on one line (the Compact display).
|
|
3
|
+
Same placeholders as blog-card.
|
|
4
|
+
-->
|
|
5
|
+
<time class="bk-date" datetime="{{publishedAt}}">{{publishedAt:DD MMM YYYY}}</time>
|
|
6
|
+
<a class="bk-title" href="{{url}}">{{title}}</a>
|
|
7
|
+
<span class="bk-cats">{{{categories}}}</span>
|
|
8
|
+
<span class="bk-time">{{readingTime}}</span>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
display: grid;
|
|
2
|
+
grid-template-columns: minmax(0, 15rem) 1fr;
|
|
3
|
+
gap: 1.25rem;
|
|
4
|
+
align-items: start;
|
|
5
|
+
padding: 1.1rem 0;
|
|
6
|
+
border-bottom: 1px solid var(--dm-border, rgba(127,127,127,.25));
|
|
7
|
+
&:has(.br-media.no-image) { grid-template-columns: 1fr; }
|
|
8
|
+
.br-media { display: block; aspect-ratio: 4 / 3; border-radius: var(--dm-radius-md, 8px); overflow: hidden; }
|
|
9
|
+
.br-media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .35s ease; }
|
|
10
|
+
&:hover .br-media img { transform: scale(1.04); }
|
|
11
|
+
.br-media.no-image { display: none; }
|
|
12
|
+
.br-body { display: flex; flex-direction: column; gap: .4rem; min-width: 0; }
|
|
13
|
+
.br-meta { margin: 0; font-size: .8rem; color: var(--dm-text-muted); text-transform: uppercase; letter-spacing: .04em; }
|
|
14
|
+
.br-title { margin: 0; font-size: 1.35rem; line-height: 1.25; }
|
|
15
|
+
.br-title a { color: inherit; text-decoration: none; }
|
|
16
|
+
.br-title a:hover { color: var(--dm-primary); }
|
|
17
|
+
.br-excerpt { margin: 0; color: var(--dm-text-muted); line-height: 1.6; }
|
|
18
|
+
.br-excerpt:empty { display: none; }
|
|
19
|
+
.br-foot { display: flex; align-items: center; justify-content: space-between; gap: .75rem; flex-wrap: wrap; font-size: .85rem; }
|
|
20
|
+
.br-cats { display: inline-flex; gap: .35rem; flex-wrap: wrap; }
|
|
21
|
+
.br-author { color: var(--dm-text-muted); }
|
|
22
|
+
@media (max-width: 640px) { grid-template-columns: 1fr; }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
blog-card-row - one post per row, picture beside the text (the List display).
|
|
3
|
+
Same placeholders as blog-card.
|
|
4
|
+
-->
|
|
5
|
+
<a class="br-media {{imageClass}}" href="{{url}}" tabindex="-1" aria-hidden="true">{{{image}}}</a>
|
|
6
|
+
<div class="br-body">
|
|
7
|
+
<p class="br-meta"><time datetime="{{publishedAt}}">{{publishedAt:D MMMM YYYY}}</time> · {{readingTime}}</p>
|
|
8
|
+
<h2 class="br-title"><a href="{{url}}">{{title}}</a></h2>
|
|
9
|
+
<p class="br-excerpt">{{excerpt}}</p>
|
|
10
|
+
<div class="br-foot"><span class="br-cats">{{{categories}}}</span><a class="br-author" href="{{authorUrl}}">{{authorName}}</a></div>
|
|
11
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
display: flex;
|
|
2
|
+
flex-direction: column;
|
|
3
|
+
border: 1px solid var(--dm-border, rgba(127,127,127,.25));
|
|
4
|
+
border-radius: var(--dm-radius-lg, 12px);
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
background: var(--dm-surface, transparent);
|
|
7
|
+
transition: transform .15s ease, box-shadow .15s ease;
|
|
8
|
+
&:hover { transform: translateY(-2px); box-shadow: var(--dm-shadow-md, 0 10px 28px rgba(0,0,0,.12)); }
|
|
9
|
+
.bc-media { display: block; aspect-ratio: 16 / 9; overflow: hidden; background: rgba(127,127,127,.08); }
|
|
10
|
+
.bc-media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .35s ease; }
|
|
11
|
+
&:hover .bc-media img { transform: scale(1.04); }
|
|
12
|
+
.bc-media.no-image { display: none; }
|
|
13
|
+
.bc-body { flex: 1; display: flex; flex-direction: column; gap: .45rem; padding: 1rem 1.1rem 1.1rem; }
|
|
14
|
+
.bc-kicker { display: flex; flex-wrap: wrap; gap: .35rem; }
|
|
15
|
+
.bc-kicker:empty { display: none; }
|
|
16
|
+
.bc-title { margin: 0; font-size: 1.15rem; line-height: 1.3; }
|
|
17
|
+
.bc-title a { color: inherit; text-decoration: none; }
|
|
18
|
+
.bc-title a:hover { color: var(--dm-primary); }
|
|
19
|
+
.bc-excerpt { margin: 0; color: var(--dm-text-muted); font-size: .93rem; line-height: 1.55;
|
|
20
|
+
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
21
|
+
.bc-excerpt:empty { display: none; }
|
|
22
|
+
.bc-meta { margin: auto 0 0; padding-top: .35rem; font-size: .8rem; color: var(--dm-text-muted); }
|
|
23
|
+
.bc-meta a { color: inherit; }
|
|
24
|
+
/* Magazine: the first post leads, across the whole grid. */
|
|
25
|
+
&.is-lead { grid-column: 1 / -1; flex-direction: row; }
|
|
26
|
+
&.is-lead .bc-media { flex: 0 0 58%; aspect-ratio: auto; min-height: 320px; }
|
|
27
|
+
&.is-lead .bc-body { justify-content: center; padding: 1.75rem; }
|
|
28
|
+
&.is-lead .bc-title { font-size: 1.85rem; }
|
|
29
|
+
&.is-lead .bc-excerpt { font-size: 1.05rem; -webkit-line-clamp: 5; }
|
|
30
|
+
&.is-lead .bc-meta { margin-top: .5rem; }
|
|
31
|
+
@media (max-width: 760px) {
|
|
32
|
+
&.is-lead { flex-direction: column; }
|
|
33
|
+
&.is-lead .bc-media { min-height: 0; aspect-ratio: 16 / 9; }
|
|
34
|
+
&.is-lead .bc-title { font-size: 1.4rem; }
|
|
35
|
+
}
|