domma-cms 0.55.1 → 0.66.0
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 +130 -2
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- 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/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/plugin-code.html +1 -1
- 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 +173 -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/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- 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/bin/cli.js +2 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- 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 +4453 -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 +1002 -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/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- 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 +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -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 +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- 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/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/markdown.js +70 -9
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +144 -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/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- 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/todo/data/todos.json +0 -1
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog Plugin - Public Routes
|
|
3
|
+
* SSR public-facing routes for the blog. Mounted at the Fastify root.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import {fileURLToPath} from 'url';
|
|
7
|
+
import {renderBlogPage} from '../../server/services/renderer.js';
|
|
8
|
+
import {listEntries} from '../../server/services/collections.js';
|
|
9
|
+
import {parseMarkdown} from '../../server/services/markdown.js';
|
|
10
|
+
import {getUserById} from '../../server/services/users.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
13
|
+
const TEMPLATES_DIR = path.join(__dirname, 'templates');
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
function escapeHtml(str) {
|
|
20
|
+
return String(str ?? '')
|
|
21
|
+
.replace(/&/g, '&')
|
|
22
|
+
.replace(/</g, '<')
|
|
23
|
+
.replace(/>/g, '>')
|
|
24
|
+
.replace(/"/g, '"')
|
|
25
|
+
.replace(/'/g, ''');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatDate(iso) {
|
|
29
|
+
if (!iso) return '';
|
|
30
|
+
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isVisible(post) {
|
|
34
|
+
return post.status === 'published' ||
|
|
35
|
+
(post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Always returns the actual array of entries, regardless of adapter shape. */
|
|
39
|
+
async function loadEntries(slug) {
|
|
40
|
+
const result = await listEntries(slug, { limit: 100000, sort: 'createdAt', order: 'desc' });
|
|
41
|
+
if (Array.isArray(result)) return result.map((entry) => ({ id: entry.id, ...(entry.data ?? entry) }));
|
|
42
|
+
if (Array.isArray(result?.entries)) {
|
|
43
|
+
return result.entries.map((entry) => ({ id: entry.id, ...(entry.data ?? entry) }));
|
|
44
|
+
}
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Render a post body through the full Markdown + shortcode pipeline. */
|
|
49
|
+
async function renderPostBody(content) {
|
|
50
|
+
if (!content) return { html: '', usedComponents: [] };
|
|
51
|
+
try {
|
|
52
|
+
// Blog public renderer is anonymous - pass empty opts so the [menu]
|
|
53
|
+
// shortcode treats the visitor as unauthenticated. The blog plugin
|
|
54
|
+
// does not track per-request user state for its public posts.
|
|
55
|
+
const { html, usedComponents } = await parseMarkdown('---\n---\n' + content, {});
|
|
56
|
+
return { html, usedComponents: usedComponents || [] };
|
|
57
|
+
} catch {
|
|
58
|
+
return { html: escapeHtml(content), usedComponents: [] };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildPostCard(post, authorName, base) {
|
|
63
|
+
const image = post.featuredImage
|
|
64
|
+
? `<a href="${base}/${escapeHtml(post.slug)}"><img src="${escapeHtml(post.featuredImage)}" alt="${escapeHtml(post.title)}" class="blog-card-image"></a>`
|
|
65
|
+
: '';
|
|
66
|
+
return `<article class="card mb-4 blog-card">
|
|
67
|
+
${image}
|
|
68
|
+
<div class="card-body">
|
|
69
|
+
<h2><a href="${base}/${escapeHtml(post.slug)}">${escapeHtml(post.title)}</a></h2>
|
|
70
|
+
<p class="text-muted small">by ${escapeHtml(authorName)} · ${formatDate(post.publishedAt)}</p>
|
|
71
|
+
<p>${escapeHtml(post.excerpt || '')}</p>
|
|
72
|
+
<a href="${base}/${escapeHtml(post.slug)}" class="btn btn-sm btn-primary">Read more</a>
|
|
73
|
+
</div>
|
|
74
|
+
</article>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildPagination(page, totalPages, basePath) {
|
|
78
|
+
if (totalPages <= 1) return '';
|
|
79
|
+
const parts = [];
|
|
80
|
+
if (page > 1) {
|
|
81
|
+
const prevHref = page === 2 ? basePath : `${basePath}/page/${page - 1}`;
|
|
82
|
+
parts.push(`<a href="${prevHref}" class="btn btn-sm btn-secondary mr-2">« Previous</a>`);
|
|
83
|
+
}
|
|
84
|
+
if (page < totalPages) {
|
|
85
|
+
parts.push(`<a href="${basePath}/page/${page + 1}" class="btn btn-sm btn-secondary">Next »</a>`);
|
|
86
|
+
}
|
|
87
|
+
return `<nav class="blog-pagination">${parts.join('')}</nav>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function resolveAuthorName(authorId) {
|
|
91
|
+
if (!authorId) return 'Unknown';
|
|
92
|
+
try {
|
|
93
|
+
const user = await getUserById(authorId);
|
|
94
|
+
return user?.name || user?.email || 'Unknown';
|
|
95
|
+
} catch {
|
|
96
|
+
return 'Unknown';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Plugin
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export default async function blogPublicPlugin(fastify, options) {
|
|
105
|
+
const { settings, config } = options;
|
|
106
|
+
const base = (settings?.basePath ?? '/blog').replace(/\/$/, '');
|
|
107
|
+
const postsPerPage = settings?.postsPerPage ?? 10;
|
|
108
|
+
|
|
109
|
+
async function renderArchive(reply, posts, archiveTitle, page) {
|
|
110
|
+
const visible = posts.filter(isVisible).sort((a, b) => {
|
|
111
|
+
return new Date(b.publishedAt || 0) - new Date(a.publishedAt || 0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const totalPages = Math.max(1, Math.ceil(visible.length / postsPerPage));
|
|
115
|
+
const safePage = Math.min(Math.max(1, page), totalPages);
|
|
116
|
+
const slice = visible.slice((safePage - 1) * postsPerPage, safePage * postsPerPage);
|
|
117
|
+
|
|
118
|
+
const cards = await Promise.all(slice.map(async (post) => {
|
|
119
|
+
const authorName = await resolveAuthorName(post.authorId);
|
|
120
|
+
return buildPostCard(post, authorName, base);
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
const cardsHtml = cards.join('\n') || '<p>No posts found.</p>';
|
|
124
|
+
const paginationHtml = buildPagination(safePage, totalPages, base);
|
|
125
|
+
|
|
126
|
+
const html = await renderBlogPage(
|
|
127
|
+
path.join(TEMPLATES_DIR, 'index.html'),
|
|
128
|
+
{ archiveTitle, posts: cardsHtml, pagination: paginationHtml },
|
|
129
|
+
{}
|
|
130
|
+
);
|
|
131
|
+
reply.type('text/html').send(html);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 1. RSS feed
|
|
135
|
+
fastify.get(`${base}/feed.xml`, async (request, reply) => {
|
|
136
|
+
const allPosts = await loadEntries('blog-posts');
|
|
137
|
+
const feedLength = settings?.feedLength ?? 20;
|
|
138
|
+
const posts = allPosts
|
|
139
|
+
.filter(isVisible)
|
|
140
|
+
.sort((a, b) => new Date(b.publishedAt || 0) - new Date(a.publishedAt || 0))
|
|
141
|
+
.slice(0, feedLength);
|
|
142
|
+
|
|
143
|
+
const siteTitle = config?.site?.title ?? 'Blog';
|
|
144
|
+
const siteUrl = (config?.site?.url ?? '').replace(/\/$/, '');
|
|
145
|
+
|
|
146
|
+
const items = posts.map((post) => ` <item>
|
|
147
|
+
<title>${escapeHtml(post.title)}</title>
|
|
148
|
+
<link>${siteUrl}${base}/${escapeHtml(post.slug)}</link>
|
|
149
|
+
<description>${escapeHtml(post.excerpt || '')}</description>
|
|
150
|
+
<pubDate>${new Date(post.publishedAt).toUTCString()}</pubDate>
|
|
151
|
+
<guid>${siteUrl}${base}/${escapeHtml(post.slug)}</guid>
|
|
152
|
+
</item>`).join('\n');
|
|
153
|
+
|
|
154
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
155
|
+
<rss version="2.0">
|
|
156
|
+
<channel>
|
|
157
|
+
<title>${escapeHtml(siteTitle)}</title>
|
|
158
|
+
<link>${siteUrl}${base}</link>
|
|
159
|
+
<description>Blog feed</description>
|
|
160
|
+
${items}
|
|
161
|
+
</channel>
|
|
162
|
+
</rss>`;
|
|
163
|
+
|
|
164
|
+
reply.type('application/xml').send(xml);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// 2. Paginated index
|
|
168
|
+
fastify.get(`${base}/page/:n`, async (request, reply) => {
|
|
169
|
+
const page = parseInt(request.params.n, 10) || 1;
|
|
170
|
+
const allPosts = await loadEntries('blog-posts');
|
|
171
|
+
await renderArchive(reply, allPosts, 'Blog', page);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// 3. Category archive
|
|
175
|
+
fastify.get(`${base}/category/:slug`, async (request, reply) => {
|
|
176
|
+
const { slug } = request.params;
|
|
177
|
+
const [allPosts, allCategories] = await Promise.all([
|
|
178
|
+
loadEntries('blog-posts'),
|
|
179
|
+
loadEntries('blog-categories')
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
const category = allCategories.find((c) => c.slug === slug);
|
|
183
|
+
if (!category) {
|
|
184
|
+
return reply.code(404).send('Category not found');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const posts = allPosts.filter((p) => Array.isArray(p.categories) && p.categories.includes(category.id));
|
|
188
|
+
await renderArchive(reply, posts, `Category: ${escapeHtml(category.name)}`, 1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// 4. Tag archive
|
|
192
|
+
fastify.get(`${base}/tag/:tag`, async (request, reply) => {
|
|
193
|
+
const { tag } = request.params;
|
|
194
|
+
const allPosts = await loadEntries('blog-posts');
|
|
195
|
+
const posts = allPosts.filter((p) => Array.isArray(p.tags) && p.tags.includes(tag));
|
|
196
|
+
await renderArchive(reply, posts, `Tag: ${escapeHtml(tag)}`, 1);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// 5. Author archive
|
|
200
|
+
fastify.get(`${base}/author/:id`, async (request, reply) => {
|
|
201
|
+
const { id } = request.params;
|
|
202
|
+
const allPosts = await loadEntries('blog-posts');
|
|
203
|
+
const posts = allPosts.filter((p) => p.authorId === id);
|
|
204
|
+
const authorName = await resolveAuthorName(id);
|
|
205
|
+
await renderArchive(reply, posts, `Author: ${escapeHtml(authorName)}`, 1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// 6. Blog index
|
|
209
|
+
fastify.get(base, async (request, reply) => {
|
|
210
|
+
const allPosts = await loadEntries('blog-posts');
|
|
211
|
+
await renderArchive(reply, allPosts, 'Blog', 1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// 7. Individual post - registered LAST so the slug match doesn't shadow archives
|
|
215
|
+
fastify.get(`${base}/:slug`, async (request, reply) => {
|
|
216
|
+
const { slug } = request.params;
|
|
217
|
+
const allPosts = await loadEntries('blog-posts');
|
|
218
|
+
const post = allPosts.find((p) => p.slug === slug);
|
|
219
|
+
|
|
220
|
+
if (!post || !isVisible(post)) {
|
|
221
|
+
return reply.code(404).send('Not found');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const authorName = await resolveAuthorName(post.authorId);
|
|
225
|
+
|
|
226
|
+
let categoriesHtml = '';
|
|
227
|
+
if (Array.isArray(post.categories) && post.categories.length) {
|
|
228
|
+
try {
|
|
229
|
+
const allCategories = await loadEntries('blog-categories');
|
|
230
|
+
const catMap = Object.fromEntries(allCategories.map((c) => [c.id, { name: c.name, slug: c.slug }]));
|
|
231
|
+
categoriesHtml = post.categories
|
|
232
|
+
.map((id) => catMap[id]
|
|
233
|
+
? `<a href="${base}/category/${escapeHtml(catMap[id].slug)}" class="badge badge-secondary mr-1">${escapeHtml(catMap[id].name)}</a>`
|
|
234
|
+
: '')
|
|
235
|
+
.join('');
|
|
236
|
+
} catch {
|
|
237
|
+
// non-fatal
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let tagsHtml = '';
|
|
242
|
+
if (Array.isArray(post.tags) && post.tags.length) {
|
|
243
|
+
tagsHtml = post.tags
|
|
244
|
+
.map((tag) => `<a href="${base}/tag/${escapeHtml(tag)}" class="badge badge-secondary mr-1">${escapeHtml(tag)}</a>`)
|
|
245
|
+
.join('');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const featuredImageHtml = post.featuredImage
|
|
249
|
+
? `<img src="${escapeHtml(post.featuredImage)}" alt="${escapeHtml(post.title)}" class="blog-post-image mb-4">`
|
|
250
|
+
: '';
|
|
251
|
+
|
|
252
|
+
const { html: bodyHtml, usedComponents } = await renderPostBody(post.content);
|
|
253
|
+
|
|
254
|
+
let commentsHtml = '';
|
|
255
|
+
let commentFormHtml = '';
|
|
256
|
+
if (settings?.commentsEnabled) {
|
|
257
|
+
try {
|
|
258
|
+
const allComments = await loadEntries('blog-comments');
|
|
259
|
+
const approved = allComments.filter((c) => c.postId === post.id && c.status === 'approved');
|
|
260
|
+
if (approved.length) {
|
|
261
|
+
const items = approved.map((c) => `<li class="mb-3">
|
|
262
|
+
<strong>${escapeHtml(c.authorName)}</strong> <span class="text-muted small">${formatDate(c.createdAt)}</span>
|
|
263
|
+
<p class="mt-1">${escapeHtml(c.body)}</p>
|
|
264
|
+
</li>`).join('\n');
|
|
265
|
+
commentsHtml = `<h4 class="mb-3">Comments</h4><ul class="list-unstyled">${items}</ul>`;
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
// non-fatal
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
commentFormHtml = `<div class="card mt-4">
|
|
272
|
+
<div class="card-header"><h4>Leave a comment</h4></div>
|
|
273
|
+
<div class="card-body">
|
|
274
|
+
<form method="POST" action="/api/plugins/blog/comments">
|
|
275
|
+
<input type="hidden" name="postId" value="${escapeHtml(post.id)}">
|
|
276
|
+
<div class="mb-3"><label>Name *<br><input type="text" name="authorName" required class="form-control"></label></div>
|
|
277
|
+
<div class="mb-3"><label>Email *<br><input type="email" name="authorEmail" required class="form-control"></label></div>
|
|
278
|
+
<div class="mb-3" style="display:none"><label>Website<br><input type="text" name="website"></label></div>
|
|
279
|
+
<div class="mb-3"><label>Comment *<br><textarea name="body" required class="form-control" rows="4"></textarea></label></div>
|
|
280
|
+
<button type="submit" class="btn btn-primary">Submit</button>
|
|
281
|
+
</form>
|
|
282
|
+
</div>
|
|
283
|
+
</div>`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const seoMeta = {
|
|
287
|
+
title: post.seo?.title || post.title,
|
|
288
|
+
description: post.seo?.description || post.excerpt || '',
|
|
289
|
+
ogImage: post.featuredImage || '',
|
|
290
|
+
ogType: 'article',
|
|
291
|
+
usedComponents
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const html = await renderBlogPage(
|
|
295
|
+
path.join(TEMPLATES_DIR, 'post.html'),
|
|
296
|
+
{
|
|
297
|
+
title: escapeHtml(post.title),
|
|
298
|
+
authorName: escapeHtml(authorName),
|
|
299
|
+
publishedAt: formatDate(post.publishedAt),
|
|
300
|
+
categories: categoriesHtml,
|
|
301
|
+
tags: tagsHtml,
|
|
302
|
+
featuredImage: featuredImageHtml,
|
|
303
|
+
content: bodyHtml,
|
|
304
|
+
comments: commentsHtml,
|
|
305
|
+
commentForm: commentFormHtml
|
|
306
|
+
},
|
|
307
|
+
seoMeta
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
reply.type('text/html').send(html);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.contacts-billing{margin-top:1rem;border-top:1px solid var(--dm-border);padding-top:.75rem}.contacts-billing-toggle{display:flex;align-items:center;gap:.5rem;width:100%;padding:.35rem 0;background:none;border:0;color:inherit;font:inherit;text-align:left;cursor:pointer}.contacts-billing-toggle:hover{color:var(--dm-primary)}.contacts-billing-body{display:none;padding-top:.75rem}.contacts-billing-body.is-open{display:block}.contacts-field-grid{gap:1rem}.contacts-terms-input{width:6rem}.contacts-repeater{margin-top:1.25rem;padding-top:.75rem;border-top:1px dashed var(--dm-border)}.contacts-repeater-head{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:.5rem}.contacts-empty-row{margin:.25rem 0 0}.contacts-row{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;margin-bottom:.5rem}.contacts-row .form-input{flex:1 1 8rem;min-width:0}.contacts-row-wide{flex:2 1 14rem!important}.contacts-row-check{flex:0 0 auto;padding:0;white-space:nowrap}.contacts-row-remove{flex:0 0 auto;font-size:1.1em;line-height:1;padding:.15rem .5rem;min-width:1.9rem}.contacts-billing-toggle [data-icon],.contacts-billing-toggle svg,#groups-panel-toggle [data-icon],#groups-panel-toggle svg{transition:transform .15s ease}.contacts-billing-toggle.is-open [data-icon],.contacts-billing-toggle.is-open svg{transform:rotate(90deg)}#groups-panel-toggle.is-open [data-icon],#groups-panel-toggle.is-open svg{transform:rotate(180deg)}
|
|
@@ -100,6 +100,134 @@
|
|
|
100
100
|
</label>
|
|
101
101
|
</div>
|
|
102
102
|
</div>
|
|
103
|
+
<!-- Everything an invoice needs. Folded away because most contacts
|
|
104
|
+
are a name and a number, and the day you need a VAT number you
|
|
105
|
+
know where to look. -->
|
|
106
|
+
<div class="contacts-billing">
|
|
107
|
+
<button type="button" id="billing-toggle" class="contacts-billing-toggle">
|
|
108
|
+
<span data-icon="chevron-right" id="billing-chevron"></span>
|
|
109
|
+
<strong>Company, addresses & invoicing</strong>
|
|
110
|
+
<span class="text-muted text-sm" id="billing-summary"></span>
|
|
111
|
+
</button>
|
|
112
|
+
|
|
113
|
+
<div id="billing-body" class="contacts-billing-body">
|
|
114
|
+
<div class="grid grid-cols-2 contacts-field-grid">
|
|
115
|
+
<div class="form-group">
|
|
116
|
+
<label for="contact-company" class="form-label">Company</label>
|
|
117
|
+
<input id="contact-company" type="text" class="form-input" placeholder="Acme Ltd">
|
|
118
|
+
</div>
|
|
119
|
+
<div class="form-group">
|
|
120
|
+
<label for="contact-job-title" class="form-label">Job Title</label>
|
|
121
|
+
<input id="contact-job-title" type="text" class="form-input" placeholder="Finance Manager">
|
|
122
|
+
</div>
|
|
123
|
+
<div class="form-group">
|
|
124
|
+
<label for="contact-website" class="form-label">Website</label>
|
|
125
|
+
<input id="contact-website" type="text" class="form-input" placeholder="acme.co.uk">
|
|
126
|
+
</div>
|
|
127
|
+
<div class="form-group">
|
|
128
|
+
<label for="contact-billing-email" class="form-label">Billing Email</label>
|
|
129
|
+
<input id="contact-billing-email" type="email" class="form-input" placeholder="accounts@acme.co.uk">
|
|
130
|
+
<div class="text-muted text-sm">Where invoices go, if not the email above.</div>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="form-group">
|
|
133
|
+
<label for="contact-vat-number" class="form-label">VAT Number</label>
|
|
134
|
+
<input id="contact-vat-number" type="text" class="form-input font-mono" placeholder="GB123456789">
|
|
135
|
+
</div>
|
|
136
|
+
<div class="form-group">
|
|
137
|
+
<label for="contact-company-number" class="form-label">Company Number</label>
|
|
138
|
+
<input id="contact-company-number" type="text" class="form-input font-mono" placeholder="01234567">
|
|
139
|
+
</div>
|
|
140
|
+
<div class="form-group">
|
|
141
|
+
<label for="contact-reference" class="form-label">Their Reference</label>
|
|
142
|
+
<input id="contact-reference" type="text" class="form-input" placeholder="PO-4471">
|
|
143
|
+
<div class="text-muted text-sm">Printed on their invoices, if they need one.</div>
|
|
144
|
+
</div>
|
|
145
|
+
<div class="form-group">
|
|
146
|
+
<label for="contact-payment-terms" class="form-label">Payment Terms</label>
|
|
147
|
+
<div class="flex items-center gap-2">
|
|
148
|
+
<input id="contact-payment-terms" type="number" min="0" max="365" class="form-input contacts-terms-input" placeholder="30">
|
|
149
|
+
<span class="text-muted text-sm">days</span>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="form-group">
|
|
153
|
+
<label for="contact-currency" class="form-label">Preferred Currency</label>
|
|
154
|
+
<select id="contact-currency" class="form-select">
|
|
155
|
+
<option value="">Use the invoice default</option>
|
|
156
|
+
<option value="GBP">GBP - British Pound</option>
|
|
157
|
+
<option value="EUR">EUR - Euro</option>
|
|
158
|
+
<option value="USD">USD - US Dollar</option>
|
|
159
|
+
</select>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div class="contacts-repeater">
|
|
164
|
+
<div class="contacts-repeater-head">
|
|
165
|
+
<strong>Bank Details</strong>
|
|
166
|
+
<span class="text-muted text-sm">Printed on an invoice so it can be paid.</span>
|
|
167
|
+
</div>
|
|
168
|
+
<div class="grid grid-cols-2 contacts-field-grid">
|
|
169
|
+
<div class="form-group">
|
|
170
|
+
<label for="contact-account-name" class="form-label">Account Name</label>
|
|
171
|
+
<input id="contact-account-name" type="text" class="form-input" placeholder="Acme Ltd">
|
|
172
|
+
</div>
|
|
173
|
+
<div class="form-group">
|
|
174
|
+
<label for="contact-bank-name" class="form-label">Bank</label>
|
|
175
|
+
<input id="contact-bank-name" type="text" class="form-input" placeholder="Barclays">
|
|
176
|
+
</div>
|
|
177
|
+
<div class="form-group">
|
|
178
|
+
<label for="contact-sort-code" class="form-label">Sort Code</label>
|
|
179
|
+
<input id="contact-sort-code" type="text" class="form-input font-mono" placeholder="20-00-00" maxlength="8">
|
|
180
|
+
</div>
|
|
181
|
+
<div class="form-group">
|
|
182
|
+
<label for="contact-account-number" class="form-label">Account Number</label>
|
|
183
|
+
<input id="contact-account-number" type="text" class="form-input font-mono" placeholder="12345678" maxlength="8">
|
|
184
|
+
</div>
|
|
185
|
+
<div class="form-group">
|
|
186
|
+
<label for="contact-iban" class="form-label">IBAN</label>
|
|
187
|
+
<input id="contact-iban" type="text" class="form-input font-mono" placeholder="GB29 NWBK 6016 1331 9268 19">
|
|
188
|
+
</div>
|
|
189
|
+
<div class="form-group">
|
|
190
|
+
<label for="contact-bic" class="form-label">BIC / SWIFT</label>
|
|
191
|
+
<input id="contact-bic" type="text" class="form-input font-mono" placeholder="NWBKGB2L">
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div class="contacts-repeater" id="addresses-repeater">
|
|
197
|
+
<div class="contacts-repeater-head">
|
|
198
|
+
<strong>Addresses</strong>
|
|
199
|
+
<button type="button" class="btn btn-sm btn-outline" id="add-address-btn">
|
|
200
|
+
<span data-icon="plus"></span> Add Address
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
<div id="addresses-list"></div>
|
|
204
|
+
<p class="text-muted text-sm contacts-empty-row" id="addresses-empty">No address yet.</p>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div class="contacts-repeater" id="emails-repeater">
|
|
208
|
+
<div class="contacts-repeater-head">
|
|
209
|
+
<strong>Additional Emails</strong>
|
|
210
|
+
<button type="button" class="btn btn-sm btn-outline" id="add-email-btn">
|
|
211
|
+
<span data-icon="plus"></span> Add Email
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
<div id="emails-list"></div>
|
|
215
|
+
<p class="text-muted text-sm contacts-empty-row" id="emails-empty">None.</p>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div class="contacts-repeater" id="phones-repeater">
|
|
219
|
+
<div class="contacts-repeater-head">
|
|
220
|
+
<strong>Additional Phones</strong>
|
|
221
|
+
<button type="button" class="btn btn-sm btn-outline" id="add-phone-btn">
|
|
222
|
+
<span data-icon="plus"></span> Add Phone
|
|
223
|
+
</button>
|
|
224
|
+
</div>
|
|
225
|
+
<div id="phones-list"></div>
|
|
226
|
+
<p class="text-muted text-sm contacts-empty-row" id="phones-empty">None.</p>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
103
231
|
<div style="display:flex;gap:0.5rem;margin-top:1rem;">
|
|
104
232
|
<button id="editor-save-btn" class="btn btn-primary">
|
|
105
233
|
<span data-icon="save"></span>
|