domma-cms 0.1.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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +469 -0
  3. package/admin/css/admin.css +1123 -0
  4. package/admin/index.html +72 -0
  5. package/admin/js/api.js +210 -0
  6. package/admin/js/app.js +270 -0
  7. package/admin/js/config/sidebar-config.js +107 -0
  8. package/admin/js/lib/card.js +63 -0
  9. package/admin/js/lib/image-editor.js +869 -0
  10. package/admin/js/lib/markdown-toolbar.js +421 -0
  11. package/admin/js/templates/dashboard.html +50 -0
  12. package/admin/js/templates/documentation.html +237 -0
  13. package/admin/js/templates/layouts.html +11 -0
  14. package/admin/js/templates/login.html +58 -0
  15. package/admin/js/templates/media.html +16 -0
  16. package/admin/js/templates/navigation.html +50 -0
  17. package/admin/js/templates/page-editor.html +126 -0
  18. package/admin/js/templates/pages.html +18 -0
  19. package/admin/js/templates/plugins.html +12 -0
  20. package/admin/js/templates/settings.html +190 -0
  21. package/admin/js/templates/tutorials.html +233 -0
  22. package/admin/js/templates/user-editor.html +12 -0
  23. package/admin/js/templates/users.html +10 -0
  24. package/admin/js/views/dashboard.js +48 -0
  25. package/admin/js/views/documentation.js +12 -0
  26. package/admin/js/views/index.js +33 -0
  27. package/admin/js/views/layouts.js +49 -0
  28. package/admin/js/views/login.js +254 -0
  29. package/admin/js/views/media.js +240 -0
  30. package/admin/js/views/navigation.js +152 -0
  31. package/admin/js/views/page-editor.js +479 -0
  32. package/admin/js/views/pages.js +64 -0
  33. package/admin/js/views/plugins.js +100 -0
  34. package/admin/js/views/settings.js +64 -0
  35. package/admin/js/views/tutorials.js +12 -0
  36. package/admin/js/views/user-editor.js +88 -0
  37. package/admin/js/views/users.js +73 -0
  38. package/bin/cli.js +334 -0
  39. package/config/auth.json +20 -0
  40. package/config/content.json +10 -0
  41. package/config/navigation.json +63 -0
  42. package/config/plugins.json +47 -0
  43. package/config/presets.json +34 -0
  44. package/config/server.json +6 -0
  45. package/config/site.json +33 -0
  46. package/package.json +67 -0
  47. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
  48. package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
  49. package/plugins/back-to-top/config.js +10 -0
  50. package/plugins/back-to-top/plugin.js +24 -0
  51. package/plugins/back-to-top/plugin.json +36 -0
  52. package/plugins/back-to-top/public/inject-body.html +105 -0
  53. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
  54. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
  55. package/plugins/cookie-consent/config.js +30 -0
  56. package/plugins/cookie-consent/plugin.js +24 -0
  57. package/plugins/cookie-consent/plugin.json +36 -0
  58. package/plugins/cookie-consent/public/inject-body.html +69 -0
  59. package/plugins/custom-css/admin/templates/custom-css.html +17 -0
  60. package/plugins/custom-css/admin/views/custom-css.js +35 -0
  61. package/plugins/custom-css/config.js +1 -0
  62. package/plugins/custom-css/data/custom.css +0 -0
  63. package/plugins/custom-css/plugin.js +63 -0
  64. package/plugins/custom-css/plugin.json +32 -0
  65. package/plugins/custom-css/public/inject-head.html +1 -0
  66. package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
  67. package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
  68. package/plugins/domma-effects/config.js +9 -0
  69. package/plugins/domma-effects/plugin.js +22 -0
  70. package/plugins/domma-effects/plugin.json +36 -0
  71. package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
  72. package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
  73. package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
  74. package/plugins/domma-effects/public/celebrations/index.js +535 -0
  75. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
  76. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
  77. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
  78. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
  79. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
  80. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
  81. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
  82. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
  83. package/plugins/domma-effects/public/inject-body.html +268 -0
  84. package/plugins/example-analytics/admin/templates/analytics.html +10 -0
  85. package/plugins/example-analytics/admin/views/analytics.js +51 -0
  86. package/plugins/example-analytics/config.js +6 -0
  87. package/plugins/example-analytics/plugin.js +58 -0
  88. package/plugins/example-analytics/plugin.json +27 -0
  89. package/plugins/example-analytics/public/inject-body.html +13 -0
  90. package/plugins/example-analytics/public/inject-head.html +1 -0
  91. package/plugins/example-analytics/stats.json +1 -0
  92. package/plugins/form-builder/admin/templates/form-editor.html +158 -0
  93. package/plugins/form-builder/admin/templates/form-settings.html +29 -0
  94. package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
  95. package/plugins/form-builder/admin/templates/forms-list.html +17 -0
  96. package/plugins/form-builder/admin/views/form-editor.js +817 -0
  97. package/plugins/form-builder/admin/views/form-settings.js +38 -0
  98. package/plugins/form-builder/admin/views/form-submissions.js +295 -0
  99. package/plugins/form-builder/admin/views/forms-list.js +164 -0
  100. package/plugins/form-builder/config.js +9 -0
  101. package/plugins/form-builder/data/forms/contact-details.json +63 -0
  102. package/plugins/form-builder/data/forms/contact.json +52 -0
  103. package/plugins/form-builder/data/submissions/contact-details.json +1 -0
  104. package/plugins/form-builder/data/submissions/contact.json +14 -0
  105. package/plugins/form-builder/email.js +103 -0
  106. package/plugins/form-builder/plugin.js +454 -0
  107. package/plugins/form-builder/plugin.json +56 -0
  108. package/plugins/form-builder/public/inject-body.html +270 -0
  109. package/plugins/form-builder/public/inject-head.html +42 -0
  110. package/public/css/site.css +189 -0
  111. package/public/js/site.js +109 -0
  112. package/scripts/copy-domma.js +48 -0
  113. package/scripts/fresh.js +41 -0
  114. package/scripts/reset.js +124 -0
  115. package/scripts/seed.js +666 -0
  116. package/scripts/setup.js +263 -0
  117. package/server/config.js +56 -0
  118. package/server/middleware/auth.js +97 -0
  119. package/server/routes/api/auth.js +116 -0
  120. package/server/routes/api/layouts.js +25 -0
  121. package/server/routes/api/media.js +93 -0
  122. package/server/routes/api/navigation.js +37 -0
  123. package/server/routes/api/pages.js +118 -0
  124. package/server/routes/api/plugins.js +46 -0
  125. package/server/routes/api/settings.js +25 -0
  126. package/server/routes/api/users.js +110 -0
  127. package/server/routes/public.js +108 -0
  128. package/server/server.js +169 -0
  129. package/server/services/content.js +298 -0
  130. package/server/services/images.js +334 -0
  131. package/server/services/markdown.js +297 -0
  132. package/server/services/plugins.js +246 -0
  133. package/server/services/renderer.js +80 -0
  134. package/server/services/users.js +212 -0
  135. package/server/templates/page.html +78 -0
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Page Editor View
3
+ * Handles both create (new) and edit (existing) modes.
4
+ * Provides a split-pane Markdown editor with live preview, toolbar, and media picker.
5
+ */
6
+ import {api} from '../api.js';
7
+ import {createToolbar, insertAtCursor, wrapSelection} from '../lib/markdown-toolbar.js';
8
+
9
+ /**
10
+ * Builds and opens the editor help slideover with a cheat-sheet reference.
11
+ */
12
+ function openEditorHelp() {
13
+ const so = E.slideover({ title: 'Editor Reference', size: 'md', position: 'right' });
14
+
15
+ const codeStyle = 'background:var(--dm-surface-subtle,#1a1a2e);padding:.2rem .4rem;border-radius:3px;font-size:.85em;font-family:monospace;';
16
+ const h3Style = 'margin:1rem 0 .5rem;font-size:1rem;';
17
+
18
+ function makeCode(text) {
19
+ const el = document.createElement('code');
20
+ el.style.cssText = codeStyle;
21
+ el.textContent = text;
22
+ return el;
23
+ }
24
+
25
+ function makeH3(text) {
26
+ const el = document.createElement('h3');
27
+ el.style.cssText = h3Style;
28
+ el.textContent = text;
29
+ return el;
30
+ }
31
+
32
+ const wrap = document.createElement('div');
33
+ wrap.style.cssText = 'padding:1rem;display:flex;flex-direction:column;gap:1rem;';
34
+
35
+ // --- Section 1: Markdown Basics ---
36
+ const section1 = document.createElement('div');
37
+ section1.appendChild(makeH3('Markdown Basics'));
38
+
39
+ const table = document.createElement('table');
40
+ table.style.cssText = 'width:100%;border-collapse:collapse;font-size:.9em;';
41
+
42
+ const thead = document.createElement('thead');
43
+ const headerRow = document.createElement('tr');
44
+ ['Syntax', 'Result'].forEach(text => {
45
+ const th = document.createElement('th');
46
+ th.style.cssText = 'text-align:left;padding:.4rem .5rem;border-bottom:1px solid var(--dm-border,#333);';
47
+ th.textContent = text;
48
+ headerRow.appendChild(th);
49
+ });
50
+ thead.appendChild(headerRow);
51
+ table.appendChild(thead);
52
+
53
+ const tbody = document.createElement('tbody');
54
+ const rows = [
55
+ ['**bold**', 'bold (rendered bold)'],
56
+ ['_italic_', 'italic (rendered italic)'],
57
+ ['## Heading', 'Heading (h2)'],
58
+ ['[text](url)', 'Link'],
59
+ ['![alt](url)', 'Image'],
60
+ ['- item', 'Bullet list'],
61
+ ['`code`', 'Inline code'],
62
+ ['---', 'Divider'],
63
+ ];
64
+ rows.forEach(([syntax, result]) => {
65
+ const tr = document.createElement('tr');
66
+ const tdSyntax = document.createElement('td');
67
+ tdSyntax.style.cssText = 'padding:.35rem .5rem;vertical-align:top;';
68
+ tdSyntax.appendChild(makeCode(syntax));
69
+ const tdResult = document.createElement('td');
70
+ tdResult.style.cssText = 'padding:.35rem .5rem;vertical-align:top;color:var(--dm-text-muted,#aaa);font-size:.85em;';
71
+ tdResult.textContent = result;
72
+ tr.appendChild(tdSyntax);
73
+ tr.appendChild(tdResult);
74
+ tbody.appendChild(tr);
75
+ });
76
+ table.appendChild(tbody);
77
+ section1.appendChild(table);
78
+ wrap.appendChild(section1);
79
+
80
+ // --- Section 2: Grid shortcodes ---
81
+ const section2 = document.createElement('div');
82
+ section2.appendChild(makeH3('Grid & Columns'));
83
+
84
+ function makeNote(text) {
85
+ const el = document.createElement('p');
86
+ el.style.cssText = 'margin:.3rem 0 .6rem;font-size:.82em;color:var(--dm-text-muted,#aaa);';
87
+ el.textContent = text;
88
+ return el;
89
+ }
90
+
91
+ function makeSnippet(label, code, note) {
92
+ const lbl = document.createElement('p');
93
+ lbl.style.cssText = 'margin:.75rem 0 .25rem;font-size:.85em;font-weight:600;';
94
+ lbl.textContent = label;
95
+ const pre = document.createElement('pre');
96
+ pre.style.cssText = codeStyle + 'display:block;padding:.5rem .75rem;white-space:pre;overflow-x:auto;margin:0;';
97
+ pre.textContent = code;
98
+ const frag = document.createDocumentFragment();
99
+ frag.appendChild(lbl);
100
+ frag.appendChild(pre);
101
+ if (note) frag.appendChild(makeNote(note));
102
+ return frag;
103
+ }
104
+
105
+ const gridAttrTable = document.createElement('table');
106
+ gridAttrTable.style.cssText = 'width:100%;border-collapse:collapse;font-size:.85em;margin-bottom:.5rem;';
107
+ [
108
+ ['cols="N"', '[grid] only', 'Number of columns (1–12)'],
109
+ ['gap="N"', '[grid], [row]', 'Gap between columns/rows (1–6)'],
110
+ ['span="N"', '[col] only', 'How many columns this cell spans'],
111
+ ['class="x"', 'all', 'Add extra CSS classes'],
112
+ ].forEach(([attr, where, desc]) => {
113
+ const tr = document.createElement('tr');
114
+ [attr, where, desc].forEach((txt, i) => {
115
+ const td = document.createElement('td');
116
+ td.style.cssText = 'padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;';
117
+ if (i === 0) {
118
+ td.appendChild(makeCode(attr));
119
+ } else {
120
+ td.style.color = 'var(--dm-text-muted,#aaa)';
121
+ td.textContent = txt;
122
+ }
123
+ tr.appendChild(td);
124
+ });
125
+ gridAttrTable.appendChild(tr);
126
+ });
127
+ section2.appendChild(gridAttrTable);
128
+
129
+ section2.appendChild(makeSnippet(
130
+ 'Equal columns',
131
+ '[grid cols="3" gap="4"]\n[col]One[/col]\n[col]Two[/col]\n[col]Three[/col]\n[/grid]',
132
+ 'cols sets the track count; each [col] fills one track.'
133
+ ));
134
+
135
+ section2.appendChild(makeSnippet(
136
+ 'Column spanning (12-col base)',
137
+ '[grid cols="12" gap="3"]\n[col span="8"]Main content[/col]\n[col span="4"]Sidebar[/col]\n[/grid]',
138
+ 'span values must add up to cols. Common splits: 8/4, 6/6, 9/3.'
139
+ ));
140
+
141
+ section2.appendChild(makeSnippet(
142
+ 'Row (flexbox, equal-width)',
143
+ '[row gap="4"]\n[col]Left[/col]\n[col]Right[/col]\n[/row]',
144
+ 'Use [row] when you want equal-width columns without specifying a count.'
145
+ ));
146
+
147
+ section2.appendChild(makeSnippet(
148
+ 'Card in a grid',
149
+ '[grid cols="3" gap="4"]\n[col]\n[card title="One"]Content[/card]\n[/col]\n[col]\n[card title="Two"]Content[/card]\n[/col]\n[col]\n[card title="Three"]Content[/card]\n[/col]\n[/grid]',
150
+ null
151
+ ));
152
+
153
+ wrap.appendChild(section2);
154
+
155
+ // --- Section 3: Card shortcode ---
156
+ const section3 = document.createElement('div');
157
+ section3.appendChild(makeH3('Cards'));
158
+
159
+ section3.appendChild(makeSnippet(
160
+ 'Basic card',
161
+ '[card title="Optional Title"]\nMarkdown **works** here.\n[/card]',
162
+ 'Omit title for a card with no header.'
163
+ ));
164
+
165
+ section3.appendChild(makeSnippet(
166
+ 'Collapsible card',
167
+ '[card title="Click to expand" collapsible="true"]\nHidden by default.\n[/card]',
168
+ null
169
+ ));
170
+
171
+ wrap.appendChild(section3);
172
+
173
+ // --- Section 4: Embedding a Form ---
174
+ const section4 = document.createElement('div');
175
+ section4.appendChild(makeH3('Embedding a Form'));
176
+
177
+ const formPre = document.createElement('pre');
178
+ formPre.style.cssText = codeStyle + 'display:block;padding:.5rem .75rem;white-space:pre;overflow-x:auto;';
179
+ formPre.textContent = '<div data-form="contact"></div>';
180
+ section4.appendChild(formPre);
181
+ section4.appendChild(makeNote('Replace "contact" with the form slug. Forms are managed under Forms in the sidebar.'));
182
+
183
+ wrap.appendChild(section4);
184
+
185
+ // --- Section 5: Effects shortcodes ---
186
+ const section5 = document.createElement('div');
187
+ section5.appendChild(makeH3('Effects'));
188
+ section5.appendChild(makeNote('Shortcodes for scroll-triggered animations, counters, typewriter text, and more. Requires the Domma Effects plugin.'));
189
+
190
+ const effectsRows = [
191
+ ['[reveal animation="fade"]...[/reveal]', 'Fade/slide/zoom on scroll'],
192
+ ['[breathe]...[/breathe]', 'Continuous gentle scale loop'],
193
+ ['[pulse]...[/pulse]', 'Repeating scale pulse'],
194
+ ['[shake]...[/shake]', 'One-shot shake'],
195
+ ['[scribe speed="50"]...[/scribe]', 'Typewriter character-by-character'],
196
+ ['[scramble]...[/scramble]', 'Character-scramble reveal'],
197
+ ['[counter to="100" /]', 'Animated number counter (self-closing)'],
198
+ ['[ripple]...[/ripple]', 'Click-triggered ripple'],
199
+ ['[twinkle count="50"]...[/twinkle]', 'Particle/star overlay'],
200
+ ['[animate type="fade-in-up"]...[/animate]', 'CSS-only animation (no plugin needed)'],
201
+ ['[ambient type="aurora"]...[/ambient]', 'CSS-only animated background'],
202
+ ['[firework type="burst" colour="rainbow" /]', 'CSS firework (self-closing)'],
203
+ ['[fireworks]...[/fireworks]', 'Fireworks display container'],
204
+ ['[celebrate theme="auto" /]', 'Seasonal canvas celebration'],
205
+ ];
206
+
207
+ const effectsTable = document.createElement('table');
208
+ effectsTable.style.cssText = 'width:100%;border-collapse:collapse;font-size:.85em;margin-bottom:.5rem;';
209
+ effectsRows.forEach(([syntax, desc]) => {
210
+ const tr = document.createElement('tr');
211
+ const tdCode = document.createElement('td');
212
+ tdCode.style.cssText = 'padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;';
213
+ tdCode.appendChild(makeCode(syntax));
214
+ const tdDesc = document.createElement('td');
215
+ tdDesc.style.cssText = 'padding:.3rem .4rem;border-bottom:1px solid var(--dm-border,#333);vertical-align:top;color:var(--dm-text-muted,#aaa);';
216
+ tdDesc.textContent = desc;
217
+ tr.appendChild(tdCode);
218
+ tr.appendChild(tdDesc);
219
+ effectsTable.appendChild(tr);
220
+ });
221
+ section5.appendChild(effectsTable);
222
+
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);
231
+
232
+ wrap.appendChild(section5);
233
+
234
+ so.setContent(wrap);
235
+ so.open();
236
+ }
237
+
238
+ // Module-level state for unsaved-changes guard
239
+ let _dirty = false;
240
+ let _beforeUnload = null;
241
+ let _saveShortcut = null;
242
+ let _guardRegistered = false;
243
+
244
+ export const pageEditorView = {
245
+ templateUrl: '/admin/js/templates/page-editor.html',
246
+
247
+ async onMount($container) {
248
+ const hash = window.location.hash;
249
+ const editMatch = hash.match(/#\/pages\/edit(\/.*)/); // .* allows home page path "/"
250
+ const urlPath = editMatch ? editMatch[1] : null;
251
+ const isEdit = !!urlPath;
252
+
253
+ const fetches = [api.layouts.get().catch(() => ({}))];
254
+ if (isEdit) fetches.push(api.pages.get(urlPath).catch(() => null));
255
+
256
+ const [layouts, page] = await Promise.all(fetches);
257
+
258
+ if (isEdit && !page) {
259
+ E.toast('Page not found.', { type: 'error' });
260
+ R.navigate('/pages');
261
+ return;
262
+ }
263
+
264
+ // Title
265
+ $container.find('#editor-title').text(isEdit ? 'Edit Page' : 'New Page');
266
+
267
+ // URL path field
268
+ if (isEdit) {
269
+ $container.find('#page-url-path').val(urlPath);
270
+ }
271
+
272
+ // Populate form fields
273
+ if (page) {
274
+ $container.find('#field-title').val(page.title || '');
275
+ $container.find('#field-description').val(page.description || '');
276
+ $container.find('#field-status').val(page.status || 'draft');
277
+ $container.find('#field-sort-order').val(page.sortOrder ?? 99);
278
+ $container.find('#field-show-in-nav').prop('checked', !!page.showInNav);
279
+ $container.find('#field-sidebar').prop('checked', !!page.sidebar);
280
+ $container.find('#field-category').val(page.category || '');
281
+ $container.find('#field-visibility').val(page.visibility || 'public');
282
+ $container.find('#field-seo-title').val(page.seo?.title || '');
283
+ $container.find('#field-seo-desc').val(page.seo?.description || '');
284
+ }
285
+
286
+ // Layouts dropdown
287
+ const $layoutSelect = $container.find('#field-layout').empty();
288
+ Object.entries(layouts).forEach(([key, preset]) => {
289
+ const selected = (page?.layout || 'default') === key ? 'selected' : '';
290
+ $layoutSelect.append(`<option value="${key}" ${selected}>${preset.label || key}</option>`);
291
+ });
292
+
293
+ // Markdown editor + live preview
294
+ const $editor = $container.find('#markdown-editor');
295
+ const $preview = $container.find('#markdown-preview');
296
+ if (page) $editor.val(page.content || '');
297
+
298
+ let _previewTimer = null;
299
+ const updatePreview = () => {
300
+ clearTimeout(_previewTimer);
301
+ _previewTimer = setTimeout(async () => {
302
+ try {
303
+ const {html} = await api.pages.preview($editor.val());
304
+ $preview.html(html);
305
+ Domma.icons.scan();
306
+ } catch {
307
+ if (window.marked) $preview.html(marked.parse($editor.val()));
308
+ }
309
+ }, 400);
310
+ };
311
+
312
+ // Create toolbar
313
+ const toolbar = createToolbar($editor, $container.find('#editor-toolbar'));
314
+
315
+ // Link callback: prompt for URL
316
+ toolbar.onLink((ta) => {
317
+ const url = prompt('Enter URL:');
318
+ if (url) wrapSelection(ta, '[', `](${url})`);
319
+ });
320
+
321
+ // Image callback: open media picker modal
322
+ toolbar.onImage(async (ta) => {
323
+ const files = await api.media.list().catch(() => []);
324
+ const images = files.filter(f => /\.(png|jpe?g|gif|webp|svg)$/i.test(f.name));
325
+
326
+ const grid = document.createElement('div');
327
+ grid.className = 'media-picker-grid';
328
+
329
+ if (images.length) {
330
+ images.forEach(img => {
331
+ const item = document.createElement('div');
332
+ item.className = 'media-picker-item';
333
+ item.dataset.url = img.url;
334
+
335
+ const image = document.createElement('img');
336
+ image.src = img.url;
337
+ image.alt = img.name;
338
+
339
+ const label = document.createElement('span');
340
+ label.textContent = img.name;
341
+
342
+ item.appendChild(image);
343
+ item.appendChild(label);
344
+ grid.appendChild(item);
345
+ });
346
+ } else {
347
+ const empty = document.createElement('p');
348
+ empty.className = 'text-muted p-3';
349
+ empty.textContent = 'No images uploaded yet.';
350
+ grid.appendChild(empty);
351
+ }
352
+
353
+ const modal = E.modal({title: 'Insert Image', size: 'lg'});
354
+ modal.element.appendChild(grid);
355
+
356
+ $(modal.element).on('click', '.media-picker-item', function () {
357
+ const url = $(this).data('url');
358
+ const alt = $(this).find('span').text();
359
+ insertAtCursor(ta, `![${alt}](${url})`);
360
+ modal.close();
361
+ $editor.get(0).dispatchEvent(new Event('input', {bubbles: true}));
362
+ });
363
+
364
+ modal.open();
365
+ });
366
+
367
+ // Help callback: open editor reference slideover
368
+ toolbar.onHelp(() => {
369
+ openEditorHelp();
370
+ });
371
+
372
+ // View mode switching
373
+ $container.find('.editor-view-btn').on('click', function () {
374
+ const mode = $(this).data('mode');
375
+ $container.find('.editor-view-btn').removeClass('active');
376
+ $(this).addClass('active');
377
+ $container.find('#editor-body')
378
+ .removeClass('editor-mode-split editor-mode-write editor-mode-preview')
379
+ .addClass(`editor-mode-${mode}`);
380
+ });
381
+
382
+ // Fullscreen toggle
383
+ $container.find('#fullscreen-btn').on('click', function () {
384
+ $container.find('.editor-card').toggleClass('editor-fullscreen');
385
+ });
386
+
387
+ // Dirty tracking + unsaved-changes guard
388
+ _dirty = false;
389
+ if (_beforeUnload) window.removeEventListener('beforeunload', _beforeUnload);
390
+ _beforeUnload = (e) => {
391
+ if (_dirty) e.preventDefault();
392
+ };
393
+ window.addEventListener('beforeunload', _beforeUnload);
394
+
395
+ // SPA navigation guard — registered once at module level
396
+ if (!_guardRegistered) {
397
+ _guardRegistered = true;
398
+ R.use((to, from, next) => {
399
+ const onEditor = window.location.hash.startsWith('#/pages/edit') ||
400
+ window.location.hash === '#/pages/new';
401
+ if (!_dirty || !onEditor) return next();
402
+ E.confirm('You have unsaved changes. Leave this page?').then(ok => {
403
+ if (ok) {
404
+ _dirty = false;
405
+ if (_saveShortcut) {
406
+ document.removeEventListener('keydown', _saveShortcut);
407
+ _saveShortcut = null;
408
+ }
409
+ next();
410
+ }
411
+ });
412
+ });
413
+ }
414
+
415
+ $editor.on('input', () => {
416
+ _dirty = true;
417
+ updatePreview();
418
+ });
419
+ updatePreview();
420
+
421
+ // Save
422
+ $container.find('#save-btn').on('click', async () => {
423
+ const enteredPath = $container.find('#page-url-path').val().trim();
424
+ if (!enteredPath) { E.toast('URL path is required.', { type: 'warning' }); return; }
425
+
426
+ const frontmatter = {
427
+ title: $container.find('#field-title').val().trim() || 'Untitled',
428
+ description: $container.find('#field-description').val().trim(),
429
+ layout: $container.find('#field-layout').val(),
430
+ status: $container.find('#field-status').val(),
431
+ sortOrder: parseInt($container.find('#field-sort-order').val(), 10) || 99,
432
+ showInNav: $container.find('#field-show-in-nav').is(':checked'),
433
+ sidebar: $container.find('#field-sidebar').is(':checked'),
434
+ category: $container.find('#field-category').val().trim() || null,
435
+ visibility: $container.find('#field-visibility').val() || 'public',
436
+ seo: {
437
+ title: $container.find('#field-seo-title').val().trim(),
438
+ description: $container.find('#field-seo-desc').val().trim()
439
+ }
440
+ };
441
+
442
+ try {
443
+ $container.find('#save-btn').prop('disabled', true).text('Saving…');
444
+ if (isEdit) {
445
+ const payload = { frontmatter, body: $editor.val() };
446
+ if (enteredPath !== urlPath) payload.newUrlPath = enteredPath;
447
+ await api.pages.update(urlPath, payload);
448
+ E.toast('Page saved successfully.', { type: 'success' });
449
+ _dirty = false;
450
+ if (enteredPath !== urlPath) R.navigate(`/pages/edit${enteredPath}`);
451
+ } else {
452
+ await api.pages.create({ urlPath: enteredPath, frontmatter, body: $editor.val() });
453
+ E.toast('Page saved successfully.', { type: 'success' });
454
+ _dirty = false;
455
+ R.navigate('/pages');
456
+ }
457
+ } catch (err) {
458
+ E.toast(`Save failed: ${err.message || 'Unknown error'}`, { type: 'error' });
459
+ } finally {
460
+ $container.find('#save-btn').prop('disabled', false).text('Save');
461
+ }
462
+ });
463
+
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
+ // Cancel
475
+ $container.find('#cancel-btn').on('click', () => R.navigate('/pages'));
476
+
477
+ Domma.icons.scan();
478
+ }
479
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pages List View
3
+ * Shows all pages in a Domma table with status filter.
4
+ */
5
+ import {api} from '../api.js';
6
+
7
+ export const pagesView = {
8
+ templateUrl: '/admin/js/templates/pages.html',
9
+
10
+ async onMount($container) {
11
+ let pages = await api.pages.list().catch(() => []);
12
+
13
+ const renderTable = (data) => {
14
+ T.create('#pages-table', {
15
+ data,
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') : '—' },
22
+ {
23
+ key: 'actions', label: 'Actions',
24
+ render: (_, row) => `
25
+ <a href="#/pages/edit${row.urlPath}" class="btn btn-sm btn-outline">Edit</a>
26
+ <a href="${row.urlPath}" target="_blank" class="btn btn-sm btn-ghost" data-tooltip="View"><span data-icon="external-link"></span></a>
27
+ <button class="btn btn-sm btn-danger btn-delete" data-path="${row.urlPath}">Delete</button>
28
+ `
29
+ }
30
+ ],
31
+ emptyMessage: 'No pages found. <a href="#/pages/new">Create one</a>.'
32
+ });
33
+ Domma.icons.scan();
34
+ document.querySelectorAll('#pages-table [data-tooltip]').forEach(el => {
35
+ E.tooltip(el, {content: el.getAttribute('data-tooltip'), position: 'top'});
36
+ });
37
+ Domma.effects.reveal('.card', { animation: 'fade', duration: 350 });
38
+ };
39
+
40
+ renderTable(pages);
41
+
42
+ // Status filter
43
+ $container.find('#status-filter').off('change').on('change', function () {
44
+ const val = $(this).val();
45
+ const filtered = val ? pages.filter(p => p.status === val) : pages;
46
+ renderTable(filtered);
47
+ });
48
+
49
+ // Delete handler (delegated)
50
+ $container.off('click', '.btn-delete').on('click', '.btn-delete', async function () {
51
+ const urlPath = $(this).data('path');
52
+ const confirmed = await E.confirm(`Delete page at <strong>${urlPath}</strong>? This cannot be undone.`);
53
+ if (!confirmed) return;
54
+ try {
55
+ await api.pages.delete(urlPath);
56
+ E.toast('Page deleted.', { type: 'success' });
57
+ pages = pages.filter(p => p.urlPath !== urlPath);
58
+ renderTable(pages);
59
+ } catch {
60
+ E.toast('Failed to delete page.', { type: 'error' });
61
+ }
62
+ });
63
+ }
64
+ };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Plugins Management View
3
+ * Card grid of discovered plugins with enable/disable toggle and settings.
4
+ */
5
+ import { api } from '../api.js';
6
+
7
+ export const pluginsView = {
8
+ templateUrl: '/admin/js/templates/plugins.html',
9
+
10
+ async onMount($container) {
11
+ let plugins = [];
12
+ try {
13
+ plugins = await api.plugins.list();
14
+ } catch (err) {
15
+ E.toast(`Failed to load plugins: ${err.message}`, { type: 'error' });
16
+ }
17
+
18
+ if (!plugins.length) {
19
+ $container.find('#plugins-empty').show();
20
+ return;
21
+ }
22
+
23
+ renderPlugins($container, plugins);
24
+ Domma.icons.scan();
25
+ }
26
+ };
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Render
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function renderPlugins($container, plugins) {
33
+ const $grid = $container.find('#plugins-grid').empty();
34
+
35
+ plugins.forEach(plugin => {
36
+ const enabledClass = plugin.enabled ? 'badge-success' : 'badge-secondary';
37
+ const enabledLabel = plugin.enabled ? 'Enabled' : 'Disabled';
38
+ const btnLabel = plugin.enabled ? 'Disable' : 'Enable';
39
+ const btnClass = plugin.enabled ? 'btn-ghost' : 'btn-primary';
40
+
41
+ const authorLine = plugin.author ? `by ${escapeHtml(plugin.author)}` : '';
42
+ const dateLine = plugin.date ? escapeHtml(plugin.date) : '';
43
+
44
+ const card = `
45
+ <div class="card plugin-card" data-plugin="${plugin.name}">
46
+ <div class="card-body">
47
+ <div class="plugin-header">
48
+ <div class="plugin-icon"><span data-icon="${plugin.icon || 'package'}"></span></div>
49
+ <div class="plugin-meta">
50
+ <div class="plugin-name">${escapeHtml(plugin.displayName)}</div>
51
+ <div class="plugin-version">v${escapeHtml(plugin.version)}${authorLine ? ` &middot; ${authorLine}` : ''}</div>
52
+ </div>
53
+ <span class="badge ${enabledClass}">${enabledLabel}</span>
54
+ </div>
55
+ <p class="plugin-desc">${escapeHtml(plugin.description)}</p>
56
+ ${dateLine ? `<p class="plugin-date text-muted" style="font-size:.8rem;margin:0">Released ${dateLine}</p>` : ''}
57
+ </div>
58
+ <div class="plugin-footer">
59
+ <span class="text-muted" style="font-size:.8rem">Restart server after changes</span>
60
+ <button class="btn btn-sm ${btnClass} btn-toggle-plugin"
61
+ data-name="${plugin.name}"
62
+ data-enabled="${plugin.enabled ? '1' : '0'}">
63
+ ${btnLabel}
64
+ </button>
65
+ </div>
66
+ </div>`;
67
+ $grid.append(card);
68
+ });
69
+
70
+ Domma.icons.scan();
71
+ Domma.effects.reveal('.plugin-card', { animation: 'fade', stagger: 60, duration: 400 });
72
+ Domma.effects.ripple('.btn-toggle-plugin');
73
+
74
+ // Toggle handler
75
+ $grid.on('click', '.btn-toggle-plugin', async function () {
76
+ const $btn = $(this);
77
+ const name = $btn.data('name');
78
+ const enabling = $btn.data('enabled') !== 1 && $btn.data('enabled') !== '1';
79
+
80
+ $btn.prop('disabled', true).text(enabling ? 'Enabling…' : 'Disabling…');
81
+ try {
82
+ await api.plugins.update(name, { enabled: enabling });
83
+ E.toast(`Plugin ${enabling ? 'enabled' : 'disabled'}. Restart the server to apply changes.`, { type: 'success' });
84
+ // Re-fetch and re-render
85
+ const updated = await api.plugins.list();
86
+ renderPlugins($container, updated);
87
+ } catch (err) {
88
+ E.toast(`Failed: ${err.message}`, { type: 'error' });
89
+ $btn.prop('disabled', false);
90
+ }
91
+ });
92
+ }
93
+
94
+ function escapeHtml(str) {
95
+ return String(str)
96
+ .replace(/&/g, '&amp;')
97
+ .replace(/</g, '&lt;')
98
+ .replace(/>/g, '&gt;')
99
+ .replace(/"/g, '&quot;');
100
+ }