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
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
<div class="view-header">
|
|
2
|
-
<
|
|
1
|
+
<div class="view-header blog-editor-bar">
|
|
2
|
+
<button type="button" id="btn-back" class="btn btn-sm btn-ghost" title="Back to Posts - asks first if there is anything unsaved">
|
|
3
|
+
<span data-icon="arrow-left" data-icon-size="14"></span> Posts
|
|
4
|
+
</button>
|
|
5
|
+
<span id="editor-title" class="blog-editor-title">New post</span>
|
|
6
|
+
<span id="save-state" class="blog-save-state" role="status" aria-live="polite"></span>
|
|
3
7
|
<div class="view-header-actions">
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
<button id="btn-
|
|
8
|
+
<span data-help-title="Saving and publishing"
|
|
9
|
+
data-help="Save (or Ctrl+S) stores your changes and keeps the post as it is - a draft stays a draft, a live post stays live and updates. Publish puts it on the site now; Schedule picks a date and time. Move to drafts takes a live or scheduled post off the site. You stay on this page throughout; Posts takes you back to the list."></span>
|
|
10
|
+
<button type="button" id="btn-drafts" class="btn btn-sm btn-ghost blog-hidden" title="Take this post off the site">Move to drafts</button>
|
|
11
|
+
<button type="button" id="btn-schedule" class="btn btn-secondary">Schedule</button>
|
|
12
|
+
<button type="button" id="btn-publish" class="btn btn-secondary">Publish</button>
|
|
13
|
+
<button type="button" id="btn-save" class="btn btn-primary" title="Save (Ctrl+S)">
|
|
14
|
+
<span data-icon="save" data-icon-size="14"></span> Save
|
|
15
|
+
</button>
|
|
7
16
|
</div>
|
|
8
17
|
</div>
|
|
9
18
|
|
|
@@ -17,7 +26,7 @@
|
|
|
17
26
|
<input type="text" id="post-title" class="form-input form-input-lg" placeholder="Post title…">
|
|
18
27
|
</div>
|
|
19
28
|
<div class="mb-3">
|
|
20
|
-
<label class="form-label" for="post-slug">Slug
|
|
29
|
+
<label class="form-label" for="post-slug">Slug <span data-help="The last part of the post's address, e.g. my-first-post. With Auto ticked it follows the title. Changing it on a published post breaks links to the old address."></span></label>
|
|
21
30
|
<div class="flex gap-2 items-center">
|
|
22
31
|
<input type="text" id="post-slug" class="form-input font-mono">
|
|
23
32
|
<label class="flex items-center gap-1 text-muted text-sm blog-nowrap" for="slug-auto">
|
|
@@ -29,11 +38,11 @@
|
|
|
29
38
|
</div>
|
|
30
39
|
</div>
|
|
31
40
|
<div class="mb-3">
|
|
32
|
-
<label class="form-label" for="post-excerpt">Excerpt
|
|
41
|
+
<label class="form-label" for="post-excerpt">Excerpt <span data-help="A sentence or two shown in post listings and used as the search-engine description when SEO Description is empty."></span></label>
|
|
33
42
|
<textarea id="post-excerpt" class="form-textarea" rows="2" placeholder="Short summary…"></textarea>
|
|
34
43
|
</div>
|
|
35
44
|
<div class="mb-3">
|
|
36
|
-
<label class="form-label" for="post-content">Content
|
|
45
|
+
<label class="form-label" for="post-content">Content <span data-help="Markdown. Every shortcode the site supports works here too."></span></label>
|
|
37
46
|
<textarea id="post-content" class="form-textarea font-mono" rows="20" placeholder="Write in Markdown…"></textarea>
|
|
38
47
|
</div>
|
|
39
48
|
</div>
|
|
@@ -42,7 +51,7 @@
|
|
|
42
51
|
<!-- SEO accordion -->
|
|
43
52
|
<div class="card mb-3">
|
|
44
53
|
<div class="card-header blog-clickable" id="seo-toggle">
|
|
45
|
-
SEO <span class="text-muted text-sm">(click to expand)</span>
|
|
54
|
+
SEO <span class="text-muted text-sm">(click to expand)</span> <span data-help="Optional overrides for what search engines and link previews show. Leave empty to use the title and excerpt."></span>
|
|
46
55
|
</div>
|
|
47
56
|
<div class="card-body blog-hidden" id="seo-panel">
|
|
48
57
|
<div class="mb-3">
|
|
@@ -60,7 +69,7 @@
|
|
|
60
69
|
<!-- Sidebar -->
|
|
61
70
|
<div class="blog-editor-side">
|
|
62
71
|
<div class="card mb-3">
|
|
63
|
-
<div class="card-header">Status
|
|
72
|
+
<div class="card-header">Status <span data-help="Save Draft keeps the post private. Publish Now puts it live. Schedule publishes it at the date and time you choose."></span></div>
|
|
64
73
|
<div class="card-body">
|
|
65
74
|
<div id="schedule-picker" class="mb-3 blog-hidden">
|
|
66
75
|
<label class="form-label" for="post-scheduled-at">Scheduled for</label>
|
|
@@ -70,15 +79,29 @@
|
|
|
70
79
|
</div>
|
|
71
80
|
</div>
|
|
72
81
|
|
|
82
|
+
<div class="card mb-3" id="layout-card">
|
|
83
|
+
<div class="card-header blog-card-head">Layout
|
|
84
|
+
<span data-help-title="Post layout"
|
|
85
|
+
data-help="How this post looks on the site. Site default follows Blog settings, so changing the default there moves every post that has not chosen. Each layout is a block you can restyle under Blocks."></span>
|
|
86
|
+
<button type="button" class="btn btn-xs btn-ghost blog-preview-btn" id="btn-preview" title="See the post as readers will, before saving">
|
|
87
|
+
<span data-icon="eye" data-icon-size="13"></span> Preview
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="card-body">
|
|
91
|
+
<div class="blog-layouts" role="radiogroup" aria-label="Layout" data-each="layoutChoices.value key=id"></div>
|
|
92
|
+
<p class="blog-layout-desc" data-bind-text="layoutDesc.value"></p>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
73
96
|
<div class="card mb-3">
|
|
74
|
-
<div class="card-header">Categories
|
|
97
|
+
<div class="card-header">Categories <span data-help="Manage the list under Blog > Categories. A post can be in several."></span></div>
|
|
75
98
|
<div class="card-body" id="categories-checklist">
|
|
76
99
|
<p class="text-muted text-sm">Loading…</p>
|
|
77
100
|
</div>
|
|
78
101
|
</div>
|
|
79
102
|
|
|
80
103
|
<div class="card mb-3">
|
|
81
|
-
<div class="card-header">Tags
|
|
104
|
+
<div class="card-header">Tags <span data-help="Free-form labels, comma-separated. Each tag gets its own page on the site."></span></div>
|
|
82
105
|
<div class="card-body">
|
|
83
106
|
<input type="text" id="post-tags" class="form-input" placeholder="tag1, tag2, …">
|
|
84
107
|
<div class="text-muted text-sm mt-1">Comma-separated</div>
|
|
@@ -86,7 +109,7 @@
|
|
|
86
109
|
</div>
|
|
87
110
|
|
|
88
111
|
<div class="card mb-3">
|
|
89
|
-
<div class="card-header">Featured Image
|
|
112
|
+
<div class="card-header">Featured Image <span data-help="Shown at the top of the post and in listings. Pick one from the Media library."></span></div>
|
|
90
113
|
<div class="card-body">
|
|
91
114
|
<div id="featured-image-preview" class="mb-2"></div>
|
|
92
115
|
<button id="btn-pick-image" class="btn btn-sm btn-secondary">Pick Image</button>
|
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
<button id="btn-save-settings" class="btn btn-primary">
|
|
4
|
-
<span data-icon="save"></span> Save
|
|
5
|
-
</button>
|
|
6
|
-
</div>
|
|
7
|
-
|
|
8
|
-
<div class="alert alert-warning mb-4">
|
|
9
|
-
Base path and posts-per-page changes require a server restart to take effect.
|
|
10
|
-
</div>
|
|
11
|
-
|
|
1
|
+
<!-- No heading: the banner above is the title. The same panel opens from the
|
|
2
|
+
banner's cog on every Blog screen; this page is for links that land here. -->
|
|
12
3
|
<div class="card blog-settings-card">
|
|
13
4
|
<div class="card-body" id="settings-form"></div>
|
|
14
5
|
</div>
|
|
@@ -1,202 +1,328 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Blog posts
|
|
2
|
+
* Blog posts - the list, on the Tool upgrade path.
|
|
3
|
+
*
|
|
4
|
+
* State is domma-reactive observables bound with M.applyBindings; there is no
|
|
5
|
+
* render function. The four status counts are chips that also filter, which is
|
|
6
|
+
* what the KPI cards were taking a row of screen for. Everything you can do to
|
|
7
|
+
* a post is on its context menu (right-click, Shift+F10, or ⋮); a click on the
|
|
8
|
+
* row opens it.
|
|
3
9
|
*
|
|
4
10
|
* @module blog/admin/views/blog
|
|
5
11
|
*/
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
13
|
+
// The loader's cache-busting token, passed on so helpers are refetched with this view.
|
|
14
|
+
const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
|
|
10
15
|
|
|
11
|
-
const
|
|
16
|
+
const STATUSES = [
|
|
17
|
+
{key: '', label: 'All'},
|
|
18
|
+
{key: 'published', label: 'Published'},
|
|
19
|
+
{key: 'draft', label: 'Drafts'},
|
|
20
|
+
{key: 'scheduled', label: 'Scheduled'}
|
|
21
|
+
];
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
/**
|
|
24
|
+
* One post row - the body of the rows `data-each`. Here, not in blog.html,
|
|
25
|
+
* because the router renders a templateUrl through `_.render` first, which
|
|
26
|
+
* would replace every `{{…}}` with nothing. Mustache escapes its values.
|
|
27
|
+
*/
|
|
28
|
+
const ROW_TEMPLATE = `
|
|
29
|
+
<div class="blog-row" data-id="{{id}}" tabindex="0">
|
|
30
|
+
<span class="blog-row-main">
|
|
31
|
+
<strong class="blog-row-title">{{title}}</strong>
|
|
32
|
+
<small class="blog-row-sub">{{sub}}</small>
|
|
33
|
+
</span>
|
|
34
|
+
<span class="badge {{badge}}">{{status}}</span>
|
|
35
|
+
<span class="blog-row-cats">{{cats}}</span>
|
|
36
|
+
<time class="blog-row-date" title="{{dateTitle}}">{{date}}</time>
|
|
37
|
+
<button type="button" class="blog-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
38
|
+
</div>`;
|
|
39
|
+
|
|
40
|
+
const CHIP_TEMPLATE = `<button type="button" class="blog-chip" data-status="{{key}}">{{label}} <b>{{count}}</b></button>`;
|
|
41
|
+
|
|
42
|
+
const BADGE = {draft: 'badge-secondary', scheduled: 'badge-warning', published: 'badge-success'};
|
|
18
43
|
|
|
19
44
|
export const blogView = {
|
|
20
45
|
templateUrl: '/plugins/blog/admin/templates/blog.html',
|
|
21
46
|
|
|
22
47
|
async onMount($container) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
function buildTable(data) {
|
|
28
|
-
const tableEl = $container.find('#posts-table').get(0);
|
|
29
|
-
if (!tableEl) return;
|
|
30
|
-
|
|
31
|
-
if (postsTable) {
|
|
32
|
-
postsTable.setData(data);
|
|
33
|
-
Domma.icons.scan(tableEl);
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
48
|
+
// Not awaited: the cog is chrome, and the screen should not wait on it.
|
|
49
|
+
import(`./settings-cog.js?v=${ASSET_VERSION}`)
|
|
50
|
+
.then(m => m.addSettingsCog($container, '.blog-bar'))
|
|
51
|
+
.catch(() => { /* no cog is better than no screen */ });
|
|
36
52
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
columns: [
|
|
42
|
-
{
|
|
43
|
-
key: 'title',
|
|
44
|
-
title: 'Title',
|
|
45
|
-
sortable: true,
|
|
46
|
-
render: (v, row) =>
|
|
47
|
-
`<a href="#/plugins/blog/posts/${escapeHtml(row.id)}" class="blog-title-link">${escapeHtml(v || 'Untitled')}</a>`
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
key: 'status',
|
|
51
|
-
title: 'Status',
|
|
52
|
-
sortable: true,
|
|
53
|
-
render: (v) => {
|
|
54
|
-
const cls = STATUS_BADGE[v] || 'badge-secondary';
|
|
55
|
-
return `<span class="badge ${cls}">${escapeHtml(v ?? 'draft')}</span>`;
|
|
56
|
-
}
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
key: 'categories',
|
|
60
|
-
title: 'Categories',
|
|
61
|
-
sortable: false,
|
|
62
|
-
render: (val) => Array.isArray(val) && val.length
|
|
63
|
-
? val.map((id) => `<span class="badge badge-secondary mr-1">${escapeHtml(catNameById(id) || id)}</span>`).join('')
|
|
64
|
-
: '<span class="blog-empty-cell">-</span>'
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
key: 'publishedAt',
|
|
68
|
-
title: 'Published',
|
|
69
|
-
sortable: true,
|
|
70
|
-
render: (v) => v
|
|
71
|
-
? `<span title="${escapeHtml(D(v).format('DD MMM YYYY HH:mm'))}">${escapeHtml(D(v).format('DD MMM YYYY'))}</span>`
|
|
72
|
-
: '<span class="blog-empty-cell">-</span>'
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
key: 'id',
|
|
76
|
-
title: 'Actions',
|
|
77
|
-
sortable: false,
|
|
78
|
-
render: (id, row) => {
|
|
79
|
-
const slug = escapeHtml(row.slug || '');
|
|
80
|
-
const viewLink = row.status === 'published' && slug
|
|
81
|
-
? `<a href="/blog/${slug}" target="_blank" class="btn btn-sm btn-outline" title="View on site"><span data-icon="external-link" data-icon-size="14"></span></a>`
|
|
82
|
-
: '';
|
|
83
|
-
return `<div class="blog-row-actions">` +
|
|
84
|
-
`<button class="btn btn-sm btn-outline post-edit-btn" data-id="${escapeHtml(id)}"><span data-icon="edit" data-icon-size="14"></span></button>` +
|
|
85
|
-
viewLink +
|
|
86
|
-
`<button class="btn btn-sm btn-danger post-delete-btn" data-id="${escapeHtml(id)}"><span data-icon="trash" data-icon-size="14"></span></button>` +
|
|
87
|
-
`</div>`;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
],
|
|
91
|
-
emptyMessage: 'No posts found.'
|
|
92
|
-
});
|
|
53
|
+
const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
|
|
54
|
+
const {BASE} = kit;
|
|
55
|
+
const add = kit.viewScope('posts', /^#\/plugins\/blog(\/posts)?\/?$/);
|
|
56
|
+
const root = $container.get(0);
|
|
93
57
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
58
|
+
// ---- State ----
|
|
59
|
+
const posts = M.observable([]);
|
|
60
|
+
const categories = M.observable([]);
|
|
61
|
+
const status = M.observable('');
|
|
62
|
+
const category = M.observable(kit.takeHandOff('category') || '');
|
|
63
|
+
const query = M.observable('');
|
|
64
|
+
const sort = M.observable('newest');
|
|
65
|
+
const loading = M.observable(true);
|
|
66
|
+
let site = '/blog';
|
|
97
67
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
68
|
+
const catName = (id) => categories.peek().find(c => c.id === id)?.name ?? id;
|
|
69
|
+
const when = (p) => p.publishedAt || p.updatedAt || p.createdAt || '';
|
|
101
70
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
71
|
+
const categoryOptions = M.computed(() => categories.value.map(c => ({id: c.id, name: c.name ?? c.slug ?? c.id})));
|
|
72
|
+
|
|
73
|
+
// Plain observables fed by effects: the admin's Domma never re-renders
|
|
74
|
+
// a data-each whose source is a computed.
|
|
75
|
+
const rows = M.observable([]);
|
|
76
|
+
const chips = M.observable([]);
|
|
77
|
+
add(M.effect(() => {
|
|
78
|
+
const term = query.value.trim().toLowerCase();
|
|
79
|
+
const list = posts.value.filter(p =>
|
|
80
|
+
(!status.value || p.status === status.value)
|
|
81
|
+
&& (!category.value || (Array.isArray(p.categories) && p.categories.includes(category.value)))
|
|
82
|
+
&& (!term || [p.title, p.excerpt, p.slug].some(v => String(v ?? '').toLowerCase().includes(term))));
|
|
83
|
+
const order = {
|
|
84
|
+
newest: (a, b) => String(when(b)).localeCompare(String(when(a))),
|
|
85
|
+
oldest: (a, b) => String(when(a)).localeCompare(String(when(b))),
|
|
86
|
+
title: (a, b) => String(a.title ?? '').localeCompare(String(b.title ?? ''), undefined, {sensitivity: 'base'})
|
|
87
|
+
}[sort.value];
|
|
88
|
+
categories.value; // re-label when categories arrive
|
|
89
|
+
rows.value = [...list].sort(order).map(p => {
|
|
90
|
+
const at = when(p);
|
|
91
|
+
const label = p.status === 'published' ? 'Published' : p.status === 'scheduled' ? 'Scheduled for' : 'Updated';
|
|
92
|
+
return {
|
|
93
|
+
id: p.id,
|
|
94
|
+
title: p.title || 'Untitled',
|
|
95
|
+
sub: `${p.sample ? 'Sample · ' : ''}/${p.slug || ''}${p.excerpt ? ' · ' + String(p.excerpt).slice(0, 90) : ''}`,
|
|
96
|
+
status: p.status ?? 'draft',
|
|
97
|
+
badge: BADGE[p.status] || 'badge-secondary',
|
|
98
|
+
cats: (p.categories || []).map(catName).join(', '),
|
|
99
|
+
date: at ? D(at).format('DD MMM YYYY') : '',
|
|
100
|
+
dateTitle: at ? `${label} ${D(at).format('DD MMM YYYY HH:mm')}` : ''
|
|
101
|
+
};
|
|
115
102
|
});
|
|
103
|
+
}));
|
|
104
|
+
add(M.effect(() => {
|
|
105
|
+
const all = posts.value;
|
|
106
|
+
chips.value = STATUSES.map(s => ({
|
|
107
|
+
key: s.key || 'all', label: s.label,
|
|
108
|
+
count: s.key ? all.filter(p => (p.status ?? 'draft') === s.key).length : all.length
|
|
109
|
+
}));
|
|
110
|
+
}));
|
|
111
|
+
// The active chip, marked without re-rendering the list.
|
|
112
|
+
add(M.effect(() => {
|
|
113
|
+
const active = status.value || 'all';
|
|
114
|
+
chips.value;
|
|
115
|
+
queueMicrotask(() => root.querySelectorAll('.blog-chip').forEach(c =>
|
|
116
|
+
c.classList.toggle('is-active', c.dataset.status === active)));
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
const filtered = M.computed(() => Boolean(status.value || category.value || query.value.trim()));
|
|
120
|
+
const data = {
|
|
121
|
+
rows, chips, query, category, categoryOptions, sort, loading,
|
|
122
|
+
hasRows: M.computed(() => rows.value.length > 0),
|
|
123
|
+
isEmpty: M.computed(() => !loading.value && rows.value.length === 0),
|
|
124
|
+
emptyText: M.computed(() => filtered.value ? 'No posts match these filters.' : 'No posts yet - New post starts one, or ⋮ adds some samples to look at.'),
|
|
125
|
+
canReset: filtered
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// ---- Server ----
|
|
129
|
+
const byId = (id) => posts.peek().find(p => p.id === id);
|
|
116
130
|
|
|
117
|
-
|
|
131
|
+
async function load() {
|
|
132
|
+
loading.value = true;
|
|
133
|
+
try {
|
|
134
|
+
const [postsRes, catsRes] = await Promise.all([
|
|
135
|
+
H.get(`${BASE}/posts`),
|
|
136
|
+
H.get(`${BASE}/categories`).catch(() => [])
|
|
137
|
+
]);
|
|
138
|
+
site = await kit.sitePath();
|
|
139
|
+
// GET /posts sends a BARE ARRAY; `.data` is only for a wrapped reply.
|
|
140
|
+
posts.value = postsRes?.data ?? (Array.isArray(postsRes) ? postsRes : []);
|
|
141
|
+
categories.value = catsRes?.data ?? (Array.isArray(catsRes) ? catsRes : []);
|
|
142
|
+
} catch {
|
|
143
|
+
E.toast('Failed to load posts.', {type: 'error'});
|
|
144
|
+
} finally {
|
|
145
|
+
loading.value = false;
|
|
146
|
+
}
|
|
118
147
|
}
|
|
119
148
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
149
|
+
/** Replace one post in place, as a NEW array, from a route's reply. */
|
|
150
|
+
const upsert = (post) => {
|
|
151
|
+
const list = posts.peek();
|
|
152
|
+
posts.value = list.some(p => p.id === post.id) ? list.map(p => (p.id === post.id ? post : p)) : [post, ...list];
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
async function publish(id) {
|
|
156
|
+
try {
|
|
157
|
+
upsert(await H.post(`${BASE}/posts/${id}/publish`, {}));
|
|
158
|
+
E.toast('Post published.', {type: 'success'});
|
|
159
|
+
} catch (err) {
|
|
160
|
+
E.toast(err?.message ?? 'Failed to publish.', {type: 'error'});
|
|
126
161
|
}
|
|
127
|
-
$container.find('#kpi-total').text(counts.total);
|
|
128
|
-
$container.find('#kpi-published').text(counts.published);
|
|
129
|
-
$container.find('#kpi-drafts').text(counts.draft);
|
|
130
|
-
$container.find('#kpi-scheduled').text(counts.scheduled);
|
|
131
162
|
}
|
|
132
163
|
|
|
133
|
-
function
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (status) filtered = filtered.filter((p) => p.status === status);
|
|
140
|
-
if (cat) filtered = filtered.filter((p) => Array.isArray(p.categories) && p.categories.includes(cat));
|
|
141
|
-
if (query) {
|
|
142
|
-
filtered = filtered.filter((p) =>
|
|
143
|
-
(p.title ?? '').toLowerCase().includes(query) ||
|
|
144
|
-
(p.excerpt ?? '').toLowerCase().includes(query)
|
|
145
|
-
);
|
|
164
|
+
async function unpublish(id) {
|
|
165
|
+
try {
|
|
166
|
+
upsert(await H.put(`${BASE}/posts/${id}`, {status: 'draft'}));
|
|
167
|
+
E.toast('Post moved back to drafts.', {type: 'success'});
|
|
168
|
+
} catch (err) {
|
|
169
|
+
E.toast(err?.message ?? 'Failed to unpublish.', {type: 'error'});
|
|
146
170
|
}
|
|
147
|
-
buildTable(filtered);
|
|
148
171
|
}
|
|
149
172
|
|
|
150
|
-
function
|
|
151
|
-
const
|
|
152
|
-
if (!
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
173
|
+
async function duplicate(id) {
|
|
174
|
+
const p = byId(id);
|
|
175
|
+
if (!p) return;
|
|
176
|
+
try {
|
|
177
|
+
const copy = await H.post(`${BASE}/posts`, {
|
|
178
|
+
title: `${p.title || 'Untitled'} (copy)`,
|
|
179
|
+
slug: `${p.slug || 'post'}-copy-${Date.now().toString(36)}`,
|
|
180
|
+
content: p.content ?? '', excerpt: p.excerpt ?? '', featuredImage: p.featuredImage ?? null,
|
|
181
|
+
categories: p.categories ?? [], tags: p.tags ?? [], template: p.template ?? '', status: 'draft'
|
|
182
|
+
});
|
|
183
|
+
upsert(copy);
|
|
184
|
+
E.toast('Copied as a draft.', {type: 'success'});
|
|
185
|
+
} catch (err) {
|
|
186
|
+
E.toast(err?.message ?? 'Failed to duplicate.', {type: 'error'});
|
|
158
187
|
}
|
|
159
188
|
}
|
|
160
189
|
|
|
161
|
-
async function
|
|
190
|
+
async function remove(id) {
|
|
191
|
+
const p = byId(id);
|
|
192
|
+
// No title in the prompt: E.confirm is not promised to escape it.
|
|
193
|
+
const ok = await E.confirm(p?.status === 'published'
|
|
194
|
+
? 'Delete this published post? Its page goes offline and links to it will break.'
|
|
195
|
+
: 'Delete this post? This cannot be undone.');
|
|
196
|
+
if (!ok) return;
|
|
162
197
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
]);
|
|
167
|
-
if (postsRes.error) throw new Error(postsRes.error);
|
|
168
|
-
// GET /posts sends a BARE ARRAY, so `.data` is undefined and this
|
|
169
|
-
// read emptied the whole screen - no rows, all four KPIs zero.
|
|
170
|
-
// Same guard as categories.js and comments.js.
|
|
171
|
-
allPosts = postsRes.data ?? (Array.isArray(postsRes) ? postsRes : []);
|
|
172
|
-
categories = catsRes.data ?? catsRes ?? [];
|
|
198
|
+
await H.delete(`${BASE}/posts/${id}`);
|
|
199
|
+
posts.value = posts.peek().filter(x => x.id !== id);
|
|
200
|
+
E.toast('Post deleted.', {type: 'success'});
|
|
173
201
|
} catch {
|
|
174
|
-
|
|
175
|
-
categories = [];
|
|
176
|
-
E.toast('Failed to load posts.', { type: 'error' });
|
|
202
|
+
E.toast('Failed to delete post.', {type: 'error'});
|
|
177
203
|
}
|
|
178
|
-
refreshKpis();
|
|
179
|
-
populateCategoryFilter();
|
|
180
|
-
applyFilters();
|
|
181
204
|
}
|
|
182
205
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
206
|
+
async function copyLink(p) {
|
|
207
|
+
try {
|
|
208
|
+
await navigator.clipboard.writeText(`${location.origin}${site}/${p.slug}`);
|
|
209
|
+
E.toast('Link copied.', {type: 'success'});
|
|
210
|
+
} catch {
|
|
211
|
+
E.toast('The browser would not allow copying.', {type: 'error'});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---- Bindings ----
|
|
216
|
+
// Refs and help BEFORE binding: data-if lifts elements out of the document.
|
|
217
|
+
const list = root.querySelector('.blog-list');
|
|
218
|
+
root.querySelector('.blog-rows').innerHTML = ROW_TEMPLATE;
|
|
219
|
+
root.querySelector('.blog-chips').innerHTML = CHIP_TEMPLATE;
|
|
220
|
+
add(await kit.scanHelp(root));
|
|
221
|
+
const handle = M.applyBindings(data, root, {
|
|
222
|
+
methods: {
|
|
223
|
+
create: () => R.navigate('#/plugins/blog/posts/new'),
|
|
224
|
+
reset() {
|
|
225
|
+
status.value = '';
|
|
226
|
+
category.value = '';
|
|
227
|
+
query.value = '';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
add(() => handle.dispose());
|
|
232
|
+
|
|
233
|
+
root.querySelector('.blog-chips').addEventListener('click', (e) => {
|
|
234
|
+
const chip = e.target.closest('.blog-chip');
|
|
235
|
+
if (chip) status.value = chip.dataset.status === 'all' ? '' : chip.dataset.status;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ---- Context menu: every action on a post ----
|
|
239
|
+
const menu = E.contextMenu(list, {
|
|
240
|
+
match: '.blog-row',
|
|
241
|
+
inherit: false,
|
|
242
|
+
density: 'compact',
|
|
243
|
+
items: (row) => {
|
|
244
|
+
const p = byId(row.dataset.id);
|
|
245
|
+
if (!p) return [];
|
|
246
|
+
const live = p.status === 'published';
|
|
247
|
+
return kit.tidy([
|
|
248
|
+
{label: 'Edit', icon: 'edit', action: () => R.navigate(`#/plugins/blog/posts/${p.id}`)},
|
|
249
|
+
{label: 'View on site', icon: 'external-link', visible: live && Boolean(p.slug),
|
|
250
|
+
action: () => window.open(`${site}/${encodeURIComponent(p.slug)}`, '_blank', 'noopener')},
|
|
251
|
+
{label: 'Copy link', icon: 'link', visible: live && Boolean(p.slug), action: () => copyLink(p)},
|
|
252
|
+
{type: 'divider'},
|
|
253
|
+
{label: 'Publish now', icon: 'check-circle', visible: !live, action: () => publish(p.id)},
|
|
254
|
+
{label: 'Unpublish', icon: 'eye-off', visible: p.status !== 'draft', action: () => unpublish(p.id)},
|
|
255
|
+
{label: 'Duplicate as draft', icon: 'copy', action: () => duplicate(p.id)},
|
|
256
|
+
{type: 'divider'},
|
|
257
|
+
{label: 'Delete', icon: 'trash', danger: true, action: () => remove(p.id)}
|
|
258
|
+
]);
|
|
259
|
+
}
|
|
191
260
|
});
|
|
261
|
+
add(() => menu?.destroy?.());
|
|
192
262
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
263
|
+
// ---- The bar's ⋮: the blog as a whole ----
|
|
264
|
+
// Adding and removing samples is admin-only on the server; ask it who
|
|
265
|
+
// this is rather than trusting whatever the browser cached at login.
|
|
266
|
+
let isAdmin = false;
|
|
267
|
+
H.get('/api/auth/me')
|
|
268
|
+
.then(me => { isAdmin = ['admin', 'super-admin'].includes((me?.data ?? me)?.role); })
|
|
269
|
+
.catch(() => { /* not shown, then */ });
|
|
270
|
+
const samples = () => posts.peek().filter(p => p.sample === true).length;
|
|
271
|
+
const barMenu = E.contextMenu(root.querySelector('.blog-bar-end'), {
|
|
272
|
+
match: '.blog-more',
|
|
273
|
+
inherit: false,
|
|
274
|
+
density: 'compact',
|
|
275
|
+
items: () => kit.tidy([
|
|
276
|
+
{label: 'View the blog', icon: 'external-link', action: () => window.open(site, '_blank', 'noopener')},
|
|
277
|
+
{label: 'Edit layouts in Blocks', icon: 'layout', action: () => R.navigate('#/blocks')},
|
|
278
|
+
{type: 'divider'},
|
|
279
|
+
{label: 'Add sample posts', icon: 'file-plus', visible: isAdmin && !samples(), action: addSamples},
|
|
280
|
+
{label: `Remove ${samples()} sample post${samples() === 1 ? '' : 's'}…`, icon: 'trash', danger: true,
|
|
281
|
+
visible: isAdmin && samples() > 0, action: removeSamples}
|
|
282
|
+
])
|
|
283
|
+
});
|
|
284
|
+
add(() => barMenu?.destroy?.());
|
|
285
|
+
root.querySelector('.blog-bar-end').addEventListener('click', (e) => {
|
|
286
|
+
const more = e.target.closest('.blog-more');
|
|
287
|
+
if (more) kit.openMenuFrom(more);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
async function addSamples() {
|
|
291
|
+
try {
|
|
292
|
+
const res = await H.post(`${BASE}/samples`, {});
|
|
293
|
+
if (res?.error) throw new Error(res.error);
|
|
294
|
+
E.toast(res.added ? `Added ${res.added} sample posts. Remove them from ⋮ whenever you like.` : 'The sample posts are already here.', {type: 'success'});
|
|
295
|
+
await load();
|
|
296
|
+
} catch (err) {
|
|
297
|
+
E.toast(err?.message ?? 'Could not add the sample posts.', {type: 'error'});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function removeSamples() {
|
|
302
|
+
if (!(await E.confirm('Remove the sample posts, and any sample category nothing else uses? Your own posts are not touched.'))) return;
|
|
303
|
+
try {
|
|
304
|
+
const res = await H.delete(`${BASE}/samples`);
|
|
305
|
+
if (res?.error) throw new Error(res.error);
|
|
306
|
+
E.toast(`Removed ${res.posts} sample posts.`, {type: 'success'});
|
|
307
|
+
await load();
|
|
308
|
+
} catch (err) {
|
|
309
|
+
E.toast(err?.message ?? 'Could not remove the sample posts.', {type: 'error'});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
list.addEventListener('click', (e) => {
|
|
314
|
+
const row = e.target.closest('.blog-row');
|
|
315
|
+
if (!row) return;
|
|
316
|
+
const kebab = e.target.closest('.blog-kebab');
|
|
317
|
+
if (kebab) return kit.openMenuFrom(kebab);
|
|
318
|
+
R.navigate(`#/plugins/blog/posts/${row.dataset.id}`);
|
|
319
|
+
});
|
|
320
|
+
list.addEventListener('keydown', (e) => {
|
|
321
|
+
const row = e.target.closest?.('.blog-row');
|
|
322
|
+
if (row && e.key === 'Enter' && e.target === row) R.navigate(`#/plugins/blog/posts/${row.dataset.id}`);
|
|
197
323
|
});
|
|
198
324
|
|
|
199
|
-
|
|
200
|
-
|
|
325
|
+
if (typeof I !== 'undefined' && I.scan) I.scan(root);
|
|
326
|
+
await load();
|
|
201
327
|
}
|
|
202
328
|
};
|