domma-cms 0.55.1 → 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 +194 -3
- package/README.md +18 -14
- package/admin/css/admin.css +1 -1
- package/admin/css/contacts.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- 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/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- 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 +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- 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/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +211 -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/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- 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 +28 -24
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +6 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +27 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/server.json +3 -1
- package/package.json +14 -10
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4473 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1022 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -0
- package/plugins/blog/admin/templates/blog.html +35 -41
- 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 +63 -40
- package/plugins/blog/admin/templates/settings.html +3 -9
- package/plugins/blog/admin/views/blog.js +288 -159
- package/plugins/blog/admin/views/categories.js +212 -206
- 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 +306 -39
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -84
- 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/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +16 -13
- 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 +259 -26
- package/plugins/blog/plugin.json +21 -73
- package/plugins/blog/plugin.public.js +217 -0
- 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/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +34 -0
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +77 -4
- package/scripts/copy-domma.js +48 -0
- package/scripts/run-tests.mjs +92 -0
- package/scripts/seed.js +1996 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/contacts.js +506 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/dashboard.js +8 -2
- package/server/routes/api/forms.js +42 -3
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +198 -9
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/api/todo.js +178 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +135 -1
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/collections.js +13 -3
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +124 -10
- package/server/services/permissionRegistry.js +74 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +340 -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 +224 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +580 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/analytics/admin/templates/analytics.html +0 -61
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -57
- 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 -126
- package/plugins/contacts/admin/views/contacts.js +0 -710
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -71
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/contacts/plugin.js +0 -291
- package/plugins/contacts/plugin.json +0 -33
- 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/templates/editor.html +0 -129
- package/plugins/invoice/admin/templates/index.html +0 -43
- package/plugins/invoice/admin/templates/issuers.html +0 -5
- package/plugins/invoice/admin/templates/receivers.html +0 -5
- package/plugins/invoice/admin/views/editor.js +0 -267
- package/plugins/invoice/admin/views/index.js +0 -155
- package/plugins/invoice/admin/views/issuers.js +0 -23
- package/plugins/invoice/admin/views/party-view.js +0 -148
- package/plugins/invoice/admin/views/receivers.js +0 -22
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
- package/plugins/invoice/collections/invoices/schema.json +0 -27
- package/plugins/invoice/config.js +0 -16
- package/plugins/invoice/plugin.js +0 -283
- package/plugins/invoice/plugin.json +0 -85
- package/plugins/invoice/templates/invoice-print.html +0 -213
- 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/data/notes.json +0 -1
- package/plugins/notes/plugin.json +0 -33
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- 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/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- 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/data/todos.json +0 -1
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
|
@@ -1,199 +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)}" style="font-weight:600;">${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 style="opacity:0.4;">-</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 style="opacity:0.4;">-</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 style="display:flex;gap:.4rem;">` +
|
|
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
|
-
allPosts = postsRes.data ?? [];
|
|
169
|
-
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'});
|
|
170
201
|
} catch {
|
|
171
|
-
|
|
172
|
-
categories = [];
|
|
173
|
-
E.toast('Failed to load posts.', { type: 'error' });
|
|
202
|
+
E.toast('Failed to delete post.', {type: 'error'});
|
|
174
203
|
}
|
|
175
|
-
refreshKpis();
|
|
176
|
-
populateCategoryFilter();
|
|
177
|
-
applyFilters();
|
|
178
204
|
}
|
|
179
205
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
}
|
|
188
260
|
});
|
|
261
|
+
add(() => menu?.destroy?.());
|
|
189
262
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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}`);
|
|
194
323
|
});
|
|
195
324
|
|
|
196
|
-
|
|
197
|
-
|
|
325
|
+
if (typeof I !== 'undefined' && I.scan) I.scan(root);
|
|
326
|
+
await load();
|
|
198
327
|
}
|
|
199
328
|
};
|