domma-cms 1.6.60 → 1.7.1

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 (168) hide show
  1. package/CHANGELOG.md +4349 -0
  2. package/README.md +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/lib/effect-defs.js +1 -1
  6. package/admin/js/lib/effects-builder.js +2 -2
  7. package/admin/js/lib/mail-compose.js +1 -0
  8. package/admin/js/lib/page-editor-reference.js +5 -5
  9. package/admin/js/lib/shortcode-tags.js +1 -1
  10. package/admin/js/templates/call-new.html +1 -1
  11. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  12. package/admin/js/views/call-new.js +1 -1
  13. package/admin/js/views/contacts.js +15 -15
  14. package/admin/js/views/form-submissions.js +2 -2
  15. package/docs/DommaFeatures.md +3005 -0
  16. package/docs/MultiTenant.md +176 -0
  17. package/docs/api-reference.md +1667 -0
  18. package/docs/cache.md +96 -0
  19. package/docs/configuration.md +1811 -0
  20. package/docs/editions-and-licences.md +190 -0
  21. package/docs/getting-started.md +201 -0
  22. package/docs/guides/form-triggers.md +260 -0
  23. package/docs/guides/mongodb-storage-adapter.md +282 -0
  24. package/docs/guides/multi-page-forms.md +121 -0
  25. package/docs/images/scribe-composer.png +0 -0
  26. package/docs/markdown-shortcodes.md +1738 -0
  27. package/docs/pentest-report-2026-04-03.md +181 -0
  28. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  29. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  30. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  31. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  32. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  33. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  34. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  35. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  36. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  37. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  38. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  39. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  40. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  41. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  42. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  43. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  44. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  45. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  46. package/docs/plugin-development.md +827 -0
  47. package/docs/recruitment-recipe.md +501 -0
  48. package/docs/scaffolding.md +312 -0
  49. package/docs/screenshots/calculator-floating.png +0 -0
  50. package/docs/screenshots/calculator-standard.png +0 -0
  51. package/docs/screenshots/email-settings.png +0 -0
  52. package/docs/screenshots/messages-count.png +0 -0
  53. package/docs/structure/user-types.md +107 -0
  54. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  55. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  56. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  57. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  58. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  59. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  60. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  61. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  62. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  63. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  64. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  65. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  66. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  67. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  68. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  69. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  70. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  71. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  72. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  73. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  74. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  75. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  76. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  77. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  78. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  79. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  80. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  81. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  82. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  83. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  84. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  85. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  86. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  87. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  88. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  89. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  90. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  91. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  92. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  93. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  94. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  95. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  96. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  97. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  98. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  99. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  100. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  101. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  102. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  103. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  104. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  105. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  106. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  107. package/docs/theming.md +320 -0
  108. package/package.json +2 -2
  109. package/plugins/_lib/admin/mail/compose-window.js +3 -1
  110. package/plugins/_lib/admin/mail/identity.js +18 -3
  111. package/plugins/_lib/admin/mail/reader-view.js +32 -3
  112. package/plugins/blog/CLAUDE.md +4 -0
  113. package/plugins/blog/admin/lib/email.js +191 -0
  114. package/plugins/blog/admin/views/comments.js +3 -1
  115. package/plugins/blog/plugin.json +2 -2
  116. package/plugins/blog/plugin.public.js +1 -1
  117. package/plugins/blog/tests/email.test.js +69 -0
  118. package/plugins/booking/CLAUDE.md +4 -0
  119. package/plugins/booking/admin/lib/email.js +191 -0
  120. package/plugins/booking/admin/views/panels.js +7 -3
  121. package/plugins/booking/plugin.json +1 -1
  122. package/plugins/booking/tests/email.test.js +69 -0
  123. package/plugins/free-tier.lock.json +27 -19
  124. package/plugins/shopping-cart/CLAUDE.md +4 -0
  125. package/plugins/shopping-cart/admin/lib/email.js +191 -0
  126. package/plugins/shopping-cart/admin/views/orders.js +11 -3
  127. package/plugins/shopping-cart/plugin.json +2 -2
  128. package/plugins/shopping-cart/tests/email.test.js +69 -0
  129. package/plugins/support/CLAUDE.md +4 -0
  130. package/plugins/support/admin/lib/email.js +191 -0
  131. package/plugins/support/admin/views/desk.js +4 -1
  132. package/plugins/support/admin/views/inbox.js +6 -2
  133. package/plugins/support/plugin.json +2 -2
  134. package/plugins/support/tests/email.test.js +69 -0
  135. package/public/js/celebrations/core/canvas.js +1 -1
  136. package/public/js/celebrations/index.js +1 -1
  137. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  138. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  139. package/public/js/celebrations/message-attrs.mjs +1 -0
  140. package/public/js/celebrations/themes/anniversary.js +1 -0
  141. package/public/js/celebrations/themes/autumn.js +1 -0
  142. package/public/js/celebrations/themes/birthday.js +1 -0
  143. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  144. package/public/js/celebrations/themes/calendar.js +1 -0
  145. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  146. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  147. package/public/js/celebrations/themes/christmas.js +1 -1
  148. package/public/js/celebrations/themes/easter.js +1 -0
  149. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  150. package/public/js/celebrations/themes/garden.js +1 -0
  151. package/public/js/celebrations/themes/graduation.js +1 -0
  152. package/public/js/celebrations/themes/greeting.js +1 -0
  153. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  154. package/public/js/celebrations/themes/money-rain.js +1 -0
  155. package/public/js/celebrations/themes/new-year.js +1 -0
  156. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  157. package/public/js/celebrations/themes/occasions.js +1 -0
  158. package/public/js/celebrations/themes/rain-control.js +1 -0
  159. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  160. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  161. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  162. package/public/js/celebrations/themes/summer-garden.js +1 -0
  163. package/public/js/celebrations/themes/winter.js +1 -0
  164. package/public/js/celebrations/trait-data.mjs +1 -1
  165. package/public/js/effects.js +1 -1
  166. package/scripts/build.js +4 -0
  167. package/server/services/markdown.js +5 -2
  168. package/server/templates/page.html +1 -1
