domma-cms 1.6.60 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4344 -0
- package/README.md +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +2 -2
- package/admin/js/lib/page-editor-reference.js +5 -5
- package/admin/js/lib/shortcode-tags.js +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +39 -0
- package/docs/DommaFeatures.md +3005 -0
- package/docs/MultiTenant.md +176 -0
- package/docs/api-reference.md +1667 -0
- package/docs/cache.md +96 -0
- package/docs/configuration.md +1811 -0
- package/docs/editions-and-licences.md +190 -0
- package/docs/getting-started.md +201 -0
- package/docs/guides/form-triggers.md +260 -0
- package/docs/guides/mongodb-storage-adapter.md +282 -0
- package/docs/guides/multi-page-forms.md +121 -0
- package/docs/images/scribe-composer.png +0 -0
- package/docs/markdown-shortcodes.md +1738 -0
- package/docs/pentest-report-2026-04-03.md +181 -0
- package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
- package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
- package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
- package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
- package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
- package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
- package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
- package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
- package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
- package/docs/plans/2026-03-05-user-types-design.md +139 -0
- package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
- package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
- package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
- package/docs/plans/2026-06-10-core-project-design.md +240 -0
- package/docs/plugin-development.md +827 -0
- package/docs/recruitment-recipe.md +501 -0
- package/docs/scaffolding.md +312 -0
- package/docs/screenshots/calculator-floating.png +0 -0
- package/docs/screenshots/calculator-standard.png +0 -0
- package/docs/screenshots/email-settings.png +0 -0
- package/docs/screenshots/messages-count.png +0 -0
- package/docs/structure/user-types.md +107 -0
- package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
- package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
- package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
- package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
- package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
- package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
- package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
- package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
- package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
- package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
- package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
- package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
- package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
- package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
- package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
- package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
- package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
- package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
- package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
- package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
- package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
- package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
- package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
- package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
- package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
- package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
- package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
- package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
- package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
- package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
- package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
- package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
- package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
- package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
- package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
- package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
- package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
- package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
- package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
- package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
- package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
- package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
- package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
- package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
- package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
- package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
- package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
- package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
- package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
- package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
- package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
- package/docs/theming.md +320 -0
- package/package.json +2 -2
- package/public/js/celebrations/core/canvas.js +1 -1
- package/public/js/celebrations/index.js +1 -1
- package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
- package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
- package/public/js/celebrations/message-attrs.mjs +1 -0
- package/public/js/celebrations/themes/anniversary.js +1 -0
- package/public/js/celebrations/themes/autumn.js +1 -0
- package/public/js/celebrations/themes/birthday.js +1 -0
- package/public/js/celebrations/themes/butterfly-flight.js +1 -0
- package/public/js/celebrations/themes/calendar.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year.js +1 -0
- package/public/js/celebrations/themes/christmas.js +1 -1
- package/public/js/celebrations/themes/easter.js +1 -0
- package/public/js/celebrations/themes/garden-drawing.js +1 -0
- package/public/js/celebrations/themes/garden.js +1 -0
- package/public/js/celebrations/themes/graduation.js +1 -0
- package/public/js/celebrations/themes/greeting.js +1 -0
- package/public/js/celebrations/themes/make-it-rain.js +1 -0
- package/public/js/celebrations/themes/money-rain.js +1 -0
- package/public/js/celebrations/themes/new-year.js +1 -0
- package/public/js/celebrations/themes/occasion-drawing.js +1 -0
- package/public/js/celebrations/themes/occasions.js +1 -0
- package/public/js/celebrations/themes/rain-control.js +1 -0
- package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
- package/public/js/celebrations/themes/sparkler-font.js +1 -0
- package/public/js/celebrations/themes/sparkler-writing.js +1 -0
- package/public/js/celebrations/themes/summer-garden.js +1 -0
- package/public/js/celebrations/themes/winter.js +1 -0
- package/public/js/celebrations/trait-data.mjs +1 -1
- package/public/js/effects.js +1 -1
- package/scripts/build.js +4 -0
- package/server/services/markdown.js +5 -2
- 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
|
+
```
|