domma-cms 0.1.0 → 0.2.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.
Files changed (87) hide show
  1. package/admin/css/admin.css +78 -1
  2. package/admin/js/api.js +32 -0
  3. package/admin/js/app.js +24 -7
  4. package/admin/js/config/sidebar-config.js +8 -0
  5. package/admin/js/templates/collection-editor.html +80 -0
  6. package/admin/js/templates/collection-entries.html +36 -0
  7. package/admin/js/templates/collections.html +12 -0
  8. package/admin/js/templates/documentation.html +136 -0
  9. package/admin/js/templates/navigation.html +26 -4
  10. package/admin/js/templates/page-editor.html +91 -85
  11. package/admin/js/templates/settings.html +433 -172
  12. package/admin/js/views/collection-editor.js +487 -0
  13. package/admin/js/views/collection-entries.js +484 -0
  14. package/admin/js/views/collections.js +153 -0
  15. package/admin/js/views/dashboard.js +14 -6
  16. package/admin/js/views/index.js +9 -3
  17. package/admin/js/views/login.js +3 -2
  18. package/admin/js/views/navigation.js +77 -11
  19. package/admin/js/views/page-editor.js +207 -25
  20. package/admin/js/views/pages.js +14 -6
  21. package/admin/js/views/settings.js +137 -2
  22. package/admin/js/views/users.js +10 -7
  23. package/bin/cli.js +37 -10
  24. package/config/auth.json +2 -1
  25. package/config/content.json +1 -0
  26. package/config/navigation.json +14 -4
  27. package/config/plugins.json +0 -18
  28. package/config/presets.json +4 -8
  29. package/config/site.json +44 -3
  30. package/package.json +6 -2
  31. package/plugins/domma-effects/admin/templates/domma-effects.html +92 -3
  32. package/plugins/domma-effects/plugin.js +125 -0
  33. package/plugins/domma-effects/public/inject-body.html +19 -0
  34. package/plugins/example-analytics/admin/views/analytics.js +2 -2
  35. package/plugins/example-analytics/plugin.json +8 -0
  36. package/plugins/example-analytics/stats.json +15 -1
  37. package/plugins/form-builder/admin/templates/form-editor.html +19 -6
  38. package/plugins/form-builder/admin/views/form-editor.js +634 -9
  39. package/plugins/form-builder/admin/views/form-submissions.js +4 -4
  40. package/plugins/form-builder/admin/views/forms-list.js +5 -5
  41. package/plugins/form-builder/data/forms/consent.json +104 -0
  42. package/plugins/form-builder/data/forms/contacts.json +66 -0
  43. package/plugins/form-builder/data/submissions/consent.json +13 -0
  44. package/plugins/form-builder/data/submissions/contacts.json +26 -0
  45. package/plugins/form-builder/plugin.js +62 -11
  46. package/plugins/form-builder/plugin.json +12 -16
  47. package/plugins/form-builder/public/form-logic-engine.js +568 -0
  48. package/plugins/form-builder/public/inject-body.html +88 -6
  49. package/plugins/form-builder/public/inject-head.html +16 -0
  50. package/plugins/form-builder/public/package.json +1 -0
  51. package/public/css/site.css +113 -0
  52. package/public/js/btt.js +90 -0
  53. package/public/js/cookie-consent.js +61 -0
  54. package/public/js/site.js +129 -34
  55. package/scripts/build.js +129 -0
  56. package/scripts/seed.js +517 -7
  57. package/server/routes/api/collections.js +301 -0
  58. package/server/routes/api/settings.js +66 -2
  59. package/server/server.js +19 -15
  60. package/server/services/collections.js +430 -0
  61. package/server/services/content.js +11 -2
  62. package/server/services/hooks.js +109 -0
  63. package/server/services/markdown.js +500 -149
  64. package/server/services/plugins.js +6 -1
  65. package/server/services/renderer.js +73 -7
  66. package/server/templates/page.html +38 -3
  67. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +0 -55
  68. package/plugins/back-to-top/admin/views/back-to-top-settings.js +0 -44
  69. package/plugins/back-to-top/config.js +0 -10
  70. package/plugins/back-to-top/plugin.js +0 -24
  71. package/plugins/back-to-top/plugin.json +0 -36
  72. package/plugins/back-to-top/public/inject-body.html +0 -105
  73. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +0 -113
  74. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +0 -73
  75. package/plugins/cookie-consent/config.js +0 -30
  76. package/plugins/cookie-consent/plugin.js +0 -24
  77. package/plugins/cookie-consent/plugin.json +0 -36
  78. package/plugins/cookie-consent/public/inject-body.html +0 -69
  79. package/plugins/custom-css/admin/templates/custom-css.html +0 -17
  80. package/plugins/custom-css/admin/views/custom-css.js +0 -35
  81. package/plugins/custom-css/config.js +0 -1
  82. package/plugins/custom-css/data/custom.css +0 -0
  83. package/plugins/custom-css/plugin.js +0 -63
  84. package/plugins/custom-css/plugin.json +0 -32
  85. package/plugins/custom-css/public/inject-head.html +0 -1
  86. package/plugins/form-builder/data/forms/contact.json +0 -52
  87. package/plugins/form-builder/data/submissions/contact.json +0 -14