@@ -0,0 +1,1015 @@
1
+ # Layout Presets Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:
4
+ > executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
5
+
6
+ **Goal:** Extend layout presets with width, background colour, background image, and custom CSS class; add full CRUD to
7
+ the admin UI using a slideover edit form.
8
+
9
+ **Architecture:** Presets are stored in `config/presets.json` and looked up in `renderer.js` at page render time. New
10
+ fields are applied as body CSS classes (width) and inline styles on the `.page-body` div (backgrounds). The admin view
11
+ is a card grid with a Domma slideover for creating and editing presets.
12
+
13
+ **Tech Stack:** Fastify 5 (ESM), Domma SPA admin (`E.slideover`, `E.toast`, `E.confirm`), Domma `apiRequest()` client,
14
+ `getConfig` / `saveConfig` config layer.
15
+
16
+ ---
17
+
18
+ ## File Map
19
+
20
+ | File | Change |
21
+ |-----------------------------------|-------------------------------------------------------------------------------------------------|
22
+ | `config/presets.json` | Add new fields to all presets; add `builtin: true` to default/landing/blank; add `blank` preset |
23
+ | `server/routes/api/layouts.js` | Add POST, PUT /:key, DELETE /:key; built-in merge-in seeding |
24
+ | `server/services/renderer.js` | Compute `layoutBodyClass` and `pageBodyStyle` from preset; add to `vars` |
25
+ | `server/templates/page.html` | Inject `{{layoutBodyClass}}` into body class; inject `{{pageBodyStyle}}` onto `.page-body` |
26
+ | `public/css/site.css` | Add four `body.dm-layout-*` width utility classes |
27
+ | `admin/js/api.js` | Add `create`, `update`, `remove` to `api.layouts` (line 158) |
28
+ | `admin/js/views/layouts.js` | Full rebuild: CRUD card grid + slideover edit form |
29
+ | `admin/js/templates/layouts.html` | Add Create button and update description text |
30
+
31
+ ---
32
+
33
+ ### Task 1: Update `config/presets.json`
34
+
35
+ **Files:**
36
+
37
+ - Modify: `config/presets.json`
38
+
39
+ The existing presets (`default`, `with-sidebar`, `minimal`, `landing`) need the new fields added. The spec names three
40
+ built-in presets: `default`, `landing`, `blank`. Mark those with `builtin: true`. Add the missing `blank` preset. Leave
41
+ `with-sidebar` and `minimal` as non-builtin (users can delete them).
42
+
43
+ > **Note:** The existing `landing` preset is labelled "Landing Page" and has navbar + footer. Per the spec, `landing`
44
+ > should be: navbar true, footer false, width full. Update it accordingly.
45
+
46
+ - [ ] **Step 1: Replace `config/presets.json` with updated content**
47
+
48
+ ```json
49
+ {
50
+ "default": {
51
+ "key": "default",
52
+ "label": "Default",
53
+ "description": "Standard page with navbar and footer.",
54
+ "builtin": true,
55
+ "navbar": true,
56
+ "footer": true,
57
+ "sidebar": false,
58
+ "width": "normal",
59
+ "bgColor": "",
60
+ "bgImage": "",
61
+ "class": ""
62
+ },
63
+ "landing": {
64
+ "key": "landing",
65
+ "label": "Landing Page",
66
+ "description": "Full-width landing page with navbar, no footer.",
67
+ "builtin": true,
68
+ "navbar": true,
69
+ "footer": false,
70
+ "sidebar": false,
71
+ "width": "full",
72
+ "bgColor": "",
73
+ "bgImage": "",
74
+ "class": ""
75
+ },
76
+ "blank": {
77
+ "key": "blank",
78
+ "label": "Blank",
79
+ "description": "Minimal page with no navbar or footer.",
80
+ "builtin": true,
81
+ "navbar": false,
82
+ "footer": false,
83
+ "sidebar": false,
84
+ "width": "normal",
85
+ "bgColor": "",
86
+ "bgImage": "",
87
+ "class": ""
88
+ },
89
+ "with-sidebar": {
90
+ "key": "with-sidebar",
91
+ "label": "With Sidebar",
92
+ "description": "Page with navbar, sidebar, and footer.",
93
+ "builtin": false,
94
+ "navbar": true,
95
+ "footer": true,
96
+ "sidebar": true,
97
+ "width": "normal",
98
+ "bgColor": "",
99
+ "bgImage": "",
100
+ "class": ""
101
+ },
102
+ "minimal": {
103
+ "key": "minimal",
104
+ "label": "Minimal",
105
+ "description": "Clean page with no navbar or footer.",
106
+ "builtin": false,
107
+ "navbar": false,
108
+ "footer": false,
109
+ "sidebar": false,
110
+ "width": "normal",
111
+ "bgColor": "",
112
+ "bgImage": "",
113
+ "class": ""
114
+ }
115
+ }
116
+ ```
117
+
118
+ - [ ] **Step 2: Verify the file is valid JSON**
119
+
120
+ ```bash
121
+ node -e "JSON.parse(require('fs').readFileSync('config/presets.json','utf8')); console.log('OK')"
122
+ ```
123
+
124
+ Expected: `OK`
125
+
126
+ - [ ] **Step 3: Commit**
127
+
128
+ ```bash
129
+ git add config/presets.json
130
+ git commit -m "feat(layouts): add width/bg/class fields and blank preset to presets.json"
131
+ ```
132
+
133
+ ---
134
+
135
+ ### Task 2: Update API routes (`server/routes/api/layouts.js`)
136
+
137
+ **Files:**
138
+
139
+ - Modify: `server/routes/api/layouts.js`
140
+
141
+ Add three new routes (POST, PUT /:key, DELETE /:key) and a built-in merge-in seeding block. Keep the four existing
142
+ routes unchanged.
143
+
144
+ The `slugify` helper turns a label into a safe key (lowercase, spaces → hyphens, strip non-alphanumeric).
145
+
146
+ The `VALID_WIDTHS` set guards the `width` field. The `bgColor` regex guards CSS injection. The `class` field is
147
+ sanitised by stripping anything outside `[a-zA-Z0-9 _-]`.
148
+
149
+ - [ ] **Step 1: Replace `server/routes/api/layouts.js` with the full updated file**
150
+
151
+ ```javascript
152
+ /**
153
+ * Layouts (Presets) API
154
+ * GET /api/layouts - get all layout presets
155
+ * PUT /api/layouts - bulk-save layout presets (legacy, kept for compatibility)
156
+ * POST /api/layouts - create a new preset
157
+ * PUT /api/layouts/:key - update a single preset
158
+ * DELETE /api/layouts/:key - delete a preset (builtin presets cannot be deleted)
159
+ * GET /api/layouts/options - get layout options
160
+ * PUT /api/layouts/options - save layout options
161
+ */
162
+ import {getConfig, saveConfig} from '../../config.js';
163
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
164
+
165
+ const VALID_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
166
+ const BG_COLOR_RE = /^[a-zA-Z0-9#(),.\s%/-]+$/;
167
+ const CLASS_RE = /[^a-zA-Z0-9\s_-]/g;
168
+
169
+ const BUILTIN_PRESETS = {
170
+ default: {
171
+ key: 'default', label: 'Default',
172
+ description: 'Standard page with navbar and footer.',
173
+ builtin: true, navbar: true, footer: true, sidebar: false,
174
+ width: 'normal', bgColor: '', bgImage: '', class: ''
175
+ },
176
+ landing: {
177
+ key: 'landing', label: 'Landing Page',
178
+ description: 'Full-width landing page with navbar, no footer.',
179
+ builtin: true, navbar: true, footer: false, sidebar: false,
180
+ width: 'full', bgColor: '', bgImage: '', class: ''
181
+ },
182
+ blank: {
183
+ key: 'blank', label: 'Blank',
184
+ description: 'Minimal page with no navbar or footer.',
185
+ builtin: true, navbar: false, footer: false, sidebar: false,
186
+ width: 'normal', bgColor: '', bgImage: '', class: ''
187
+ }
188
+ };
189
+
190
+ function slugify(str) {
191
+ return str.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
192
+ }
193
+
194
+ function sanitisePreset(data) {
195
+ return {
196
+ navbar: data.navbar !== false,
197
+ footer: data.footer !== false,
198
+ sidebar: data.sidebar === true,
199
+ width: VALID_WIDTHS.has(data.width) ? data.width : 'normal',
200
+ bgColor: data.bgColor && BG_COLOR_RE.test(data.bgColor) ? data.bgColor : '',
201
+ bgImage: typeof data.bgImage === 'string' ? data.bgImage.trim() : '',
202
+ class: typeof data.class === 'string' ? data.class.replace(CLASS_RE, '').trim() : '',
203
+ label: String(data.label || '').slice(0, 60).trim(),
204
+ description: String(data.description || '').slice(0, 200).trim()
205
+ };
206
+ }
207
+
208
+ export async function layoutsRoutes(fastify) {
209
+ // ── Merge-in seeding: ensure built-in presets exist ──────────────────────
210
+ const existing = getConfig('presets') || {};
211
+ let seeded = false;
212
+ for (const [key, preset] of Object.entries(BUILTIN_PRESETS)) {
213
+ if (!existing[key]) {
214
+ existing[key] = preset;
215
+ seeded = true;
216
+ }
217
+ }
218
+ if (seeded) saveConfig('presets', existing);
219
+
220
+ const canRead = {preHandler: [authenticate, requirePermission('layouts', 'read')]};
221
+ const canUpdate = {preHandler: [authenticate, requirePermission('layouts', 'update')]};
222
+
223
+ // ── Existing routes ───────────────────────────────────────────────────────
224
+ fastify.get('/layouts', canRead, async () => {
225
+ return getConfig('presets');
226
+ });
227
+
228
+ fastify.put('/layouts', canUpdate, async (request, reply) => {
229
+ const data = request.body;
230
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
231
+ return reply.status(400).send({error: 'Invalid presets data: expected an object'});
232
+ }
233
+ for (const [key, preset] of Object.entries(data)) {
234
+ if (!preset || typeof preset !== 'object' || Array.isArray(preset)) {
235
+ return reply.status(400).send({error: `Invalid preset "${key}": expected an object`});
236
+ }
237
+ if (typeof preset.label !== 'string' || !preset.label.trim()) {
238
+ return reply.status(400).send({error: `Invalid preset "${key}": missing or empty "label" field`});
239
+ }
240
+ }
241
+ saveConfig('presets', data);
242
+ return {success: true};
243
+ });
244
+
245
+ // ── New CRUD routes ───────────────────────────────────────────────────────
246
+ fastify.post('/layouts', canUpdate, async (request, reply) => {
247
+ const {label, description, ...rest} = request.body || {};
248
+ if (!label || !String(label).trim()) {
249
+ return reply.status(400).send({error: 'label is required'});
250
+ }
251
+ const key = slugify(label);
252
+ if (!key) {
253
+ return reply.status(400).send({error: 'label must contain at least one alphanumeric character'});
254
+ }
255
+ const presets = getConfig('presets') || {};
256
+ if (presets[key]) {
257
+ return reply.status(409).send({error: 'A preset with this key already exists'});
258
+ }
259
+ presets[key] = {
260
+ key,
261
+ builtin: false,
262
+ label: String(label).slice(0, 60).trim(),
263
+ description: String(description || '').slice(0, 200).trim(),
264
+ ...sanitisePreset({label, description, ...rest})
265
+ };
266
+ saveConfig('presets', presets);
267
+ return {success: true, key, preset: presets[key]};
268
+ });
269
+
270
+ fastify.put('/layouts/:key', canUpdate, async (request, reply) => {
271
+ const {key} = request.params;
272
+ const presets = getConfig('presets') || {};
273
+ if (!presets[key]) {
274
+ return reply.status(404).send({error: `Preset "${key}" not found`});
275
+ }
276
+ const {label, description, ...rest} = request.body || {};
277
+ if (!label || !String(label).trim()) {
278
+ return reply.status(400).send({error: 'label is required'});
279
+ }
280
+ presets[key] = {
281
+ ...presets[key],
282
+ label: String(label).slice(0, 60).trim(),
283
+ description: String(description || '').slice(0, 200).trim(),
284
+ ...sanitisePreset({label, description, ...rest}),
285
+ key,
286
+ builtin: presets[key].builtin // never overwrite builtin flag
287
+ };
288
+ saveConfig('presets', presets);
289
+ return {success: true, preset: presets[key]};
290
+ });
291
+
292
+ fastify.delete('/layouts/:key', canUpdate, async (request, reply) => {
293
+ const {key} = request.params;
294
+ const presets = getConfig('presets') || {};
295
+ if (!presets[key]) {
296
+ return reply.status(404).send({error: `Preset "${key}" not found`});
297
+ }
298
+ if (presets[key].builtin) {
299
+ return reply.status(400).send({error: 'Built-in presets cannot be deleted'});
300
+ }
301
+ delete presets[key];
302
+ saveConfig('presets', presets);
303
+ return {success: true};
304
+ });
305
+
306
+ // ── Layout options ────────────────────────────────────────────────────────
307
+ fastify.get('/layouts/options', canRead, async () => {
308
+ return getConfig('site')?.layoutOptions ?? {spacerSize: 8};
309
+ });
310
+
311
+ fastify.put('/layouts/options', canUpdate, async (request, reply) => {
312
+ const data = request.body;
313
+ if (!data || typeof data !== 'object') {
314
+ return reply.status(400).send({error: 'Invalid layout options'});
315
+ }
316
+ const site = getConfig('site');
317
+ site.layoutOptions = {...site.layoutOptions, ...data};
318
+ saveConfig('site', site);
319
+ return {success: true};
320
+ });
321
+ }
322
+ ```
323
+
324
+ - [ ] **Step 2: Test the server starts cleanly**
325
+
326
+ ```bash
327
+ curl -s http://localhost:4096/api/layouts -H "Authorization: Bearer $(node -e "const S=require('./admin/js/api.js') catch '' ")" 2>/dev/null | head -5
328
+ ```
329
+
330
+ Actually, just tail the server log or visit `http://localhost:4096` to confirm no startup errors. The server is already
331
+ running on port 4096 - do NOT kill or restart it. Instead, check the server process is alive:
332
+
333
+ ```bash
334
+ curl -s -o /dev/null -w "%{http_code}" http://localhost:4096/
335
+ ```
336
+
337
+ Expected: `200`
338
+
339
+ > **Note:** Because the server uses file-watching or is already running, you may need to trigger a restart for the new
340
+ > routes to take effect. If `nodemon` is running, it will restart automatically on file save. If not, the routes won't be
341
+ > live until the server is manually restarted - but that's outside your scope. The test below uses curl once the server is
342
+ > running.
343
+
344
+ - [ ] **Step 3: Commit**
345
+
346
+ ```bash
347
+ git add server/routes/api/layouts.js
348
+ git commit -m "feat(layouts): add POST/PUT/:key/DELETE/:key routes and built-in preset seeding"
349
+ ```
350
+
351
+ ---
352
+
353
+ ### Task 3: Update renderer and page template
354
+
355
+ **Files:**
356
+
357
+ - Modify: `server/services/renderer.js` (lines 107-147 - the `vars` object)
358
+ - Modify: `server/templates/page.html` (lines 32 and 46)
359
+
360
+ Add `layoutBodyClass` and `pageBodyStyle` to the renderer's `vars` object. Update the template to inject them.
361
+
362
+ - [ ] **Step 1: Add the two computed values to `vars` in `renderer.js`**
363
+
364
+ Inside `renderPage`, after the existing `preset` lookup (line 69), add two computed constants just before the `vars`
365
+ block:
366
+
367
+ ```javascript
368
+ // After: const preset = presets[page.layout] || presets['default'] || {};
369
+ // Add these two lines:
370
+ const layoutWidth = VALID_LAYOUT_WIDTHS.has(preset.width) ? preset.width : 'normal';
371
+ const layoutBodyClass = ['dm-layout-' + layoutWidth, preset.class || ''].filter(Boolean).join(' ');
372
+
373
+ const pageBodyStyleParts = [];
374
+ if (preset.bgColor) pageBodyStyleParts.push(`background-color:${preset.bgColor}`);
375
+ if (preset.bgImage) pageBodyStyleParts.push(`background-image:url('${preset.bgImage}');background-size:cover;background-position:center`);
376
+ const pageBodyStyle = pageBodyStyleParts.join(';');
377
+ ```
378
+
379
+ Add the `VALID_LAYOUT_WIDTHS` constant at the top of the file (after imports):
380
+
381
+ ```javascript
382
+ const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
383
+ ```
384
+
385
+ Then add `layoutBodyClass` and `pageBodyStyle` to the `vars` object:
386
+
387
+ ```javascript
388
+ const vars = {
389
+ // ... existing fields ...
390
+ layout: page.layout || 'default',
391
+ layoutBodyClass, // ← add this
392
+ pageBodyStyle, // ← add this
393
+ showNavbar: preset.navbar !== false,
394
+ // ... rest unchanged ...
395
+ };
396
+ ```
397
+
398
+ The full updated section (replace lines 97-147 of `renderer.js`):
399
+
400
+ ```javascript
401
+ const autoTheme = (!page.theme && site.autoTheme?.enabled) ? site.autoTheme : null;
402
+ const activeTheme = page.theme
403
+ || (autoTheme ? autoTheme.dayTheme : site.theme)
404
+ || 'charcoal-dark';
405
+ const dommaTheme = site.baseTheme || activeTheme;
406
+ const customThemeClass = site.baseTheme ? `dm-theme-${activeTheme}` : '';
407
+
408
+ const layoutWidth = VALID_LAYOUT_WIDTHS.has(preset.width) ? preset.width : 'normal';
409
+ const layoutBodyClass = ['dm-layout-' + layoutWidth, preset.class || ''].filter(Boolean).join(' ');
410
+ const pageBodyStyleParts = [];
411
+ if (preset.bgColor) pageBodyStyleParts.push(`background-color:${preset.bgColor}`);
412
+ if (preset.bgImage) pageBodyStyleParts.push(`background-image:url('${preset.bgImage}');background-size:cover;background-position:center`);
413
+ const pageBodyStyle = pageBodyStyleParts.join(';');
414
+
415
+ const vars = {
416
+ seoTitle,
417
+ seoDescription,
418
+ ogImage,
419
+ title: page.title,
420
+ html: page.html,
421
+ breadcrumbsHtml,
422
+ theme: activeTheme,
423
+ dommaTheme,
424
+ customThemeClass,
425
+ fontLink,
426
+ fontStyleTag,
427
+ layout: page.layout || 'default',
428
+ layoutBodyClass,
429
+ pageBodyStyle,
430
+ showNavbar: preset.navbar !== false,
431
+ showFooter: preset.footer !== false,
432
+ showSidebar: preset.sidebar === true || page.sidebar === true,
433
+ navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
434
+ siteJson: JSON.stringify(Object.assign(
435
+ {
436
+ title: site.title,
437
+ footer: site.footer
438
+ ? {
439
+ ...site.footer,
440
+ links: (site.footer.links || []).filter(l => !l.hidden)
441
+ }
442
+ : site.footer,
443
+ social: site.social || null
444
+ },
445
+ site.backToTop?.enabled ? { backToTop: site.backToTop } : {},
446
+ site.cookieConsent?.enabled ? {cookieConsent: site.cookieConsent} : {},
447
+ autoTheme ? {autoTheme} : {}
448
+ )).replace(/<\/script>/gi, '<\\/script>'),
449
+ headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
450
+ headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
451
+ bodyEndInject: [
452
+ injection.bodyEnd,
453
+ site.backToTop?.enabled ? '<script src="/public/js/btt.js"></script>' : '',
454
+ site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js"></script>' : ''
455
+ ].filter(Boolean).join('\n'),
456
+ dconfigScript,
457
+ };
458
+ ```
459
+
460
+ Also add `VALID_LAYOUT_WIDTHS` near the top of `renderer.js`, after the imports:
461
+
462
+ ```javascript
463
+ const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
464
+ ```
465
+
466
+ - [ ] **Step 2: Update `server/templates/page.html`**
467
+
468
+ **Line 32** - add `{{layoutBodyClass}}` to the body class:
469
+
470
+ Before:
471
+
472
+ ```html
473
+ <body class="dm-cloaked dm-theme-{{theme}}" data-layout="{{layout}}">
474
+ ```
475
+
476
+ After:
477
+
478
+ ```html
479
+ <body class="dm-cloaked dm-theme-{{theme}} {{layoutBodyClass}}" data-layout="{{layout}}">
480
+ ```
481
+
482
+ **Line 46** - add conditional style to `.page-body`:
483
+
484
+ Before:
485
+
486
+ ```html
487
+ <div class="page-body">
488
+ ```
489
+
490
+ After:
491
+
492
+ ```html
493
+ <div class="page-body"{{#if pageBodyStyle}} style="{{pageBodyStyle}}"{{/if}}>
494
+ ```
495
+
496
+ - [ ] **Step 3: Verify syntax**
497
+
498
+ ```bash
499
+ node -e "import('./server/services/renderer.js').then(() => console.log('OK')).catch(e => console.error(e.message))"
500
+ ```
501
+
502
+ Expected: `OK`
503
+
504
+ - [ ] **Step 4: Commit**
505
+
506
+ ```bash
507
+ git add server/services/renderer.js server/templates/page.html
508
+ git commit -m "feat(layouts): apply width class and bg styles from preset in renderer"
509
+ ```
510
+
511
+ ---
512
+
513
+ ### Task 4: Add CSS width utility classes (`public/css/site.css`)
514
+
515
+ **Files:**
516
+
517
+ - Modify: `public/css/site.css` (append to end of file)
518
+
519
+ - [ ] **Step 1: Append the four width classes to `public/css/site.css`**
520
+
521
+ ```css
522
+ /* ── Layout preset width utilities ────────────────────────────── */
523
+ body.dm-layout-narrow .page-body { max-width: 768px; margin-inline: auto; }
524
+ body.dm-layout-normal .page-body { max-width: 1100px; margin-inline: auto; }
525
+ body.dm-layout-wide .page-body { max-width: 1280px; margin-inline: auto; }
526
+ body.dm-layout-full .page-body { max-width: none; }
527
+ ```
528
+
529
+ - [ ] **Step 2: Commit**
530
+
531
+ ```bash
532
+ git add public/css/site.css
533
+ git commit -m "feat(layouts): add dm-layout-* width utility classes"
534
+ ```
535
+
536
+ ---
537
+
538
+ ### Task 5: Add new methods to the API client (`admin/js/api.js`)
539
+
540
+ **Files:**
541
+
542
+ - Modify: `admin/js/api.js` (lines 158-163 - the `layouts` namespace)
543
+
544
+ The current `layouts` block (line 158):
545
+
546
+ ```javascript
547
+ layouts: {
548
+ get: () => apiRequest('/layouts', { method: 'GET' }),
549
+ save: (data) => apiRequest('/layouts', {method: 'PUT', body: JSON.stringify(data)}),
550
+ getOptions: () => apiRequest('/layouts/options', {method: 'GET'}),
551
+ saveOptions: (data) => apiRequest('/layouts/options', {method: 'PUT', body: JSON.stringify(data)})
552
+ },
553
+ ```
554
+
555
+ Replace with:
556
+
557
+ ```javascript
558
+ layouts: {
559
+ get: () => apiRequest('/layouts', {method: 'GET'}),
560
+ save: (data) => apiRequest('/layouts', {method: 'PUT', body: JSON.stringify(data)}),
561
+ create: (data) => apiRequest('/layouts', {method: 'POST', body: JSON.stringify(data)}),
562
+ update: (key, data) => apiRequest(`/layouts/${key}`, {method: 'PUT', body: JSON.stringify(data)}),
563
+ remove: (key) => apiRequest(`/layouts/${key}`, {method: 'DELETE'}),
564
+ getOptions: () => apiRequest('/layouts/options', {method: 'GET'}),
565
+ saveOptions: (data) => apiRequest('/layouts/options', {method: 'PUT', body: JSON.stringify(data)})
566
+ },
567
+ ```
568
+
569
+ - [ ] **Step 1: Apply the edit to `admin/js/api.js`**
570
+
571
+ - [ ] **Step 2: Commit**
572
+
573
+ ```bash
574
+ git add admin/js/api.js
575
+ git commit -m "feat(layouts): add create/update/remove methods to api.layouts client"
576
+ ```
577
+
578
+ ---
579
+
580
+ ### Task 6: Rebuild admin layouts UI
581
+
582
+ **Files:**
583
+
584
+ - Modify: `admin/js/templates/layouts.html`
585
+ - Modify: `admin/js/views/layouts.js`
586
+
587
+ This is the biggest task. The view is rebuilt from scratch. The template gets a Create button and an updated
588
+ description.
589
+
590
+ #### Part A - Update the template (`admin/js/templates/layouts.html`)
591
+
592
+ Replace the entire file:
593
+
594
+ ```html
595
+ <div class="view-header">
596
+ <h1><span data-icon="layout"></span> Layouts</h1>
597
+ </div>
598
+
599
+ <div class="tabs" id="layouts-tabs">
600
+ <div class="tab-list">
601
+ <button class="tab-item active" data-tab="panel-presets">Layout Presets</button>
602
+ <button class="tab-item" data-tab="panel-options">Layout Options</button>
603
+ </div>
604
+ <div class="tab-content">
605
+
606
+ <div class="tab-panel active" id="panel-presets">
607
+ <div class="view-actions mb-4" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
608
+ <p class="text-muted" style="margin:0;">Layout presets define the structure, width, and background of pages. Assign a layout to each page via the page editor.</p>
609
+ <button id="create-layout-btn" class="btn btn-primary"><span data-icon="plus"></span> New Layout</button>
610
+ </div>
611
+ <div id="presets-grid" class="presets-grid"></div>
612
+ </div>
613
+
614
+ <div class="tab-panel" id="panel-options">
615
+ <div class="card mb-4">
616
+ <div class="card-header"><h3>Spacer Shortcode</h3></div>
617
+ <div class="card-body">
618
+ <p class="text-muted mb-3">
619
+ The <code>[spacer /]</code> shortcode inserts a vertical gap. Set the default height here;
620
+ individual spacers can override it with <code>size="N"</code>.
621
+ </p>
622
+ <div class="form-group">
623
+ <label class="form-label" for="spacer-size-input">Default spacer height (px)</label>
624
+ <input id="spacer-size-input" type="number" class="form-input" style="max-width:120px" min="0" max="500"
625
+ step="1" value="8">
626
+ </div>
627
+ <div class="form-group mt-3">
628
+ <label class="form-label" for="spacer-class-input">Default spacer CSS class</label>
629
+ <input id="spacer-class-input" type="text" class="form-input" style="max-width:240px"
630
+ placeholder="e.g. my-gap">
631
+ <small class="text-muted">Applied to every <code>[spacer /]</code> unless overridden per-instance with
632
+ <code>class="…"</code>.</small>
633
+ </div>
634
+ <button id="save-options-btn" class="btn btn-primary mt-3"><span data-icon="save"></span> Save Options
635
+ </button>
636
+ </div>
637
+ </div>
638
+ </div>
639
+
640
+ </div>
641
+ </div>
642
+ ```
643
+
644
+ #### Part B - Rebuild the view (`admin/js/views/layouts.js`)
645
+
646
+ Replace the entire file:
647
+
648
+ ```javascript
649
+ /**
650
+ * Layouts View
651
+ * Card grid + slideover edit/create for layout presets.
652
+ */
653
+ import {api} from '../api.js';
654
+
655
+ const WIDTH_LABELS = {narrow: 'Narrow', normal: 'Normal', wide: 'Wide', full: 'Full'};
656
+
657
+ const LABEL_STYLE = 'display:block;font-size:.7rem;font-weight:600;color:var(--dm-text-muted,#aaa);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem;';
658
+ const INPUT_STYLE = 'width:100%;padding:.4rem .6rem;background:var(--dm-input-bg,#1a1a1a);border:1px solid var(--dm-border,#333);border-radius:4px;color:var(--dm-text,#eee);font-size:.9em;box-sizing:border-box;';
659
+ const FIELD_STYLE = 'display:flex;flex-direction:column;gap:.25rem;';
660
+
661
+ function field(labelText, control) {
662
+ const wrap = document.createElement('div');
663
+ wrap.style.cssText = FIELD_STYLE;
664
+ const lbl = document.createElement('label');
665
+ lbl.style.cssText = LABEL_STYLE;
666
+ lbl.textContent = labelText;
667
+ wrap.appendChild(lbl);
668
+ wrap.appendChild(control);
669
+ return wrap;
670
+ }
671
+
672
+ function textInput(placeholder = '', value = '') {
673
+ const inp = document.createElement('input');
674
+ inp.type = 'text';
675
+ inp.placeholder = placeholder;
676
+ inp.value = value;
677
+ inp.style.cssText = INPUT_STYLE;
678
+ return inp;
679
+ }
680
+
681
+ function checkbox(label, checked = false) {
682
+ const wrap = document.createElement('label');
683
+ wrap.style.cssText = 'display:flex;align-items:center;gap:.5rem;cursor:pointer;';
684
+ const cb = document.createElement('input');
685
+ cb.type = 'checkbox';
686
+ cb.checked = checked;
687
+ const span = document.createElement('span');
688
+ span.style.cssText = 'font-size:.9em;color:var(--dm-text,#eee);';
689
+ span.textContent = label;
690
+ wrap.appendChild(cb);
691
+ wrap.appendChild(span);
692
+ return {wrap, cb};
693
+ }
694
+
695
+ function widthSelect(current = 'normal') {
696
+ const sel = document.createElement('select');
697
+ sel.style.cssText = INPUT_STYLE;
698
+ [['narrow', 'Narrow (768px)'], ['normal', 'Normal (1100px)'], ['wide', 'Wide (1280px)'], ['full', 'Full width']].forEach(([val, text]) => {
699
+ const opt = document.createElement('option');
700
+ opt.value = val;
701
+ opt.textContent = text;
702
+ if (val === current) opt.selected = true;
703
+ sel.appendChild(opt);
704
+ });
705
+ return sel;
706
+ }
707
+
708
+ function colourPicker(value = '') {
709
+ const wrap = document.createElement('div');
710
+ wrap.style.cssText = 'display:flex;align-items:center;gap:.5rem;';
711
+
712
+ const picker = document.createElement('input');
713
+ picker.type = 'color';
714
+ picker.value = value || '#ffffff';
715
+ picker.disabled = !value;
716
+ picker.style.cssText = 'width:38px;height:32px;padding:2px;border:1px solid var(--dm-border,#333);border-radius:4px;background:transparent;cursor:pointer;';
717
+
718
+ const statusSpan = document.createElement('span');
719
+ statusSpan.style.cssText = 'font-size:.85em;color:var(--dm-text-muted,#aaa);';
720
+ statusSpan.textContent = value ? value : 'None';
721
+
722
+ const chooseBtn = document.createElement('button');
723
+ chooseBtn.type = 'button';
724
+ chooseBtn.textContent = 'Choose';
725
+ chooseBtn.style.cssText = 'padding:.25rem .6rem;font-size:.8em;background:transparent;border:1px solid var(--dm-border,#333);border-radius:4px;color:var(--dm-text-muted,#aaa);cursor:pointer;';
726
+
727
+ const clearBtn = document.createElement('button');
728
+ clearBtn.type = 'button';
729
+ clearBtn.textContent = 'Clear';
730
+ clearBtn.style.cssText = chooseBtn.style.cssText;
731
+ clearBtn.disabled = !value;
732
+
733
+ chooseBtn.addEventListener('click', () => {
734
+ picker.disabled = false;
735
+ clearBtn.disabled = false;
736
+ picker.click();
737
+ });
738
+
739
+ picker.addEventListener('input', () => {
740
+ statusSpan.textContent = picker.value;
741
+ clearBtn.disabled = false;
742
+ });
743
+
744
+ clearBtn.addEventListener('click', () => {
745
+ picker.value = '#ffffff';
746
+ picker.disabled = true;
747
+ clearBtn.disabled = true;
748
+ statusSpan.textContent = 'None';
749
+ });
750
+
751
+ wrap.appendChild(picker);
752
+ wrap.appendChild(statusSpan);
753
+ wrap.appendChild(chooseBtn);
754
+ wrap.appendChild(clearBtn);
755
+
756
+ return {
757
+ wrap,
758
+ getValue() { return picker.disabled ? '' : picker.value; }
759
+ };
760
+ }
761
+
762
+ function buildCard(key, preset, onEdit) {
763
+ const card = document.createElement('div');
764
+ card.className = 'card preset-card';
765
+ card.style.cssText = 'cursor:pointer;transition:box-shadow .15s;';
766
+ card.addEventListener('mouseenter', () => { card.style.boxShadow = '0 0 0 2px var(--dm-primary,#4f7fff)'; });
767
+ card.addEventListener('mouseleave', () => { card.style.boxShadow = ''; });
768
+ card.addEventListener('click', () => onEdit(key, preset));
769
+
770
+ const header = document.createElement('div');
771
+ header.className = 'card-header';
772
+ header.style.cssText = 'display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;';
773
+
774
+ const title = document.createElement('strong');
775
+ title.textContent = preset.label || key;
776
+ header.appendChild(title);
777
+
778
+ if (preset.builtin) {
779
+ const badge = document.createElement('span');
780
+ badge.className = 'badge';
781
+ badge.textContent = 'built-in';
782
+ badge.style.cssText = 'font-size:.65rem;';
783
+ header.appendChild(badge);
784
+ }
785
+
786
+ const widthPill = document.createElement('span');
787
+ widthPill.className = 'badge badge-outline';
788
+ widthPill.textContent = WIDTH_LABELS[preset.width] || 'Normal';
789
+ widthPill.style.cssText = 'margin-left:auto;font-size:.65rem;';
790
+ header.appendChild(widthPill);
791
+
792
+ card.appendChild(header);
793
+
794
+ const body = document.createElement('div');
795
+ body.className = 'card-body';
796
+
797
+ if (preset.description) {
798
+ const desc = document.createElement('p');
799
+ desc.className = 'text-muted';
800
+ desc.style.cssText = 'font-size:.85em;margin:0 0 .5rem;';
801
+ desc.textContent = preset.description;
802
+ body.appendChild(desc);
803
+ }
804
+
805
+ // Toggle indicators
806
+ const indicators = document.createElement('div');
807
+ indicators.style.cssText = 'display:flex;gap:.75rem;flex-wrap:wrap;';
808
+ [['Navbar', preset.navbar], ['Footer', preset.footer], ['Sidebar', preset.sidebar]].forEach(([label, on]) => {
809
+ const dot = document.createElement('span');
810
+ dot.style.cssText = `font-size:.75em;color:${on ? 'var(--dm-success,#4caf50)' : 'var(--dm-text-muted,#666)'};display:flex;align-items:center;gap:.25rem;`;
811
+ dot.textContent = (on ? '● ' : '○ ') + label;
812
+ indicators.appendChild(dot);
813
+ });
814
+ body.appendChild(indicators);
815
+
816
+ // Bg colour swatch
817
+ if (preset.bgColor) {
818
+ const swatch = document.createElement('span');
819
+ swatch.title = preset.bgColor;
820
+ swatch.style.cssText = `display:inline-block;width:14px;height:14px;border-radius:3px;background:${preset.bgColor};border:1px solid var(--dm-border,#333);margin-top:.4rem;vertical-align:middle;`;
821
+ body.appendChild(swatch);
822
+ }
823
+
824
+ card.appendChild(body);
825
+ return card;
826
+ }
827
+
828
+ function openSlideover(key, preset, isNew, onSaved, onDeleted) {
829
+ const so = E.slideover({title: isNew ? 'New Layout' : 'Edit Layout', size: 'md'});
830
+
831
+ const form = document.createElement('div');
832
+ form.style.cssText = 'display:flex;flex-direction:column;gap:1rem;padding:1rem;';
833
+
834
+ const labelInp = textInput('e.g. Dark Landing', preset.label || '');
835
+ const descInp = textInput('Optional description', preset.description || '');
836
+ const widthSel = widthSelect(preset.width || 'normal');
837
+ const navbarCb = checkbox('Show Navbar', preset.navbar !== false);
838
+ const footerCb = checkbox('Show Footer', preset.footer !== false);
839
+ const sidebarCb = checkbox('Show Sidebar', preset.sidebar === true);
840
+ const bgCol = colourPicker(preset.bgColor || '');
841
+ const bgImageInp = textInput('https://...', preset.bgImage || '');
842
+ const cssClassInp = textInput('e.g. no-hero wide-content', preset.class || '');
843
+
844
+ form.appendChild(field('Label', labelInp));
845
+ form.appendChild(field('Description', descInp));
846
+ form.appendChild(field('Width', widthSel));
847
+
848
+ const toggleRow = document.createElement('div');
849
+ toggleRow.style.cssText = 'display:flex;gap:1.5rem;flex-wrap:wrap;';
850
+ toggleRow.appendChild(navbarCb.wrap);
851
+ toggleRow.appendChild(footerCb.wrap);
852
+ toggleRow.appendChild(sidebarCb.wrap);
853
+ form.appendChild(toggleRow);
854
+
855
+ form.appendChild(field('Background Colour', bgCol.wrap));
856
+ form.appendChild(field('Background Image URL', bgImageInp));
857
+ form.appendChild(field('Custom CSS Class', cssClassInp));
858
+
859
+ // Action buttons
860
+ const btnRow = document.createElement('div');
861
+ btnRow.style.cssText = 'display:flex;gap:.5rem;flex-wrap:wrap;padding-top:.5rem;border-top:1px solid var(--dm-border,#333);';
862
+
863
+ const saveBtn = document.createElement('button');
864
+ saveBtn.type = 'button';
865
+ saveBtn.className = 'btn btn-primary';
866
+ saveBtn.textContent = 'Save';
867
+
868
+ btnRow.appendChild(saveBtn);
869
+
870
+ if (!isNew && !preset.builtin) {
871
+ const delBtn = document.createElement('button');
872
+ delBtn.type = 'button';
873
+ delBtn.className = 'btn btn-danger';
874
+ delBtn.style.marginLeft = 'auto';
875
+ delBtn.textContent = 'Delete';
876
+ delBtn.addEventListener('click', async () => {
877
+ const confirmed = await E.confirm(`Delete the "${preset.label || key}" layout?`);
878
+ if (!confirmed) return;
879
+ try {
880
+ await api.layouts.remove(key);
881
+ E.toast('Layout deleted.', {type: 'success'});
882
+ so.close();
883
+ onDeleted(key);
884
+ } catch (err) {
885
+ E.toast(err?.message || 'Failed to delete layout.', {type: 'error'});
886
+ }
887
+ });
888
+ btnRow.appendChild(delBtn);
889
+ }
890
+
891
+ form.appendChild(btnRow);
892
+
893
+ saveBtn.addEventListener('click', async () => {
894
+ const label = labelInp.value.trim();
895
+ if (!label) { E.toast('Label is required.', {type: 'warning'}); return; }
896
+
897
+ const data = {
898
+ label,
899
+ description: descInp.value.trim(),
900
+ width: widthSel.value,
901
+ navbar: navbarCb.cb.checked,
902
+ footer: footerCb.cb.checked,
903
+ sidebar: sidebarCb.cb.checked,
904
+ bgColor: bgCol.getValue(),
905
+ bgImage: bgImageInp.value.trim(),
906
+ class: cssClassInp.value.trim()
907
+ };
908
+
909
+ try {
910
+ let result;
911
+ if (isNew) {
912
+ result = await api.layouts.create(data);
913
+ } else {
914
+ result = await api.layouts.update(key, data);
915
+ }
916
+ E.toast('Layout saved.', {type: 'success'});
917
+ so.close();
918
+ onSaved(isNew ? result.key : key, result.preset || {...preset, ...data});
919
+ } catch (err) {
920
+ E.toast(err?.message || 'Failed to save layout.', {type: 'error'});
921
+ }
922
+ });
923
+
924
+ so.element.appendChild(form);
925
+ so.open();
926
+ }
927
+
928
+ export const layoutsView = {
929
+ templateUrl: '/admin/js/templates/layouts.html',
930
+
931
+ async onMount($container) {
932
+ E.tabs($container.find('#layouts-tabs').get(0));
933
+
934
+ // ── Layout Presets tab ──────────────────────────────────────────────
935
+ let presets = await api.layouts.get().catch(() => ({}));
936
+ const $grid = $container.find('#presets-grid').empty();
937
+
938
+ function renderGrid() {
939
+ $grid.empty();
940
+ const gridEl = document.createElement('div');
941
+ gridEl.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1rem;';
942
+
943
+ Object.entries(presets).forEach(([key, preset]) => {
944
+ const card = buildCard(key, preset, (k, p) => {
945
+ openSlideover(k, p, false,
946
+ (savedKey, savedPreset) => {
947
+ presets[savedKey] = {...presets[savedKey], ...savedPreset};
948
+ renderGrid();
949
+ },
950
+ (deletedKey) => {
951
+ delete presets[deletedKey];
952
+ renderGrid();
953
+ }
954
+ );
955
+ });
956
+ gridEl.appendChild(card);
957
+ });
958
+
959
+ $grid.get(0).appendChild(gridEl);
960
+ I.scan($grid.get(0));
961
+ }
962
+
963
+ renderGrid();
964
+
965
+ $container.find('#create-layout-btn').on('click', () => {
966
+ openSlideover(null, {}, true,
967
+ (newKey, newPreset) => {
968
+ presets[newKey] = newPreset;
969
+ renderGrid();
970
+ },
971
+ () => {}
972
+ );
973
+ });
974
+
975
+ // ── Layout Options tab ──────────────────────────────────────────────
976
+ const options = await api.layouts.getOptions().catch(() => ({spacerSize: 8, spacerClass: ''}));
977
+ const $spacerSize = $container.find('#spacer-size-input');
978
+ const $spacerClass = $container.find('#spacer-class-input');
979
+ $spacerSize.val(options.spacerSize ?? 8);
980
+ $spacerClass.val(options.spacerClass ?? '');
981
+
982
+ $container.find('#save-options-btn').on('click', async () => {
983
+ const spacerSize = parseInt($spacerSize.val(), 10) || 8;
984
+ const spacerClass = $spacerClass.val().trim();
985
+ try {
986
+ await api.layouts.saveOptions({spacerSize, spacerClass});
987
+ E.toast('Layout options saved.', {type: 'success'});
988
+ } catch {
989
+ E.toast('Failed to save layout options.', {type: 'error'});
990
+ }
991
+ });
992
+ }
993
+ };
994
+ ```
995
+
996
+ - [ ] **Step 1: Replace `admin/js/templates/layouts.html` with Part A above**
997
+
998
+ - [ ] **Step 2: Replace `admin/js/views/layouts.js` with Part B above**
999
+
1000
+ - [ ] **Step 3: Smoke test in the browser**
1001
+ - Open the admin panel → Layouts
1002
+ - Verify the preset cards render (Default, Landing, Blank, With Sidebar, Minimal)
1003
+ - Click a card → slideover opens with all fields pre-filled
1004
+ - Edit a non-builtin card → Save → card updates in grid → success toast
1005
+ - Click "New Layout" → fill in label → Save → new card appears
1006
+ - Delete a non-builtin preset → confirm dialog → card disappears
1007
+ - Try to delete a built-in preset from the API directly:
1008
+ `curl -X DELETE http://localhost:4096/api/layouts/default` → should return 400
1009
+
1010
+ - [ ] **Step 4: Commit**
1011
+
1012
+ ```bash
1013
+ git add admin/js/templates/layouts.html admin/js/views/layouts.js
1014
+ git commit -m "feat(layouts): rebuild admin UI with CRUD card grid and slideover edit form"
1015
+ ```