@@ -15,6 +15,9 @@ import {userEditorView} from './user-editor.js';
15
15
  import {pluginsView} from './plugins.js';
16
16
  import {documentationView} from './documentation.js';
17
17
  import {tutorialsView} from './tutorials.js';
18
+ import {collectionsView} from './collections.js';
19
+ import {collectionEditorView} from './collection-editor.js';
20
+ import {collectionEntriesView} from './collection-entries.js';
18
21
 
19
22
  export const views = {
20
23
  dashboard: dashboardView,
@@ -27,7 +30,10 @@ export const views = {
27
30
  login: loginView,
28
31
  users: usersView,
29
32
  userEditor: userEditorView,
30
- plugins: pluginsView,
31
- documentation: documentationView,
32
- tutorials: tutorialsView
33
+ plugins: pluginsView,
34
+ documentation: documentationView,
35
+ tutorials: tutorialsView,
36
+ collections: collectionsView,
37
+ collectionEditor: collectionEditorView,
38
+ collectionEntries: collectionEntriesView
33
39
  };
@@ -6,7 +6,7 @@
6
6
  * - needsSetup = true → show setup form → onboarding wizard (site → theme → done)
7
7
  * - needsSetup = false → show login form → redirect to dashboard
8
8
  */
9
- import { api, setAuthData, isAuthenticated } from '../api.js';
9
+ import {api, isAuthenticated, setAuthData} from '../api.js';
10
10
 
11
11
  // ---------------------------------------------------------------------------
12
12
  // Blueprints
@@ -65,7 +65,8 @@ const THEMES = [
65
65
  { id: 'lemon-light', label: 'Lemon', dark: false, primary: '#d4ac0d', bg: '#fffef0' },
66
66
  { id: 'silver-dark', label: 'Silver', dark: true, primary: '#95a5a6', bg: '#1c1e20' },
67
67
  { id: 'silver-light', label: 'Silver', dark: false, primary: '#7f8c8d', bg: '#f2f3f4' },
68
- { id: 'grayve', label: 'Grayve', dark: true, primary: '#aaaaaa', bg: '#111111' },
68
+ {id: 'grayve-dark', label: 'Grayve', dark: true, primary: '#00bcd4', bg: '#1a1e21'},
69
+ {id: 'grayve-light', label: 'Grayve', dark: false, primary: '#00838f', bg: '#ffffff'},
69
70
  ];
70
71
 
71
72
  export const loginView = {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Navigation View
3
3
  */
4
- import { api } from '../api.js';
4
+ import {api} from '../api.js';
5
5
 
6
6
  let _nextId = 1;
7
7
 
@@ -46,8 +46,12 @@ export const navigationView = {
46
46
  templateUrl: '/admin/js/templates/navigation.html',
47
47
 
48
48
  async onMount($container) {
49
- let nav = await api.navigation.get().catch(() => ({ brand: {}, items: [] }));
49
+ let [nav, site] = await Promise.all([
50
+ api.navigation.get().catch(() => ({brand: {}, items: []})),
51
+ api.settings.get().catch(() => ({}))
52
+ ]);
50
53
  let flatItems = flattenItems(nav.items);
54
+ let footerLinks = (site.footer?.links || []).map(l => ({text: l.text || '', url: l.url || ''}));
51
55
 
52
56
  const syncFromDom = () => {
53
57
  $container.find('.nav-item-row').each(function () {
@@ -98,10 +102,54 @@ export const navigationView = {
98
102
  });
99
103
  };
100
104
 
105
+ const renderFooterLinks = () => {
106
+ const $list = $container.find('#footer-links-list').empty();
107
+ footerLinks.forEach((link, idx) => {
108
+ $list.append(`
109
+ <div class="nav-item-row" data-footer-idx="${idx}">
110
+ <input type="text" class="form-input footer-link-text nav-col-main" value="${esc(link.text)}" placeholder="Label">
111
+ <input type="text" class="form-input footer-link-url nav-col-main" value="${esc(link.url)}" placeholder="/url">
112
+ <span class="nav-col-action">
113
+ <button class="btn btn-sm btn-danger btn-remove-footer" data-idx="${idx}" data-tooltip="Remove"><span data-icon="trash"></span></button>
114
+ </span>
115
+ </div>
116
+ `);
117
+ });
118
+ Domma.icons.scan('#footer-links-list');
119
+ document.querySelectorAll('#footer-links-list [data-tooltip]').forEach(el => {
120
+ E.tooltip(el, {content: el.getAttribute('data-tooltip'), position: 'top'});
121
+ });
122
+ };
123
+
124
+ const syncFooterFromDom = () => {
125
+ footerLinks = [];
126
+ $container.find('#footer-links-list .nav-item-row').each(function () {
127
+ footerLinks.push({
128
+ text: $(this).find('.footer-link-text').val().trim(),
129
+ url: $(this).find('.footer-link-url').val().trim()
130
+ });
131
+ });
132
+ };
133
+
134
+ $container.find('#add-footer-link').on('click', () => {
135
+ syncFooterFromDom();
136
+ footerLinks.push({text: '', url: ''});
137
+ renderFooterLinks();
138
+ });
139
+
140
+ $container.off('click', '.btn-remove-footer').on('click', '.btn-remove-footer', function () {
141
+ syncFooterFromDom();
142
+ const idx = parseInt($(this).data('idx'), 10);
143
+ footerLinks.splice(idx, 1);
144
+ renderFooterLinks();
145
+ });
146
+
101
147
  $container.find('#field-brand-text').val(nav.brand?.text || '');
102
148
  $container.find('#field-brand-url').val(nav.brand?.url || '/');
149
+ $container.find('#field-brand-icon').val(nav.brand?.icon || '');
103
150
  $container.find('#field-nav-variant').val(nav.variant || 'dark');
104
151
  renderItems();
152
+ renderFooterLinks();
105
153
 
106
154
  $container.find('#add-nav-item').on('click', () => {
107
155
  syncFromDom();
@@ -124,28 +172,46 @@ export const navigationView = {
124
172
 
125
173
  $container.find('#save-nav-btn').on('click', async () => {
126
174
  syncFromDom();
175
+ syncFooterFromDom();
127
176
  const nested = nestItems(
128
177
  flatItems.map(i => ({ ...i, text: i.text.trim(), url: i.url.trim(), icon: i.icon.trim() }))
129
178
  ).filter(i => i.text || i.url);
130
179
 
131
- const data = {
180
+ const brandIcon = $container.find('#field-brand-icon').val().trim();
181
+ const navData = {
132
182
  brand: {
133
183
  text: $container.find('#field-brand-text').val().trim(),
134
- url: $container.find('#field-brand-url').val().trim() || '/'
184
+ url: $container.find('#field-brand-url').val().trim() || '/',
185
+ ...(brandIcon && {icon: brandIcon})
135
186
  },
136
187
  items: nested,
137
188
  variant: $container.find('#field-nav-variant').val(),
138
189
  position: nav.position || 'sticky'
139
190
  };
140
191
 
192
+ const validFooterLinks = footerLinks.filter(l => l.text || l.url);
193
+
141
194
  try {
142
- await api.navigation.save(data);
143
- nav = data;
144
- flatItems = flattenItems(nav.items);
145
- renderItems();
146
- E.toast('Navigation saved.', { type: 'success' });
147
- } catch {
148
- E.toast('Failed to save navigation.', { type: 'error' });
195
+ await api.navigation.save(navData);
196
+ nav = navData;
197
+ flatItems = flattenItems(nav.items);
198
+ renderItems();
199
+ E.toast('Navigation saved.', {type: 'success'});
200
+ } catch (e) {
201
+ console.error('[navigation] save failed:', e);
202
+ E.toast('Failed to save navigation.', {type: 'error'});
203
+ return;
204
+ }
205
+
206
+ // Save footer links into site config — best-effort, independent of nav save
207
+ try {
208
+ const currentSite = await api.settings.get().catch(() => ({}));
209
+ await api.settings.save({...currentSite, footer: {...(currentSite.footer || {}), links: validFooterLinks}});
210
+ footerLinks = validFooterLinks;
211
+ renderFooterLinks();
212
+ } catch (e) {
213
+ console.error('[navigation] footer links save failed:', e);
214
+ E.toast('Footer links could not be saved.', {type: 'warning'});
149
215
  }
150
216
  });
151
217
  }
@@ -170,6 +170,95 @@ function openEditorHelp() {
170
170
 
171
171
  wrap.appendChild(section3);
172
172
 
173
+ // --- Section 3c: Hero shortcode ---
174
+ const section3c = document.createElement('div');
175
+ section3c.appendChild(makeH3('Hero'));
176
+ section3c.appendChild(makeNote('Full-width hero sections — no plugin required. Uses Domma\'s built-in Hero CSS component.'));
177
+
178
+ const heroAttrTable = document.createElement('table');
179
+ heroAttrTable.style.cssText = 'width:100%;border-collapse:collapse;font-size:.85em;margin-bottom:.5rem;';
180
+ [
181
+ ['title="..."', 'Heading text'],
182
+ ['tagline="..."', 'Subtitle text'],
183
+ ['size="sm|lg|full"', 'Height variant (default: normal)'],
184
+ ['variant="dark|primary|gradient-blue|gradient-purple|gradient-sunset|gradient-ocean"', 'Colour / gradient preset'],
185
+ ['image="url"', 'Background image URL (adds cover mode)'],
186
+ ['overlay="light|dark|darker|gradient|gradient-reverse"', 'Image overlay style'],
187
+ ['align="center|left"', 'Content alignment (default: center)'],
188
+ ['fullwidth="true"', 'Break out of the page container to span full viewport width'],
189
+ ['class="..."', 'Extra CSS classes'],
190
+ ['id="..."', 'Element id'],
191
+ ].forEach(([attr, desc]) => {
192
+ const tr = document.createElement('tr');
193
+ const tdCode = document.createElement('td');
194
+ tdCode.style.cssText = 'padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;';
195
+ tdCode.appendChild(makeCode(attr));
196
+ const tdDesc = document.createElement('td');
197
+ tdDesc.style.cssText = 'padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;color:var(--dm-text-muted,#aaa);';
198
+ tdDesc.textContent = desc;
199
+ tr.appendChild(tdCode);
200
+ tr.appendChild(tdDesc);
201
+ heroAttrTable.appendChild(tr);
202
+ });
203
+ section3c.appendChild(heroAttrTable);
204
+
205
+ section3c.appendChild(makeSnippet(
206
+ 'Basic hero',
207
+ '[hero title="Welcome" tagline="Build something great"][/hero]',
208
+ null
209
+ ));
210
+
211
+ section3c.appendChild(makeSnippet(
212
+ 'Gradient with body content',
213
+ '[hero title="Get Started" tagline="Everything you need." size="lg" variant="gradient-blue" align="center"]\nSome introductory **Markdown** content here.\n[/hero]',
214
+ null
215
+ ));
216
+
217
+ section3c.appendChild(makeSnippet(
218
+ 'Background image with overlay',
219
+ '[hero title="Our Story" image="/media/hero.jpg" overlay="dark" size="full"][/hero]',
220
+ 'Combine image + overlay for text legibility over photos.'
221
+ ));
222
+
223
+ wrap.appendChild(section3c);
224
+
225
+ // --- Section 3b: Interactive Components ---
226
+ const section3b = document.createElement('div');
227
+ section3b.appendChild(makeH3('Interactive Components'));
228
+ section3b.appendChild(makeNote('Tabs, accordion, carousel, and countdown — no plugin required.'));
229
+
230
+ section3b.appendChild(makeSnippet(
231
+ 'Tabs',
232
+ '[tabs]\n[tab title="First"]Content **A**[/tab]\n[tab title="Second"]Content **B**[/tab]\n[/tabs]',
233
+ 'Add style="pills" for pill-style navigation.'
234
+ ));
235
+
236
+ section3b.appendChild(makeSnippet(
237
+ 'Accordion',
238
+ '[accordion]\n[item title="Question 1"]Answer here.[/item]\n[item title="Question 2"]Answer here.[/item]\n[/accordion]',
239
+ 'Add multiple="true" to allow several panels open at once.'
240
+ ));
241
+
242
+ section3b.appendChild(makeSnippet(
243
+ 'Carousel',
244
+ '[carousel autoplay="true" interval="5000"]\n[slide title="Slide 1"]Description[/slide]\n[slide image="/media/photo.jpg" title="Slide 2"]Caption[/slide]\n[/carousel]',
245
+ 'Omit autoplay for a manual carousel. loop="false" disables wrapping.'
246
+ ));
247
+
248
+ section3b.appendChild(makeSnippet(
249
+ 'Countdown (to date)',
250
+ '[countdown to="2026-12-31" format="DD:HH:mm:ss" /]',
251
+ null
252
+ ));
253
+
254
+ section3b.appendChild(makeSnippet(
255
+ 'Countdown (duration)',
256
+ '[countdown duration="300" format="mm:ss" /]',
257
+ 'duration is in seconds. format options: mm:ss · HH:mm:ss · DD:HH:mm:ss'
258
+ ));
259
+
260
+ wrap.appendChild(section3b);
261
+
173
262
  // --- Section 4: Embedding a Form ---
174
263
  const section4 = document.createElement('div');
175
264
  section4.appendChild(makeH3('Embedding a Form'));
@@ -192,7 +281,8 @@ function openEditorHelp() {
192
281
  ['[breathe]...[/breathe]', 'Continuous gentle scale loop'],
193
282
  ['[pulse]...[/pulse]', 'Repeating scale pulse'],
194
283
  ['[shake]...[/shake]', 'One-shot shake'],
195
- ['[scribe speed="50"]...[/scribe]', 'Typewriter character-by-character'],
284
+ ['[scribe speed="50"]...[/scribe]', 'Typewriter — simple mode (text typed letter by letter)'],
285
+ ['[scribe loop="true"][render]Text[/render][wait]1500[/wait][undo /][/scribe]', 'Typewriter — script mode (sequenced render/wait/undo actions)'],
196
286
  ['[scramble]...[/scramble]', 'Character-scramble reveal'],
197
287
  ['[counter to="100" /]', 'Animated number counter (self-closing)'],
198
288
  ['[ripple]...[/ripple]', 'Click-triggered ripple'],
@@ -220,17 +310,105 @@ function openEditorHelp() {
220
310
  });
221
311
  section5.appendChild(effectsTable);
222
312
 
223
- const effectsLink = document.createElement('p');
224
- effectsLink.style.cssText = 'margin:.5rem 0 0;font-size:.82em;';
225
- const a = document.createElement('a');
226
- a.href = '#/plugins/domma-effects';
227
- a.textContent = 'Full reference & examples →';
228
- a.addEventListener('click', () => so.close());
229
- effectsLink.appendChild(a);
230
- section5.appendChild(effectsLink);
313
+ section5.appendChild(makeSnippet(
314
+ 'Scribe script looping multi-phrase typewriter',
315
+ '[scribe loop="true" loop-delay="2000" delete-speed="20"]\n[render]We build things that matter.[/render]\n[wait]1200[/wait]\n[undo all="true" /]\n[render]We design for humans.[/render]\n[wait]1200[/wait]\n[undo all="true" /]\n[/scribe]',
316
+ 'Use [render]...[/render] to type text, [wait]Ms[/wait] to pause, and [undo /] to delete. loop="true" replays the sequence. Only [render], [wait], and [undo] shortcodes are parsed — plain text between actions is ignored.'
317
+ ));
318
+
319
+ section5.appendChild(makeNote('Enable the Domma Effects plugin to activate these shortcodes on your site.'));
231
320
 
232
321
  wrap.appendChild(section5);
233
322
 
323
+ // --- Section 6: Tables shortcode ---
324
+ const section6t = document.createElement('div');
325
+ section6t.appendChild(makeH3('Tables'));
326
+ section6t.appendChild(makeNote('Wrap a standard Markdown (GFM) table with Domma CSS classes and responsive horizontal scrolling.'));
327
+
328
+ const tableAttrTable = document.createElement('table');
329
+ tableAttrTable.style.cssText = 'width:100%;border-collapse:collapse;font-size:.85em;margin-bottom:.5rem;';
330
+ [
331
+ ['striped="true"', 'Alternate row background (.table-striped)'],
332
+ ['bordered="true"', 'Borders on all cells (.table-bordered)'],
333
+ ['compact="true"', 'Reduced cell padding (.table-compact)'],
334
+ ['caption="..."', 'Caption text above the table'],
335
+ ['class="..."', 'Extra CSS classes appended to .table'],
336
+ ['id="..."', 'id attribute on the <table> element'],
337
+ ].forEach(([attr, desc]) => {
338
+ const tr = document.createElement('tr');
339
+ const tdCode = document.createElement('td');
340
+ tdCode.style.cssText = 'padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;';
341
+ tdCode.appendChild(makeCode(attr));
342
+ const tdDesc = document.createElement('td');
343
+ tdDesc.style.cssText = 'padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;color:var(--dm-text-muted,#aaa);';
344
+ tdDesc.textContent = desc;
345
+ tr.appendChild(tdCode);
346
+ tr.appendChild(tdDesc);
347
+ tableAttrTable.appendChild(tr);
348
+ });
349
+ section6t.appendChild(tableAttrTable);
350
+
351
+ section6t.appendChild(makeSnippet(
352
+ 'Striped + bordered table',
353
+ '[table striped="true" bordered="true" caption="Product Pricing"]\n| Product | Price |\n| ------- | ----: |\n| Widget | $9.99 |\n| Gadget | $14.99 |\n[/table]',
354
+ 'The inner content must be a valid GFM Markdown table. The shortcode adds Domma CSS classes and wraps in a responsive scroll container.'
355
+ ));
356
+
357
+ wrap.appendChild(section6t);
358
+
359
+ // --- Section 7: Slideover shortcode ---
360
+ const section6 = document.createElement('div');
361
+ section6.appendChild(makeH3('Slideover'));
362
+ section6.appendChild(makeNote('A trigger button that opens a slide-in panel with Markdown content. No plugin required.'));
363
+
364
+ const soAttrTable = document.createElement('table');
365
+ soAttrTable.style.cssText = 'width:100%;border-collapse:collapse;font-size:.85em;margin-bottom:.5rem;';
366
+ [
367
+ ['title="..."', 'Panel header text'],
368
+ ['trigger="..."', 'Button label (default: "Open")'],
369
+ ['size="sm|md|lg"', 'Panel width (default: md)'],
370
+ ['position="right|left"', 'Slide direction (default: right)'],
371
+ ].forEach(([attr, desc]) => {
372
+ const tr = document.createElement('tr');
373
+ const tdCode = document.createElement('td');
374
+ tdCode.style.cssText = 'padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;';
375
+ tdCode.appendChild(makeCode(attr));
376
+ const tdDesc = document.createElement('td');
377
+ tdDesc.style.cssText = 'padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;color:var(--dm-text-muted,#aaa);';
378
+ tdDesc.textContent = desc;
379
+ tr.appendChild(tdCode);
380
+ tr.appendChild(tdDesc);
381
+ soAttrTable.appendChild(tr);
382
+ });
383
+ section6.appendChild(soAttrTable);
384
+
385
+ section6.appendChild(makeSnippet(
386
+ 'Example',
387
+ '[slideover title="More Info" trigger="Read more" size="md"]\n## Details\nMarkdown content here.\n[/slideover]',
388
+ 'Nested [card] and [grid] shortcodes work inside the slideover body.'
389
+ ));
390
+
391
+ wrap.appendChild(section6);
392
+
393
+ // --- Section 7: DConfig ---
394
+ const section7 = document.createElement('div');
395
+ section7.appendChild(makeH3('DConfig — Declarative Behaviour'));
396
+ section7.appendChild(makeNote('Define click handlers and class toggles without writing JavaScript. Use the DConfig section above or embed inline with [dconfig]...[/dconfig] in the content body. Inline shortcodes win on selector conflict.'));
397
+
398
+ section7.appendChild(makeSnippet(
399
+ 'Toggle a class on click',
400
+ '{ "#my-btn": { "events": { "click": { "target": "#panel", "toggleClass": "hidden" } } } }',
401
+ 'Selector keys use standard CSS selectors. "target" is optional — defaults to the element itself.'
402
+ ));
403
+
404
+ section7.appendChild(makeSnippet(
405
+ 'Inline shortcode syntax',
406
+ '[dconfig]\n{ "#my-btn": { "events": { "click": { "target": "#panel", "toggleClass": "hidden" } } } }\n[/dconfig]',
407
+ null
408
+ ));
409
+
410
+ wrap.appendChild(section7);
411
+
234
412
  so.setContent(wrap);
235
413
  so.open();
236
414
  }
@@ -238,7 +416,6 @@ function openEditorHelp() {
238
416
  // Module-level state for unsaved-changes guard
239
417
  let _dirty = false;
240
418
  let _beforeUnload = null;
241
- let _saveShortcut = null;
242
419
  let _guardRegistered = false;
243
420
 
244
421
  export const pageEditorView = {
@@ -281,6 +458,9 @@ export const pageEditorView = {
281
458
  $container.find('#field-visibility').val(page.visibility || 'public');
282
459
  $container.find('#field-seo-title').val(page.seo?.title || '');
283
460
  $container.find('#field-seo-desc').val(page.seo?.description || '');
461
+ if (page.dconfig) {
462
+ $container.find('#field-dconfig').val(JSON.stringify(page.dconfig, null, 2));
463
+ }
284
464
  }
285
465
 
286
466
  // Layouts dropdown
@@ -290,6 +470,9 @@ export const pageEditorView = {
290
470
  $layoutSelect.append(`<option value="${key}" ${selected}>${preset.label || key}</option>`);
291
471
  });
292
472
 
473
+ // Initialise meta tabs
474
+ E.tabs($container.find('#editor-meta-tabs').get(0));
475
+
293
476
  // Markdown editor + live preview
294
477
  const $editor = $container.find('#markdown-editor');
295
478
  const $preview = $container.find('#markdown-preview');
@@ -402,10 +585,6 @@ export const pageEditorView = {
402
585
  E.confirm('You have unsaved changes. Leave this page?').then(ok => {
403
586
  if (ok) {
404
587
  _dirty = false;
405
- if (_saveShortcut) {
406
- document.removeEventListener('keydown', _saveShortcut);
407
- _saveShortcut = null;
408
- }
409
588
  next();
410
589
  }
411
590
  });
@@ -423,6 +602,18 @@ export const pageEditorView = {
423
602
  const enteredPath = $container.find('#page-url-path').val().trim();
424
603
  if (!enteredPath) { E.toast('URL path is required.', { type: 'warning' }); return; }
425
604
 
605
+ // Validate DConfig JSON before save attempt
606
+ const dconfigRaw = $container.find('#field-dconfig').val().trim();
607
+ let dconfig = null;
608
+ if (dconfigRaw) {
609
+ try {
610
+ dconfig = JSON.parse(dconfigRaw);
611
+ } catch {
612
+ E.toast('DConfig JSON is invalid. Please check the format before saving.', {type: 'warning'});
613
+ return;
614
+ }
615
+ }
616
+
426
617
  const frontmatter = {
427
618
  title: $container.find('#field-title').val().trim() || 'Untitled',
428
619
  description: $container.find('#field-description').val().trim(),
@@ -436,7 +627,8 @@ export const pageEditorView = {
436
627
  seo: {
437
628
  title: $container.find('#field-seo-title').val().trim(),
438
629
  description: $container.find('#field-seo-desc').val().trim()
439
- }
630
+ },
631
+ dconfig
440
632
  };
441
633
 
442
634
  try {
@@ -461,16 +653,6 @@ export const pageEditorView = {
461
653
  }
462
654
  });
463
655
 
464
- // Ctrl+S / Cmd+S → save
465
- if (_saveShortcut) document.removeEventListener('keydown', _saveShortcut);
466
- _saveShortcut = (e) => {
467
- if ((e.ctrlKey || e.metaKey) && e.key === 's') {
468
- e.preventDefault();
469
- $container.find('#save-btn').get(0).click();
470
- }
471
- };
472
- document.addEventListener('keydown', _saveShortcut);
473
-
474
656
  // Cancel
475
657
  $container.find('#cancel-btn').on('click', () => R.navigate('/pages'));
476
658
 
@@ -14,13 +14,21 @@ export const pagesView = {
14
14
  T.create('#pages-table', {
15
15
  data,
16
16
  columns: [
17
- { key: 'title', label: 'Title', render: (val, row) => `<a href="#/pages/edit${row.urlPath}">${val}</a>` },
18
- { key: 'urlPath', label: 'URL', render: (val) => `<code>${val}</code>` },
19
- { key: 'layout', label: 'Layout' },
20
- { key: 'status', label: 'Status', render: (val) => `<span class="badge badge-${val === 'published' ? 'success' : 'warning'}">${val}</span>` },
21
- { key: 'updatedAt', label: 'Updated', render: (val) => val ? D(val).format('DD MMM YYYY') : '—' },
17
+ {
18
+ key: 'title',
19
+ title: 'Title',
20
+ render: (val, row) => `<a href="#/pages/edit${row.urlPath}">${val}</a>`
21
+ },
22
+ {key: 'urlPath', title: 'URL', render: (val) => `<code>${val}</code>`},
23
+ {key: 'layout', title: 'Layout'},
24
+ {
25
+ key: 'status',
26
+ title: 'Status',
27
+ render: (val) => `<span class="badge badge-${val === 'published' ? 'success' : 'warning'}">${val}</span>`
28
+ },
29
+ {key: 'updatedAt', title: 'Updated', render: (val) => val ? D(val).format('DD MMM YYYY') : '—'},
22
30
  {
23
- key: 'actions', label: 'Actions',
31
+ key: 'actions', title: 'Actions',
24
32
  render: (_, row) => `
25
33
  <a href="#/pages/edit${row.urlPath}" class="btn btn-sm btn-outline">Edit</a>
26
34
  <a href="${row.urlPath}" target="_blank" class="btn btn-sm btn-ghost" data-tooltip="View"><span data-icon="external-link"></span></a>