syntaxmatrix 2.5.5.5__py3-none-any.whl → 2.6.2__py3-none-any.whl

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 (44) hide show
  1. syntaxmatrix/__init__.py +3 -2
  2. syntaxmatrix/agentic/agents.py +1220 -169
  3. syntaxmatrix/agentic/agents_orchestrer.py +326 -0
  4. syntaxmatrix/agentic/code_tools_registry.py +27 -32
  5. syntaxmatrix/auth.py +142 -5
  6. syntaxmatrix/commentary.py +16 -16
  7. syntaxmatrix/core.py +192 -84
  8. syntaxmatrix/db.py +460 -4
  9. syntaxmatrix/{display.py → display_html.py} +2 -6
  10. syntaxmatrix/gpt_models_latest.py +1 -1
  11. syntaxmatrix/media/__init__.py +0 -0
  12. syntaxmatrix/media/media_pixabay.py +277 -0
  13. syntaxmatrix/models.py +1 -1
  14. syntaxmatrix/page_builder_defaults.py +183 -0
  15. syntaxmatrix/page_builder_generation.py +1122 -0
  16. syntaxmatrix/page_layout_contract.py +644 -0
  17. syntaxmatrix/page_patch_publish.py +1471 -0
  18. syntaxmatrix/preface.py +670 -0
  19. syntaxmatrix/profiles.py +28 -10
  20. syntaxmatrix/routes.py +1941 -593
  21. syntaxmatrix/selftest_page_templates.py +360 -0
  22. syntaxmatrix/settings/client_items.py +28 -0
  23. syntaxmatrix/settings/model_map.py +1022 -207
  24. syntaxmatrix/settings/prompts.py +328 -130
  25. syntaxmatrix/static/assets/hero-default.svg +22 -0
  26. syntaxmatrix/static/icons/bot-icon.png +0 -0
  27. syntaxmatrix/static/icons/favicon.png +0 -0
  28. syntaxmatrix/static/icons/logo.png +0 -0
  29. syntaxmatrix/static/icons/logo3.png +0 -0
  30. syntaxmatrix/templates/admin_branding.html +104 -0
  31. syntaxmatrix/templates/admin_features.html +63 -0
  32. syntaxmatrix/templates/admin_secretes.html +108 -0
  33. syntaxmatrix/templates/change_password.html +124 -0
  34. syntaxmatrix/templates/dashboard.html +296 -131
  35. syntaxmatrix/templates/dataset_resize.html +535 -0
  36. syntaxmatrix/templates/edit_page.html +2535 -0
  37. syntaxmatrix/utils.py +2728 -2835
  38. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/METADATA +6 -2
  39. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/RECORD +42 -25
  40. syntaxmatrix/generate_page.py +0 -634
  41. syntaxmatrix/static/icons/hero_bg.jpg +0 -0
  42. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/WHEEL +0 -0
  43. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/licenses/LICENSE.txt +0 -0
  44. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,2535 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>Edit page · {{ page_name }}</title>
7
+
8
+ <!-- SortableJS (drag & drop) -->
9
+ <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
10
+
11
+ <!-- CodeMirror (real code editor: syntax colours + matching brackets) -->
12
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"/>
13
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
14
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/xml/xml.min.js"></script>
15
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/javascript/javascript.min.js"></script>
16
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/css/css.min.js"></script>
17
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
18
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/edit/closetag.min.js"></script>
19
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/edit/closebrackets.min.js"></script>
20
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/edit/matchbrackets.min.js"></script>
21
+
22
+ <style>
23
+ :root{
24
+ --bg0:#020617;
25
+ --bg1:#0b1224;
26
+ --bg2:#0f1b33;
27
+ --line:rgba(148,163,184,.25);
28
+ --text:#e5e7eb;
29
+ --muted:#9ca3af;
30
+ --good:#22c55e;
31
+ --bad:#ef4444;
32
+ --brandA:#0ea5e9;
33
+ --brandB:#22c55e;
34
+ --cardShadow: 0 20px 40px rgba(15,23,42,.75), 0 0 0 .5px rgba(148,163,184,.35);
35
+ --radius:14px;
36
+ --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
37
+ --ui: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
38
+ }
39
+ *{box-sizing:border-box}
40
+ html, body{
41
+ height: 100%;
42
+ }
43
+ body{
44
+ margin:0;
45
+ font-family:var(--ui);
46
+ background: radial-gradient(circle at 0 0, #1e293b 0, var(--bg0) 55%, #000 100%);
47
+ color:var(--text);
48
+
49
+ /* NEW: prevent whole-page scrolling */
50
+ overflow: hidden;
51
+ }
52
+
53
+ .topbar{
54
+ position:sticky; top:0; z-index:50;
55
+ background: rgba(2,6,23,.78);
56
+ backdrop-filter: blur(10px);
57
+ border-bottom:1px solid var(--line);
58
+ padding:14px 16px;
59
+ display:flex; gap:12px; align-items:center; justify-content:space-between;
60
+ }
61
+ .topbar .left{
62
+ display:flex; flex-direction:column; gap:2px;
63
+ min-width: 280px;
64
+ }
65
+ .topbar h1{margin:0; font-size:1.05rem; font-weight:650;}
66
+ .topbar .sub{color:var(--muted); font-size:.82rem;}
67
+ .topbar .right{display:flex; gap:8px; align-items:center; flex-wrap:wrap; justify-content:flex-end;}
68
+ .pill{
69
+ font-size:.72rem;
70
+ padding:3px 9px;
71
+ border-radius:999px;
72
+ border:1px solid var(--line);
73
+ color:var(--muted);
74
+ }
75
+ .pill.ok{border-color:rgba(34,197,94,.7); color:#bbf7d0;}
76
+ .pill.fail{border-color:rgba(239,68,68,.7); color:#fecaca;}
77
+
78
+ .btn{
79
+ border:1px solid var(--line);
80
+ background: transparent;
81
+ color: var(--text);
82
+ padding:8px 12px;
83
+ border-radius:999px;
84
+ cursor:pointer;
85
+ font-weight:600;
86
+ font-size:.85rem;
87
+ }
88
+ .btn:hover{border-color:rgba(56,189,248,.65); color:#38bdf8;}
89
+ .btn.primary{
90
+ border:none;
91
+ background: linear-gradient(135deg, var(--brandA), var(--brandB));
92
+ color:#07111f;
93
+ box-shadow: 0 12px 24px rgba(8,47,73,.75);
94
+ }
95
+ .btn.primary:hover{filter:brightness(1.06);}
96
+ .btn.small{padding:6px 10px; font-size:.78rem; font-weight:650;}
97
+ .btn.danger{border-color:rgba(239,68,68,.55); color:#fecaca;}
98
+ .btn.danger:hover{border-color:rgba(239,68,68,.9); color:#fff;}
99
+
100
+ .wrap{
101
+ display:grid;
102
+ grid-template-columns: 290px minmax(0, 1fr) 340px;
103
+ gap: 12px;
104
+ padding: 12px;
105
+
106
+ /* NEW: fill viewport below topbar */
107
+ height: calc(100vh - 86px);
108
+ align-items: stretch;
109
+ grid-template-rows: 1fr;
110
+ min-height: 0;
111
+ }
112
+ .wrap > *{
113
+ min-height: 0;
114
+ }
115
+
116
+ .panel{
117
+ border:1px solid var(--line);
118
+ background: rgba(2,6,23,.65);
119
+ border-radius: var(--radius);
120
+ box-shadow: var(--cardShadow);
121
+ overflow:hidden;
122
+ }
123
+ .panel .hd{
124
+ padding:12px 12px 10px;
125
+ border-bottom:1px solid var(--line);
126
+ display:flex; justify-content:space-between; align-items:center;
127
+ }
128
+ .panel .hd h2{margin:0; font-size:.9rem; font-weight:750;}
129
+ .panel .bd{padding:12px;}
130
+ .muted{color:var(--muted); font-size:.82rem; line-height:1.35;}
131
+
132
+ .palette{display:flex; flex-direction:column; gap:8px;}
133
+ .widget{
134
+ padding:10px 10px;
135
+ border-radius:12px;
136
+ border:1px dashed rgba(148,163,184,.35);
137
+ background: rgba(15,23,42,.55);
138
+ cursor:grab;
139
+ user-select:none;
140
+ display:flex; align-items:center; justify-content:space-between;
141
+ gap:10px;
142
+ }
143
+ .widget b{font-size:.86rem;}
144
+ .widget span{color:var(--muted); font-size:.75rem;}
145
+ .widget:active{cursor:grabbing;}
146
+ .hint{margin-top:8px; color:var(--muted); font-size:.78rem;}
147
+
148
+ .canvas{
149
+ height: 100%;
150
+ min-height: 0;
151
+ border:1px solid var(--line);
152
+ border-radius: var(--radius);
153
+ background: rgba(2,6,23,.35);
154
+ box-shadow: var(--cardShadow);
155
+
156
+ /* ✅ canvas is the scroll container */
157
+ overflow-y: auto;
158
+ overflow-x: hidden;
159
+
160
+ /* optional but nice: stop scroll chaining */
161
+ overscroll-behaviour: contain;
162
+ }
163
+
164
+ /* scroll area inside the canvas */
165
+ .canvas .sections{
166
+ padding:12px;
167
+ display:flex;
168
+ flex-direction:column;
169
+ gap:10px;
170
+
171
+ /* ✅ not the scroller anymore */
172
+ overflow: visible;
173
+ height: auto;
174
+ }
175
+ .canvas .hd{
176
+ position: sticky;
177
+ top: 0;
178
+ z-index: 5;
179
+ }
180
+ .canvas .hd .title{display:flex; flex-direction:column; gap:2px;}
181
+ .canvas .hd .title b{font-size:.92rem;}
182
+ .canvas .hd .title small{color:var(--muted); font-size:.78rem;}
183
+
184
+ .section{
185
+ border:1px solid rgba(148,163,184,.22);
186
+ background: rgba(15,23,42,.45);
187
+ border-radius: 16px;
188
+ overflow:hidden;
189
+ }
190
+ .section.selected{outline:2px solid rgba(56,189,248,.55);}
191
+ .section .shd{
192
+ padding:10px 10px;
193
+ border-bottom:1px solid rgba(148,163,184,.18);
194
+ display:flex; align-items:center; justify-content:space-between; gap:10px;
195
+ }
196
+ .handle{
197
+ font-family: var(--mono);
198
+ color: rgba(148,163,184,.9);
199
+ padding: 2px 7px;
200
+ border-radius: 10px;
201
+ border: 1px solid rgba(148,163,184,.22);
202
+ cursor:grab;
203
+ user-select:none;
204
+ }
205
+ .section .meta{
206
+ display:flex; align-items:center; gap:10px; flex-wrap:wrap;
207
+ }
208
+ .section .meta b{font-size:.86rem;}
209
+ .chip{
210
+ font-size:.72rem;
211
+ padding:2px 8px;
212
+ border-radius:999px;
213
+ border:1px solid rgba(148,163,184,.22);
214
+ color: var(--muted);
215
+ }
216
+ .section .actions{display:flex; gap:6px; align-items:center;}
217
+ .section .sbd{padding:10px;}
218
+
219
+ .items{
220
+ display:grid;
221
+ gap:10px;
222
+ }
223
+ .items.cols-1{grid-template-columns: 1fr;}
224
+ .items.cols-2{grid-template-columns: repeat(2, 1fr);}
225
+ .items.cols-3{grid-template-columns: repeat(3, 1fr);}
226
+ .items.cols-4{grid-template-columns: repeat(4, 1fr);}
227
+ .items.cols-5{grid-template-columns: repeat(5, 1fr);}
228
+ @media (max-width: 1100px){
229
+ .wrap{grid-template-columns: 1fr; }
230
+ .canvas{min-height: 0;}
231
+ .items.cols-4,.items.cols-5{grid-template-columns: repeat(2, 1fr);}
232
+ }
233
+ @media (max-width: 520px){
234
+ .items.cols-2,.items.cols-3,.items.cols-4,.items.cols-5{grid-template-columns: 1fr;}
235
+ }
236
+
237
+ .item{
238
+ border:1px solid rgba(148,163,184,.22);
239
+ background: rgba(2,6,23,.55);
240
+ border-radius: 14px;
241
+ padding:10px;
242
+ display:flex; flex-direction:column; gap:8px;
243
+ cursor:pointer;
244
+ }
245
+ .item.selected{outline:2px solid rgba(34,197,94,.5);}
246
+ .item .cta-drop{
247
+ margin-top:6px;
248
+ padding:8px 10px;
249
+ border-radius: 12px;
250
+ border: 1px dashed rgba(148,163,184,.35);
251
+ color: rgba(148,163,184,.95);
252
+ font-size: .92rem;
253
+ user-select:none;
254
+ }
255
+ .item .cta-drop.has{
256
+ border-style: solid;
257
+ color: rgba(229,231,235,.92);
258
+ }
259
+ .item .cta-drop.over{
260
+ border-color: rgba(34,197,94,.65);
261
+ box-shadow: 0 0 0 2px rgba(34,197,94,.18) inset;
262
+ }
263
+ .item .thumb{
264
+ border-radius: 12px;
265
+ border:1px solid rgba(148,163,184,.18);
266
+ background: rgba(15,23,42,.55);
267
+ height: 110px;
268
+ display:flex; align-items:center; justify-content:center;
269
+ overflow:hidden;
270
+ position:relative;
271
+ }
272
+ .item .thumb img{width:100%; height:100%; object-fit:cover; display:block;}
273
+ .item .thumb .drop{
274
+ position:absolute; inset:0;
275
+ display:flex; align-items:center; justify-content:center;
276
+ color: rgba(229,231,235,.85);
277
+ font-size:.75rem;
278
+ opacity:.0;
279
+ background: rgba(2,6,23,.45);
280
+ transition: opacity .12s ease;
281
+ }
282
+ .item:hover .thumb .drop{opacity:1;}
283
+ .item .title{font-weight:750; font-size:.86rem;}
284
+ .item .text{color:var(--muted); font-size:.8rem; line-height:1.25;}
285
+
286
+ .item .cta-drop{
287
+ margin-top:6px;
288
+ padding:8px 10px;
289
+ border-radius: 12px;
290
+ border: 1px dashed rgba(148,163,184,.35);
291
+ color: rgba(148,163,184,.95);
292
+ font-size: .78rem;
293
+ user-select:none;
294
+ }
295
+ .item .cta-drop.has{
296
+ border-style: solid;
297
+ color: rgba(229,231,235,.92);
298
+ }
299
+ .item .cta-drop.over{
300
+ border-color: rgba(34,197,94,.65);
301
+ box-shadow: 0 0 0 2px rgba(34,197,94,.18) inset;
302
+ }
303
+
304
+ .tabs{display:flex; gap:6px; padding:10px; border-bottom:1px solid var(--line); background: rgba(2,6,23,.55);}
305
+ .tabbtn{
306
+ flex:1;
307
+ padding:8px 10px;
308
+ border-radius: 12px;
309
+ border:1px solid rgba(148,163,184,.22);
310
+ background: rgba(15,23,42,.35);
311
+ color: var(--text);
312
+ font-weight:700;
313
+ cursor:pointer;
314
+ font-size:.82rem;
315
+ }
316
+ .tabbtn.active{
317
+ border-color: rgba(56,189,248,.55);
318
+ box-shadow: inset 0 0 0 1px rgba(56,189,248,.35);
319
+ color:#38bdf8;
320
+ }
321
+ .tab{display:none; padding:12px;}
322
+ .tab.active{display:block;}
323
+
324
+ .field{display:flex; flex-direction:column; gap:6px; margin-bottom:10px;}
325
+ .field label{font-size:.78rem; font-weight:750; color:#d1d5db;}
326
+ .field input, .field textarea, .field select{
327
+ width:100%;
328
+ padding:8px 10px;
329
+ border-radius: 12px;
330
+ border:1px solid rgba(148,163,184,.28);
331
+ background: rgba(2,6,23,.75);
332
+ color: var(--text);
333
+ outline:none;
334
+ font-size:.85rem;
335
+ }
336
+ .field textarea{min-height:92px; resize:vertical; font-family: var(--ui);}
337
+ .row{display:flex; gap:8px; align-items:center;}
338
+ .row > *{flex:1;}
339
+ .hr{height:1px; background: var(--line); margin:12px 0;}
340
+
341
+ .media-grid{
342
+ display:grid;
343
+ grid-template-columns: repeat(3, 1fr);
344
+ gap:8px;
345
+ }
346
+ .media-tile{
347
+ border:1px solid rgba(148,163,184,.22);
348
+ border-radius: 12px;
349
+ background: rgba(15,23,42,.35);
350
+ overflow:hidden;
351
+ cursor:grab;
352
+ height: 88px;
353
+ position:relative;
354
+ }
355
+ .media-tile img{width:100%; height:100%; object-fit:cover; display:block;}
356
+ .media-tile .cap{
357
+ position:absolute; left:0; right:0; bottom:0;
358
+ padding:4px 6px;
359
+ background: rgba(2,6,23,.65);
360
+ color: rgba(229,231,235,.9);
361
+ font-size:.68rem;
362
+ white-space:nowrap;
363
+ overflow:hidden;
364
+ text-overflow:ellipsis;
365
+ }
366
+ .media-empty{
367
+ color:var(--muted);
368
+ font-size:.82rem;
369
+ padding:10px;
370
+ border:1px dashed rgba(148,163,184,.25);
371
+ border-radius: 12px;
372
+ background: rgba(2,6,23,.35);
373
+ }
374
+
375
+ /* Code editor block */
376
+ .codewrap{
377
+ margin: 12px;
378
+ border:1px solid var(--line);
379
+ border-radius: var(--radius);
380
+ overflow:hidden;
381
+ box-shadow: var(--cardShadow);
382
+ background: rgba(2,6,23,.65);
383
+ }
384
+ .codewrap .hd{
385
+ padding:12px;
386
+ border-bottom:1px solid var(--line);
387
+ display:flex; justify-content:space-between; align-items:center; gap:10px;
388
+ background: rgba(2,6,23,.55);
389
+ }
390
+ .codewrap .hd b{font-size:.9rem;}
391
+ .codewrap .bd{padding:12px;}
392
+ .CodeMirror{
393
+ height: 520px;
394
+ font-family: var(--mono);
395
+ font-size: 13.5px;
396
+ border-radius: 14px;
397
+ border: 1px solid rgba(15,23,42,.95);
398
+ }
399
+
400
+ /* CodeMirror theme */
401
+ .cm-s-smx-dark.CodeMirror{background: var(--bg0); color: var(--text);}
402
+ .cm-s-smx-dark .CodeMirror-gutters{background: var(--bg0); border-right:1px solid rgba(30,64,175,.85); color:#6b7280;}
403
+ .cm-s-smx-dark .CodeMirror-linenumber{color:#4b5563;}
404
+ .cm-s-smx-dark span{background:none;}
405
+ .cm-s-smx-dark .cm-tag{color:#60a5fa;}
406
+ .cm-s-smx-dark .cm-attribute{color:#facc15;}
407
+ .cm-s-smx-dark .cm-string{color:#4ade80;}
408
+ .cm-s-smx-dark .cm-number{color:#fb923c;}
409
+ .cm-s-smx-dark .cm-keyword{color:#f97373;}
410
+ .cm-s-smx-dark .cm-comment{color:#9ca3af; font-style:italic;}
411
+ .cm-s-smx-dark .cm-error{background:none; color:#f97373; border-bottom:1px dotted #f97373;}
412
+ .cm-s-smx-dark .CodeMirror-matchingbracket{
413
+ background-color: rgba(34,197,94,.18) !important;
414
+ border-radius: 3px;
415
+ border: 1px solid rgba(34,197,94,.9);
416
+ color: #f9fafb !important;
417
+ }
418
+ .cm-s-smx-dark .CodeMirror-nonmatchingbracket{
419
+ background-color: rgba(239,68,68,.18) !important;
420
+ border-radius: 3px;
421
+ border: 1px solid rgba(239,68,68,.95);
422
+ color: #fee2e2 !important;
423
+ }
424
+ .widget, .section, .item { cursor: grab; user-select: none; }
425
+ .widget:active, .section:active, .item:active { cursor: grabbing; }
426
+
427
+ .panel{
428
+ height: 100%;
429
+ display:flex;
430
+ flex-direction:column;
431
+ overflow:hidden;
432
+ }
433
+ .panel .bd{
434
+ overflow:auto;
435
+ min-height:0;
436
+ }
437
+ .tab.active{
438
+ overflow:auto;
439
+ min-height:0;
440
+ }
441
+ </style>
442
+ </head>
443
+
444
+ <body>
445
+ <div class="topbar">
446
+ <div class="left">
447
+ <h1>Edit page</h1>
448
+ <div class="sub">
449
+ Editing <code style="font-family:var(--mono)">{{ page_name }}</code> · Builder + HTML editor (CodeMirror)
450
+ </div>
451
+ </div>
452
+
453
+ {% if published_as %}
454
+ <div id="publishedBanner" style="
455
+ max-width:1100px; margin:12px auto 0; padding:10px 12px;
456
+ border-radius:12px;
457
+ border:1px solid rgba(34,197,94,.45);
458
+ background: rgba(34,197,94,.10);
459
+ color:#bbf7d0;
460
+ display:flex; align-items:center; justify-content:space-between; gap:10px;">
461
+ <div>
462
+ <strong>Published as:</strong>
463
+ <code style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;">
464
+ {{ published_as }}
465
+ </code>
466
+ </div>
467
+ <button type="button" id="closePublishedBanner" style="
468
+ border:1px solid rgba(148,163,184,.35);
469
+ background:transparent; color:#e5e7eb;
470
+ border-radius:999px; padding:4px 10px; cursor:pointer;">
471
+ OK
472
+ </button>
473
+ </div>
474
+
475
+ <script>
476
+ (function () {
477
+ // Remove published_as from the URL so the banner shows once only.
478
+ try {
479
+ const u = new URL(window.location.href);
480
+ u.searchParams.delete("published_as");
481
+ const qs = u.searchParams.toString();
482
+ history.replaceState({}, document.title, u.pathname + (qs ? "?" + qs : "") + u.hash);
483
+ } catch (e) {}
484
+
485
+ const btn = document.getElementById("closePublishedBanner");
486
+ const banner = document.getElementById("publishedBanner");
487
+ if (btn && banner) btn.addEventListener("click", () => banner.remove());
488
+ })();
489
+ </script>
490
+ {% endif %}
491
+
492
+ <div class="right">
493
+ <span id="status" class="pill">Ready</span>
494
+ <a class="btn" href="{{ url_for('admin_panel') }}">Back to Admin</a>
495
+ <a class="btn" target="_blank" href="{{ url_for('view_page', page_name=page_name) }}">Preview</a>
496
+ <button class="btn" id="btn-save-layout" type="button">Save layout</button>
497
+ <button class="btn primary" id="btn-generate-html" type="button">Update</button>
498
+ <button class="btn primary" id="btn-publish-layout" type="button">Publish layout</button>
499
+ </div>
500
+ </div>
501
+
502
+ <div class="wrap">
503
+ <!-- LEFT: palette -->
504
+ <div class="panel">
505
+ <div class="hd"><h2>Drag sections</h2></div>
506
+ <div class="bd">
507
+ <div id="section-palette" class="palette">
508
+ <div class="widget" data-kind="section" data-type="hero"><b>Hero</b><span>headline + CTA</span></div>
509
+ <div class="widget" data-kind="section" data-type="features"><b>Features</b><span>cards grid</span></div>
510
+ <div class="widget" data-kind="section" data-type="gallery"><b>Gallery</b><span>images grid</span></div>
511
+ <div class="widget" data-kind="section" data-type="testimonials"><b>Testimonials</b><span>quotes</span></div>
512
+ <div class="widget" data-kind="section" data-type="faq"><b>FAQ</b><span>questions</span></div>
513
+ <div class="widget" data-kind="section" data-type="cta"><b>CTA block</b><span>banner</span></div>
514
+ <div class="widget" data-kind="section" data-type="richtext"><b>Rich text</b><span>free content</span></div>
515
+ </div>
516
+
517
+ <div class="hr"></div>
518
+
519
+ <div class="muted"><b>Drag items</b> (into a section):</div>
520
+ <div id="item-palette" class="palette" style="margin-top:8px;">
521
+ <div class="widget" data-kind="item" data-type="card"><b>Card</b><span>title + text + image</span></div>
522
+ <div class="widget" data-kind="item" data-type="image"><b>Image</b><span>single media</span></div>
523
+ <div class="widget" data-kind="item" data-type="faq"><b>FAQ item</b><span>Q + A</span></div>
524
+ <div class="widget" data-kind="item" data-type="quote"><b>Quote</b><span>name + quote</span></div>
525
+ </div>
526
+
527
+ <div class="hr"></div>
528
+
529
+ <div class="muted"><b>Card actions</b> (drop onto a card):</div>
530
+ <div id="card-action-palette" class="palette" style="margin-top:8px;">
531
+ <div class="widget" draggable="true" data-action="card-cta">
532
+ <b>Button</b><span>attach to a card</span></div>
533
+ </div>
534
+
535
+ <div class="hr"></div>
536
+
537
+ <div class="hint">
538
+ Tip: Sections accept items. Each item can have its own image (perfect for feature cards with icons/images).
539
+ </div>
540
+ </div>
541
+ </div>
542
+
543
+ <!-- MIDDLE: builder canvas -->
544
+ <div class="canvas">
545
+ <div class="hd">
546
+ <div class="title">
547
+ <b>Page builder</b>
548
+ <small>Drag sections here · drag items into a section · click to edit</small>
549
+ </div>
550
+ <div class="row" style="max-width:420px;">
551
+ <select id="default-cols" title="Default grid columns">
552
+ <option value="1">Default: 1 col</option>
553
+ <option value="2">Default: 2 cols</option>
554
+ <option value="3" selected>Default: 3 cols</option>
555
+ <option value="4">Default: 4 cols</option>
556
+ <option value="5">Default: 5 cols</option>
557
+ </select>
558
+ <button class="btn danger" id="btn-clear" type="button">Clear</button>
559
+ </div>
560
+ </div>
561
+ <div class="sections" id="sections"></div>
562
+ </div>
563
+
564
+ <!-- RIGHT: inspector + media + ai -->
565
+ <div class="panel">
566
+ <div class="tabs">
567
+ <button class="tabbtn active" data-tab="inspector" type="button">Inspector</button>
568
+ <button class="tabbtn" data-tab="design" type="button">Design</button>
569
+ <button class="tabbtn" data-tab="media" type="button">Media</button>
570
+ <button class="tabbtn" data-tab="pixabay" type="button">Online images</button>
571
+ </div>
572
+
573
+ <div class="tab active" id="tab-inspector">
574
+ <div class="muted" id="insp-empty">
575
+ Click a section or item to edit it.
576
+ </div>
577
+
578
+ <div id="insp" style="display:none;">
579
+ <div class="field">
580
+ <label>Selected</label>
581
+ <input id="insp-kind" disabled />
582
+ </div>
583
+
584
+ <div class="field">
585
+ <label>Title</label>
586
+ <input id="insp-title" placeholder="Title"/>
587
+ </div>
588
+
589
+ <div class="field">
590
+ <label>Text</label>
591
+ <textarea id="insp-text" placeholder="Text / description"></textarea>
592
+ </div>
593
+
594
+ <div class="row">
595
+ <div class="field" id="field-cols">
596
+ <label>Columns</label>
597
+ <select id="insp-cols">
598
+ <option>1</option><option>2</option><option>3</option><option>4</option><option>5</option>
599
+ </select>
600
+ </div>
601
+
602
+ <div class="field" id="field-img">
603
+ <label>Image URL</label>
604
+ <input id="insp-img" placeholder="/uploads/media/images/..." />
605
+ </div>
606
+ <div class="field" id="field-href" style="display:none;">
607
+ <label>Button link URL</label>
608
+ <input id="insp-href" placeholder="/page/contact or https://example.com" />
609
+ </div>
610
+
611
+ <!-- Sprint 3: card CTA alignment -->
612
+ <div class="field" id="field-cta-align" style="display:none; min-width:180px;">
613
+ <label>Button alignment</label>
614
+ <select id="insp-cta-align">
615
+ <option value="">Default (Left)</option>
616
+ <option value="left">Left</option>
617
+ <option value="center">Centre</option>
618
+ <option value="right">Right</option>
619
+ <option value="full">Full width</option>
620
+ </select>
621
+ </div>
622
+ </div>
623
+
624
+ <!-- HERO overlay buttons (only shown when Hero section is selected) -->
625
+ <div id="hero-ctas" style="display:none;">
626
+ <div class="hr"></div>
627
+ <div class="muted"><b>Hero buttons</b> (leave URL blank to hide a button)</div>
628
+
629
+ <div class="row">
630
+ <div class="field" style="flex:1;">
631
+ <label>Button 1 label</label>
632
+ <input id="hero-cta1-label" placeholder="Explore features" />
633
+ </div>
634
+ <div class="field" style="flex:1;">
635
+ <label>Button 1 URL</label>
636
+ <input id="hero-cta1-href" placeholder="#sec_... or https://example.com" />
637
+ </div>
638
+ </div>
639
+
640
+ <div class="row">
641
+ <div class="field" style="flex:1;">
642
+ <label>Button 2 label</label>
643
+ <input id="hero-cta2-label" placeholder="Talk to us" />
644
+ </div>
645
+ <div class="field" style="flex:1;">
646
+ <label>Button 2 URL</label>
647
+ <input id="hero-cta2-href" placeholder="#sec_... or https://example.com" />
648
+ </div>
649
+ </div>
650
+
651
+ <div class="hint">
652
+ Tip: use <code style="font-family:var(--mono)">#</code> + a section id (e.g. <code style="font-family:var(--mono)">#sec_xxx</code>) to jump.
653
+ </div>
654
+ </div>
655
+
656
+ <div class="row" id="row-cta" style="display:none;">
657
+ <div class="field" style="flex:1;">
658
+ <label>Button label</label>
659
+ <input id="insp-cta-label" placeholder="Read more" />
660
+ </div>
661
+ <div class="field" style="width:140px;align-self:flex-end;">
662
+ <button class="btn danger" id="btn-remove-cta" type="button">Remove</button>
663
+ </div>
664
+ </div>
665
+
666
+ <div class="row" id="row-cta-align" style="display:none;"></div>
667
+
668
+ <div class="row">
669
+ <button class="btn" id="btn-apply" type="button">Apply</button>
670
+ <button class="btn danger" id="btn-delete" type="button">Delete</button>
671
+ </div>
672
+
673
+ <div class="hint">
674
+ You can drag an image from the Media tab onto an item's thumbnail area.
675
+ </div>
676
+
677
+ <!-- Sprint 4: per-section styling -->
678
+ <div class="hr" id="hr-sec-style" style="display:none;"></div>
679
+
680
+ <div id="sec-style-box" style="display:none;">
681
+ <div class="muted" style="margin-bottom:8px;"><b>Section style</b></div>
682
+
683
+ <div class="row">
684
+ <div class="field" style="flex:1;">
685
+ <label>Background colour</label>
686
+ <input type="color" id="sec-bg" value="#ffffff" />
687
+ </div>
688
+
689
+ <div class="field" style="width:180px;">
690
+ <label>Spacing</label>
691
+ <select id="sec-pad">
692
+ <option value="">Default</option>
693
+ <option value="compact">Compact</option>
694
+ <option value="normal">Normal</option>
695
+ <option value="spacious">Spacious</option>
696
+ </select>
697
+ </div>
698
+ </div>
699
+
700
+ <div class="row">
701
+ <div class="field" style="width:180px;">
702
+ <label>Text alignment</label>
703
+ <select id="sec-align">
704
+ <option value="">Default</option>
705
+ <option value="left">Left</option>
706
+ <option value="center">Centre</option>
707
+ </select>
708
+ </div>
709
+
710
+ <div class="field" style="flex:1;">
711
+ <label style="display:flex;gap:10px;align-items:center;">
712
+ <input type="checkbox" id="sec-style-on" />
713
+ Enable custom section style
714
+ </label>
715
+ <div class="muted" style="margin-top:4px;">Turn off to revert to the page defaults.</div>
716
+ </div>
717
+ </div>
718
+ </div>
719
+
720
+ </div>
721
+ </div>
722
+
723
+ <div class="tab" id="tab-design">
724
+ <div class="field">
725
+ <label>Body font</label>
726
+ <select id="design-font-body">
727
+ <option value="">Default (as generated)</option>
728
+ <option value="system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif">System</option>
729
+ <option value="Arial, Helvetica, sans-serif">Arial</option>
730
+ <option value="&quot;Trebuchet MS&quot;, &quot;Segoe UI&quot;, Arial, sans-serif">Trebuchet MS</option>
731
+ <option value="Georgia, &quot;Times New Roman&quot;, serif">Georgia (serif)</option>
732
+ <option value="&quot;Times New Roman&quot;, Times, serif">Times New Roman</option>
733
+ </select>
734
+ </div>
735
+
736
+ <div class="field">
737
+ <label>Heading font</label>
738
+ <select id="design-font-heading">
739
+ <option value="">Default (same as body)</option>
740
+ <option value="system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif">System</option>
741
+ <option value="Arial, Helvetica, sans-serif">Arial</option>
742
+ <option value="&quot;Trebuchet MS&quot;, &quot;Segoe UI&quot;, Arial, sans-serif">Trebuchet MS</option>
743
+ <option value="Georgia, &quot;Times New Roman&quot;, serif">Georgia (serif)</option>
744
+ <option value="&quot;Times New Roman&quot;, Times, serif">Times New Roman</option>
745
+ </select>
746
+ </div>
747
+
748
+ <div class="hint">
749
+ Colours apply to the published page. Leave toggles off to keep current defaults.
750
+ </div>
751
+
752
+ <div class="row">
753
+ <div class="field">
754
+ <label style="display:flex;gap:10px;align-items:center;">
755
+ <input type="checkbox" id="design-accent-on" />
756
+ Accent colour
757
+ </label>
758
+ <input type="color" id="design-accent" value="#6366f1" disabled />
759
+ </div>
760
+
761
+ <div class="field">
762
+ <label style="display:flex;gap:10px;align-items:center;">
763
+ <input type="checkbox" id="design-text-on" />
764
+ Text colour
765
+ </label>
766
+ <input type="color" id="design-text" value="#0f172a" disabled />
767
+ </div>
768
+ </div>
769
+
770
+ <div class="row">
771
+ <div class="field">
772
+ <label style="display:flex;gap:10px;align-items:center;">
773
+ <input type="checkbox" id="design-muted-on" />
774
+ Muted text colour
775
+ </label>
776
+ <input type="color" id="design-muted" value="#475569" disabled />
777
+ </div>
778
+
779
+ <div class="field">
780
+ <label style="display:flex;gap:10px;align-items:center;">
781
+ <input type="checkbox" id="design-bg-on" />
782
+ Background colour
783
+ </label>
784
+ <input type="color" id="design-bg" value="#f8fafc" disabled />
785
+ </div>
786
+ </div>
787
+
788
+ <div class="row">
789
+ <button class="btn danger" id="btn-design-reset" type="button">Reset design</button>
790
+ </div>
791
+ </div>
792
+
793
+ <div class="tab" id="tab-media">
794
+ <div class="row">
795
+ <button class="btn" id="btn-refresh-media" type="button">Refresh</button>
796
+ <button class="btn" id="btn-open-upload" type="button">Upload</button>
797
+ </div>
798
+
799
+ <div id="upload-box" style="display:none; margin-top:10px;">
800
+ <form id="upload-form">
801
+ <input type="file" name="media_files" id="media_files" multiple />
802
+ <div style="display:flex; gap:8px; margin-top:8px;">
803
+ <button class="btn primary" type="submit">Upload</button>
804
+ <button class="btn" type="button" id="btn-close-upload">Close</button>
805
+ </div>
806
+ <div class="hint">Images will go under <code style="font-family:var(--mono)">uploads/media/images/...</code></div>
807
+ </form>
808
+ </div>
809
+
810
+ <div class="hr"></div>
811
+
812
+ <div id="media-list">
813
+ <div class="media-empty">No media loaded yet. Click “Refresh”.</div>
814
+ </div>
815
+ </div>
816
+
817
+ <div class="tab" id="tab-pixabay">
818
+ <div class="muted">
819
+ Search Pixabay → click an image → we import it locally so links never break.
820
+ </div>
821
+
822
+ <div class="hr"></div>
823
+
824
+ <div class="field">
825
+ <label>Search</label>
826
+ <input id="px-q" placeholder="e.g. modern office teamwork, healthcare clinic, maths tutoring" />
827
+ </div>
828
+
829
+ <div class="row">
830
+ <div class="field">
831
+ <label>Orientation</label>
832
+ <select id="px-orientation">
833
+ <option value="horizontal" selected>Landscape</option>
834
+ <option value="vertical">Portrait</option>
835
+ <option value="all">Any</option>
836
+ </select>
837
+ </div>
838
+ <div class="field">
839
+ <label>Type</label>
840
+ <select id="px-type">
841
+ <option value="photo" selected>Photo</option>
842
+ <option value="illustration">Illustration</option>
843
+ <option value="vector">Vector</option>
844
+ </select>
845
+ </div>
846
+ </div>
847
+
848
+ <button class="btn primary" id="btn-px-search" type="button">Search</button>
849
+
850
+ <div class="hr"></div>
851
+
852
+ <div id="px-out" class="muted">No results yet.</div>
853
+ </div>
854
+ </div>
855
+ </div>
856
+
857
+ <!-- CODE EDITOR -->
858
+ <div class="codewrap">
859
+ <div class="hd">
860
+ <b>HTML editor (detached from the builder unless you click “Generate HTML”)</b>
861
+ <span id="cm-status" class="pill">Initialising…</span>
862
+ </div>
863
+
864
+ <div class="bd">
865
+ <form id="save-html-form" method="post" action="{{ url_for('edit_page', page_name=page_name) }}">
866
+ <div class="field">
867
+ <label>Page key (slug)</label>
868
+ <input id="page_name" type="text" name="page_name" value="{{ page_name }}" required />
869
+ </div>
870
+
871
+ <div class="field">
872
+ <label>Page HTML</label>
873
+ <textarea id="page_content" name="page_content" rows="24">{{ content|e }}</textarea>
874
+ </div>
875
+
876
+ <!-- keep real submit hidden; we trigger it via the topbar button -->
877
+ <button type="submit" id="real-submit" style="display:none;">Save</button>
878
+
879
+ <div class="hint">
880
+ This is what will be served for the page. The builder saves separately in <code style="font-family:var(--mono)">page_layouts</code>.
881
+ </div>
882
+ </form>
883
+ </div>
884
+ </div>
885
+ <input id="quick-img-input" type="file" accept="image/*" style="display:none;" />
886
+ <script>
887
+ // ----------------------------
888
+ // Boot data from server
889
+ // ----------------------------
890
+ const PAGE_NAME = {{ page_name|tojson }};
891
+ const INITIAL_LAYOUT_RAW = {{ layout_json|tojson }}; // string | object | null
892
+
893
+ // Ensure `state` exists as a real global (so theme edits persist)
894
+ var state = window.state || { sections: [] };
895
+ window.state = state;
896
+
897
+ try{
898
+ if (INITIAL_LAYOUT_RAW){
899
+ const parsed = (typeof INITIAL_LAYOUT_RAW === "string")
900
+ ? JSON.parse(INITIAL_LAYOUT_RAW)
901
+ : INITIAL_LAYOUT_RAW;
902
+
903
+ if (parsed && Array.isArray(parsed.sections)) { state = parsed; window.state = state; }
904
+ }
905
+ }catch(e){
906
+ console.warn("Layout JSON parse failed", e);
907
+ }
908
+
909
+ // default columns
910
+ const defaultColsSel = document.getElementById("default-cols");
911
+ function getDefaultCols(){ return parseInt(defaultColsSel.value || "3", 10); }
912
+
913
+ // ----------------------------
914
+ // Helpers
915
+ // ----------------------------
916
+ function uid(prefix){
917
+ return (prefix || "id") + "_" + Math.random().toString(16).slice(2) + Date.now().toString(16);
918
+ }
919
+ function setStatus(text, ok){
920
+ const el = document.getElementById("status");
921
+ if (!el) return;
922
+ el.textContent = text;
923
+ el.classList.remove("ok","fail");
924
+ if (ok === true) el.classList.add("ok");
925
+ if (ok === false) el.classList.add("fail");
926
+ }
927
+ function safeText(s){ return (s == null) ? "" : String(s); }
928
+ function isHeroSectionId(sid){
929
+ const sec = findSection(sid);
930
+ return !!sec && String(sec.type || sec.section_type || sec.kind || "").toLowerCase() === "hero";
931
+ }
932
+
933
+ function syncHeroSectionImage(sid, newUrl){
934
+ const sec = findSection(sid);
935
+ if (!sec) return;
936
+ const t = String(sec.type || sec.section_type || sec.kind || "").toLowerCase();
937
+ if (t === "hero") sec.imageUrl = (newUrl || "").trim();
938
+ }
939
+
940
+
941
+ function setHeroImageNow(url){
942
+ url = (url || "").trim();
943
+ if (!url) return false;
944
+
945
+ // If HERO section is selected, commit instantly to state
946
+ if (selected.kind === "section" && isHeroSectionId(selected.sid)){
947
+ const sec = findSection(selected.sid);
948
+ sec.imageUrl = url;
949
+ inspImg.value = url; // keep inspector in sync
950
+ render(); // refresh UI
951
+ openInspectorForSelection();
952
+ setStatus("Hero image set", true);
953
+ setTimeout(()=>setStatus("Ready"), 800);
954
+ return true;
955
+ }
956
+
957
+ return false;
958
+ }
959
+
960
+ // ----------------------------
961
+ // Selection
962
+ // ----------------------------
963
+ let selected = { kind:null, sid:null, iid:null };
964
+
965
+ function selectSection(sid){
966
+ selected = { kind:"section", sid, iid:null };
967
+ render();
968
+ openInspectorForSelection();
969
+ }
970
+ function selectItem(sid, iid){
971
+ selected = { kind:"item", sid, iid };
972
+ render();
973
+ openInspectorForSelection();
974
+ }
975
+ function clearSelection(){
976
+ selected = { kind:null, sid:null, iid:null };
977
+ render();
978
+ openInspectorForSelection();
979
+ }
980
+
981
+ function findSection(sid){
982
+ return state.sections.find(s => s.id === sid);
983
+ }
984
+ function findItem(sid, iid){
985
+ const s = findSection(sid);
986
+ if (!s || !Array.isArray(s.items)) return null;
987
+ return s.items.find(x => x.id === iid);
988
+ }
989
+
990
+ function firstSectionIdByType(type){
991
+ const want = String(type || "").toLowerCase();
992
+ const sec = (state.sections || []).find(s => String(s.type || "").toLowerCase() === want);
993
+ return sec ? String(sec.id || "") : "";
994
+ }
995
+
996
+ function isHeroSelection(){
997
+ try {
998
+ let sec = null;
999
+
1000
+ if (selected && selected.kind === "item") {
1001
+ sec = findSection(selected.sid);
1002
+ } else if (selected && selected.kind === "section") {
1003
+ sec = findSection(selected.sid);
1004
+ }
1005
+
1006
+ if (!sec) return false;
1007
+
1008
+ const t = (sec.type || sec.section_type || sec.kind || "");
1009
+ return String(t).toLowerCase() === "hero";
1010
+ } catch (e) {
1011
+ return false;
1012
+ }
1013
+ }
1014
+
1015
+ // ----------------------------
1016
+ // Render
1017
+ // ----------------------------
1018
+ const sectionsEl = document.getElementById("sections");
1019
+
1020
+ function sectionLabel(type){
1021
+ const map = {
1022
+ hero:"Hero", features:"Features", gallery:"Gallery",
1023
+ testimonials:"Testimonials", faq:"FAQ", cta:"CTA block", richtext:"Rich text"
1024
+ };
1025
+ return map[type] || type;
1026
+ }
1027
+
1028
+ // ----------------------------
1029
+ // Quick upload (drop file OR double-click thumb)
1030
+ // ----------------------------
1031
+ let quickPickTarget = null;
1032
+ const quickImgInput = document.getElementById("quick-img-input");
1033
+
1034
+ async function uploadSingleImageFile(file){
1035
+ if (!file) return null;
1036
+ if (!file.type || !file.type.startsWith("image/")){
1037
+ alert("Please choose an image file.");
1038
+ return null;
1039
+ }
1040
+
1041
+ const fd = new FormData();
1042
+ // Backend already uses /admin/upload_media for the upload form :contentReference[oaicite:1]{index=1}
1043
+ fd.append("media_files", file, file.name);
1044
+
1045
+ setStatus("Uploading…");
1046
+ const r = await fetch("/admin/upload_media", { method:"POST", body: fd, credentials:"same-origin" });
1047
+ const j = await r.json().catch(()=> ({}));
1048
+
1049
+ if (!r.ok){
1050
+ throw new Error(j.error || "Upload failed");
1051
+ }
1052
+
1053
+ // Best case: backend returns url(s)
1054
+ let url = null;
1055
+ if (j && Array.isArray(j.items) && j.items.length) url = j.items[0].url || null;
1056
+ if (!url && j && typeof j.url === "string") url = j.url;
1057
+
1058
+ // Fallback: refresh media list and try to match by filename
1059
+ await refreshMedia();
1060
+
1061
+ // Switch to Media tab after upload
1062
+ document.querySelector('[data-tab="media"]')?.click();
1063
+
1064
+ // Highlight newest matching tile
1065
+ setTimeout(() => {
1066
+ const tiles = Array.from(document.querySelectorAll("#media-list .media-tile"));
1067
+ const t = tiles.find(x => (x.getAttribute("title") || "").includes(file.name));
1068
+ if (t){
1069
+ t.scrollIntoView({ block:"nearest", behavior:"smooth" });
1070
+ t.style.outline = "2px solid rgba(34,197,94,.8)";
1071
+ t.style.outlineOffset = "2px";
1072
+ setTimeout(()=>{ t.style.outline = ""; t.style.outlineOffset = ""; }, 1200);
1073
+ }
1074
+ }, 150);
1075
+
1076
+ if (!url){
1077
+ try{
1078
+ const rr = await fetch("/admin/media/list.json", { credentials:"same-origin" });
1079
+ const jj = await rr.json();
1080
+ const items = jj.items || [];
1081
+ const match = items.find(x =>
1082
+ (x.url && x.url.includes(file.name)) ||
1083
+ (x.path && String(x.path).includes(file.name)) ||
1084
+ (x.name && String(x.name) === file.name)
1085
+ );
1086
+ if (match && match.url) url = match.url;
1087
+ }catch(e){}
1088
+ }
1089
+
1090
+ return url;
1091
+ }
1092
+
1093
+ if (quickImgInput){
1094
+ quickImgInput.addEventListener("change", async ()=>{
1095
+ const file = quickImgInput.files && quickImgInput.files[0];
1096
+ if (!file || !quickPickTarget) return;
1097
+
1098
+ try{
1099
+ const url = await uploadSingleImageFile(file);
1100
+ if (!url){
1101
+ setStatus("Upload ok (no URL)", false);
1102
+ return;
1103
+ }
1104
+
1105
+ // Apply to the stored target
1106
+ if (quickPickTarget.kind === "item"){
1107
+ const it = findItem(quickPickTarget.sid, quickPickTarget.iid);
1108
+ if (it){
1109
+ it.imageUrl = url;
1110
+ syncHeroSectionImage(quickPickTarget.sid, url);
1111
+ render();
1112
+ openInspectorForSelection();
1113
+
1114
+ }
1115
+ } else if (quickPickTarget.kind === "section"){
1116
+ const s = findSection(quickPickTarget.sid);
1117
+ if (s){
1118
+ s.imageUrl = url;
1119
+ render();
1120
+ openInspectorForSelection();
1121
+ }
1122
+ }
1123
+
1124
+ setStatus("Uploaded", true);
1125
+ setTimeout(()=>setStatus("Ready"), 800);
1126
+ }catch(err){
1127
+ console.warn(err);
1128
+ setStatus("Upload failed", false);
1129
+ alert(err.message || "Upload failed");
1130
+ }finally{
1131
+ quickPickTarget = null;
1132
+ quickImgInput.value = "";
1133
+ }
1134
+ });
1135
+ }
1136
+
1137
+ // Prevent dropping a file on the page from navigating away
1138
+ document.addEventListener("dragover", (e)=> e.preventDefault());
1139
+ document.addEventListener("drop", (e)=> {
1140
+ // allow our thumb handler to manage it
1141
+ if (e.target && e.target.closest && e.target.closest(".thumb")) return;
1142
+ e.preventDefault();
1143
+ });
1144
+
1145
+ // ----------------------------
1146
+ // Quick upload (double-click item thumbnail)
1147
+ // ----------------------------
1148
+ let _itemClickTimer = null;
1149
+ let _quickPickTarget = null; // { sid, iid }
1150
+
1151
+ function openQuickImagePickerForItem(sid, iid){
1152
+ _quickPickTarget = { sid, iid };
1153
+ const inp = document.getElementById("quick-img-input");
1154
+ if (!inp) return;
1155
+ inp.value = "";
1156
+ inp.click();
1157
+ }
1158
+
1159
+ async function uploadSingleImageFile(file){
1160
+ const fd = new FormData();
1161
+ fd.append("media_files", file, file.name);
1162
+
1163
+ const r = await fetch("/admin/upload_media", {
1164
+ method: "POST",
1165
+ body: fd,
1166
+ credentials: "same-origin"
1167
+ });
1168
+
1169
+ let j = null;
1170
+ try { j = await r.json(); } catch(e) {}
1171
+
1172
+ if (!r.ok){
1173
+ const msg = (j && (j.error || j.message)) ? (j.error || j.message) : "Upload failed";
1174
+ throw new Error(msg);
1175
+ }
1176
+
1177
+ // Try a few likely response shapes
1178
+ let url = null;
1179
+ if (j){
1180
+ if (typeof j.url === "string") url = j.url;
1181
+ if (!url && Array.isArray(j.items) && j.items[0] && j.items[0].url) url = j.items[0].url;
1182
+ if (!url && Array.isArray(j.uploaded) && j.uploaded[0] && j.uploaded[0].url) url = j.uploaded[0].url;
1183
+ if (!url && Array.isArray(j.files) && j.files[0] && j.files[0].url) url = j.files[0].url;
1184
+ }
1185
+
1186
+ // Refresh media so the new image appears
1187
+ await refreshMedia();
1188
+
1189
+ // If backend didn’t return a URL, try match by filename in refreshed tiles
1190
+ if (!url){
1191
+ const tiles = Array.from(document.querySelectorAll("#media-list .media-tile"));
1192
+ const t = tiles.find(x => (x.getAttribute("title") || "").includes(file.name));
1193
+ if (t) url = t.dataset.url || null;
1194
+ }
1195
+
1196
+ return url;
1197
+ }
1198
+
1199
+ document.getElementById("quick-img-input")?.addEventListener("change", async (e)=>{
1200
+ const inp = e.target;
1201
+ const file = inp.files && inp.files[0];
1202
+ if (!file) return;
1203
+
1204
+ if (!String(file.type || "").startsWith("image/")){
1205
+ alert("Please choose an image file.");
1206
+ return;
1207
+ }
1208
+
1209
+ setStatus("Uploading…");
1210
+ try{
1211
+ const url = await uploadSingleImageFile(file);
1212
+
1213
+ if (_quickPickTarget && url){
1214
+ const it = findItem(_quickPickTarget.sid, _quickPickTarget.iid);
1215
+ if (it){
1216
+ it.imageUrl = url;
1217
+ syncHeroSectionImage(_quickPickTarget.sid, url);
1218
+
1219
+ // ✅ if the parent section is HERO, sync section imageUrl too
1220
+
1221
+ const sec = findSection(_quickPickTarget.sid);
1222
+ if (sec && String(sec.type || "").toLowerCase() === "hero"){
1223
+ sec.imageUrl = url;
1224
+ }
1225
+
1226
+ render();
1227
+ openInspectorForSelection();
1228
+ }
1229
+ }
1230
+ setStatus("Uploaded", true);
1231
+ }catch(err){
1232
+ console.warn(err);
1233
+ setStatus("Upload failed", false);
1234
+ alert(err.message || "Upload failed");
1235
+ }finally{
1236
+ _quickPickTarget = null;
1237
+ setTimeout(()=>setStatus("Ready"), 900);
1238
+ }
1239
+ });
1240
+
1241
+ function render(){
1242
+ sectionsEl.innerHTML = "";
1243
+
1244
+ state.sections.forEach((s) => {
1245
+ const wrap = document.createElement("div");
1246
+ wrap.className = "section" + (selected.kind==="section" && selected.sid===s.id ? " selected" : "");
1247
+ wrap.dataset.sid = s.id;
1248
+
1249
+ const shd = document.createElement("div");
1250
+ shd.className = "shd";
1251
+
1252
+ const left = document.createElement("div");
1253
+ left.className = "meta";
1254
+ left.innerHTML = `
1255
+ <span class="handle" title="Drag section">⋮⋮</span>
1256
+ <b>${sectionLabel(s.type)}</b>
1257
+ <span class="chip">${safeText(s.type)}</span>
1258
+ <span class="chip">cols: ${safeText(s.cols || 1)}</span>
1259
+ `;
1260
+
1261
+ const right = document.createElement("div");
1262
+ right.className = "actions";
1263
+
1264
+ const cols = document.createElement("select");
1265
+ cols.className = "btn small";
1266
+ ["1","2","3","4","5"].forEach(v=>{
1267
+ const o=document.createElement("option");
1268
+ o.value=v; o.textContent=v;
1269
+ if (String(s.cols||"")===v) o.selected=true;
1270
+ cols.appendChild(o);
1271
+ });
1272
+ cols.title="Columns";
1273
+ cols.addEventListener("change", ()=>{
1274
+ s.cols = parseInt(cols.value, 10);
1275
+ render();
1276
+ });
1277
+
1278
+ const del = document.createElement("button");
1279
+ del.className = "btn small danger";
1280
+ del.type="button";
1281
+ del.textContent = "Delete";
1282
+ del.addEventListener("click", (ev)=>{
1283
+ ev.stopPropagation();
1284
+ state.sections = state.sections.filter(x => x.id !== s.id);
1285
+ if (selected.sid === s.id) clearSelection();
1286
+ render();
1287
+ });
1288
+
1289
+ right.appendChild(cols);
1290
+ right.appendChild(del);
1291
+
1292
+ shd.appendChild(left);
1293
+ shd.appendChild(right);
1294
+ shd.addEventListener("click", ()=>selectSection(s.id));
1295
+
1296
+ const sbd = document.createElement("div");
1297
+ sbd.className = "sbd";
1298
+
1299
+ const items = document.createElement("div");
1300
+ items.className = "items cols-" + (s.cols || 1);
1301
+ items.id = "items_" + s.id;
1302
+
1303
+ (s.items || []).forEach((it) => {
1304
+ const card = document.createElement("div");
1305
+ card.className = "item" + (selected.kind==="item" && selected.iid===it.id ? " selected" : "");
1306
+ card.dataset.iid = it.id;
1307
+
1308
+ const imgUrl = (it.imageUrl || "").trim();
1309
+ const thumb = document.createElement("div");
1310
+ thumb.className = "thumb";
1311
+ thumb.innerHTML = imgUrl
1312
+ ? `<img src="${imgUrl}" alt=""> <div class="drop">Drop image here</div>`
1313
+ : `<div class="drop">Drop image or double-click to upload</div>`;
1314
+
1315
+ // Drag/drop image onto item
1316
+ thumb.addEventListener("dragover", (e)=>{ e.preventDefault(); });
1317
+ thumb.addEventListener("drop", async (e)=>{
1318
+ e.preventDefault();
1319
+
1320
+ // 1) File dropped from desktop
1321
+ const files = e.dataTransfer?.files;
1322
+ if (files && files.length){
1323
+ const f = files[0];
1324
+ if (String(f.type || "").startsWith("image/")){
1325
+ setStatus("Uploading…");
1326
+ try{
1327
+ const url = await uploadSingleImageFile(f);
1328
+ if (url){
1329
+ it.imageUrl = url;
1330
+ syncHeroSectionImage(s.id, url);
1331
+ /*
1332
+ // ✅ keep HERO section background in sync with the item image
1333
+ if (String(s.type || "").toLowerCase() === "hero"){
1334
+ s.imageUrl = url;
1335
+ }
1336
+ */
1337
+ render();
1338
+ openInspectorForSelection();
1339
+ }
1340
+
1341
+ setStatus("Uploaded", true);
1342
+ }catch(err){
1343
+ console.warn(err);
1344
+ setStatus("Upload failed", false);
1345
+ alert(err.message || "Upload failed");
1346
+ }finally{
1347
+ setTimeout(()=>setStatus("Ready"), 900);
1348
+ }
1349
+ return;
1350
+ }
1351
+ }
1352
+
1353
+ // 2) URL dropped from Media tab (existing behaviour)
1354
+ const url = e.dataTransfer.getData("text/plain");
1355
+ if (url){
1356
+ it.imageUrl = url;
1357
+ syncHeroSectionImage(s.id, url);
1358
+ render();
1359
+ openInspectorForSelection();
1360
+ }
1361
+ });
1362
+
1363
+ // Double-click on the thumb opens file picker and uploads
1364
+ thumb.addEventListener("dblclick", (ev)=>{
1365
+ ev.stopPropagation();
1366
+
1367
+ // cancel the single-click selection timer (so dblclick always wins)
1368
+ if (_itemClickTimer){ clearTimeout(_itemClickTimer); _itemClickTimer = null; }
1369
+
1370
+ // set selection without re-rendering first
1371
+ selected = { kind:"item", sid: s.id, iid: it.id };
1372
+ openInspectorForSelection();
1373
+
1374
+ openQuickImagePickerForItem(s.id, it.id);
1375
+ });
1376
+
1377
+ const title = document.createElement("div");
1378
+ title.className = "title";
1379
+ title.textContent = safeText(it.title || it.type || "Item");
1380
+
1381
+ const text = document.createElement("div");
1382
+ text.className = "text";
1383
+ text.textContent = safeText(it.text || "");
1384
+
1385
+ card.appendChild(thumb);
1386
+ card.appendChild(title);
1387
+ card.appendChild(text);
1388
+
1389
+ // Attach a button to a card (only for card/image items)
1390
+ const tLower = String(it.type || "").toLowerCase();
1391
+ if (tLower === "card" || tLower === "image"){
1392
+ const cta = document.createElement("div");
1393
+ const has = String(it.href || "").trim().length > 0;
1394
+
1395
+ cta.className = "cta-drop" + (has ? " has" : "");
1396
+ cta.textContent = has ? "Button attached (click to edit)" : "Drop button here";
1397
+ cta.title = has ? "Click the card then edit link/label in Inspector" : "Drop the Button from Card actions here";
1398
+
1399
+ cta.addEventListener("click", (ev)=>{
1400
+ ev.stopPropagation();
1401
+ selectItem(s.id, it.id);
1402
+ });
1403
+
1404
+ cta.addEventListener("dragover", (e)=>{
1405
+ const a = e.dataTransfer?.getData("text/smx-action");
1406
+ if (a === "card-cta"){
1407
+ e.preventDefault();
1408
+ cta.classList.add("over");
1409
+ }
1410
+ });
1411
+ cta.addEventListener("dragleave", ()=> cta.classList.remove("over"));
1412
+
1413
+ cta.addEventListener("drop", (e)=>{
1414
+ const a = e.dataTransfer?.getData("text/smx-action");
1415
+ if (a !== "card-cta") return;
1416
+ e.preventDefault();
1417
+ cta.classList.remove("over");
1418
+
1419
+ // Attach CTA ONLY on explicit drop
1420
+ if (!String(it.href || "").trim()) it.href = "#";
1421
+ if (!String(it.ctaLabel || "").trim()) it.ctaLabel = "Read more";
1422
+ if (!String(it.ctaAlign || "").trim()) it.ctaAlign = "left";
1423
+
1424
+ selectItem(s.id, it.id);
1425
+ setStatus("Button added — set link/label then Apply", true);
1426
+ });
1427
+
1428
+ card.appendChild(cta);
1429
+ }
1430
+
1431
+ card.addEventListener("click", (ev)=>{
1432
+ ev.stopPropagation();
1433
+ if (_itemClickTimer) clearTimeout(_itemClickTimer);
1434
+ _itemClickTimer = setTimeout(()=>{
1435
+ selectItem(s.id, it.id);
1436
+ _itemClickTimer = null;
1437
+ }, 230);
1438
+ });
1439
+
1440
+ items.appendChild(card);
1441
+ });
1442
+
1443
+ sbd.appendChild(items);
1444
+ wrap.appendChild(shd);
1445
+ wrap.appendChild(sbd);
1446
+
1447
+ sectionsEl.appendChild(wrap);
1448
+ });
1449
+
1450
+ wireSortables();
1451
+ openInspectorForSelection();
1452
+ }
1453
+
1454
+ // ----------------------------
1455
+ // Sortables
1456
+ // ----------------------------
1457
+ let sortables = [];
1458
+ function killSortables(){
1459
+ sortables.forEach(s => { try{s.destroy();}catch(e){} });
1460
+ sortables = [];
1461
+ }
1462
+
1463
+ function wireSortables(){
1464
+ killSortables();
1465
+
1466
+ // Sections palette: clones into canvas
1467
+ sortables.push(new Sortable(document.getElementById("section-palette"), {
1468
+ group: { name: "sections", pull: "clone", put: false },
1469
+ sort: false,
1470
+ animation: 150,
1471
+ draggable: ".widget"
1472
+ }));
1473
+
1474
+ // Items palette: clones into any items container
1475
+ sortables.push(new Sortable(document.getElementById("item-palette"), {
1476
+ group: { name: "items", pull: "clone", put: false },
1477
+ sort: false,
1478
+ animation: 150,
1479
+ draggable: ".widget"
1480
+ }));
1481
+
1482
+ // Sections container: accepts clones + sorts
1483
+ sortables.push(new Sortable(sectionsEl, {
1484
+ group: { name: "sections", pull: true, put: true },
1485
+ animation: 150,
1486
+ draggable: ".section",
1487
+ // handle: ".handle", // optional (enable if you want drag by the ⋮⋮ only)
1488
+
1489
+ onAdd: (evt) => {
1490
+ // Dropped from palette -> create section in state
1491
+ const src = evt.item;
1492
+ const type = src.getAttribute("data-type") || "section";
1493
+
1494
+ const sec = {
1495
+ id: uid("sec"),
1496
+ type,
1497
+ title: sectionLabel(type),
1498
+ text: "",
1499
+ cols: getDefaultCols(),
1500
+ items: []
1501
+ };
1502
+
1503
+ state.sections.splice(evt.newIndex, 0, sec);
1504
+ render();
1505
+ },
1506
+
1507
+ onEnd: () => {
1508
+ // Reorder state by DOM order
1509
+ const order = Array.from(sectionsEl.querySelectorAll(".section")).map(x => x.dataset.sid);
1510
+ state.sections.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
1511
+ render();
1512
+ }
1513
+ }));
1514
+
1515
+ // Items containers per section
1516
+ state.sections.forEach((s) => {
1517
+ const el = document.getElementById("items_" + s.id);
1518
+ if (!el) return;
1519
+
1520
+ sortables.push(new Sortable(el, {
1521
+ group: { name: "items", pull: true, put: true },
1522
+ animation: 150,
1523
+ onAdd: (evt) => {
1524
+ const src = evt.item;
1525
+ const t = src.getAttribute("data-type") || "card";
1526
+ const it = {
1527
+ id: uid("item"),
1528
+ type: t,
1529
+ title: (t === "button") ? "Read more" :
1530
+ (t === "faq") ? "Question" :
1531
+ (t === "quote") ? "Name" : "Title",
1532
+ text: (t === "button") ? "" :
1533
+ (t === "faq") ? "Answer goes here." :
1534
+ (t === "quote") ? "Quote goes here." : "Description goes here.",
1535
+ imageUrl: (t === "button") ? "" : "",
1536
+ href: (t === "button") ? "#" : "" // IMPORTANT
1537
+ };
1538
+
1539
+ s.items = s.items || [];
1540
+ s.items.splice(evt.newIndex, 0, it);
1541
+ render();
1542
+ },
1543
+ onEnd: () => {
1544
+ const ids = Array.from(el.querySelectorAll(".item")).map(x => x.dataset.iid);
1545
+ s.items.sort((a,b)=> ids.indexOf(a.id) - ids.indexOf(b.id));
1546
+ render();
1547
+ }
1548
+ }));
1549
+ });
1550
+ }
1551
+
1552
+
1553
+ // Card action palette (plain HTML drag/drop, not Sortable)
1554
+ function wireCardActionPalette(){
1555
+ const pal = document.getElementById("card-action-palette");
1556
+ if (!pal) return;
1557
+ pal.querySelectorAll('.widget[data-action]').forEach(w=>{
1558
+ w.addEventListener("dragstart", (e)=>{
1559
+ e.dataTransfer.setData("text/smx-action", w.dataset.action || "");
1560
+ e.dataTransfer.effectAllowed = "copy";
1561
+ });
1562
+ });
1563
+ }
1564
+
1565
+
1566
+ // ----------------------------
1567
+ // Inspector
1568
+ // ----------------------------
1569
+ const inspBox = document.getElementById("insp");
1570
+ const inspEmpty = document.getElementById("insp-empty");
1571
+ const inspKind = document.getElementById("insp-kind");
1572
+ const inspTitle = document.getElementById("insp-title");
1573
+ const inspText = document.getElementById("insp-text");
1574
+ const inspCols = document.getElementById("insp-cols");
1575
+ const inspImg = document.getElementById("insp-img");
1576
+ const inspHref = document.getElementById("insp-href");
1577
+ const fieldHref = document.getElementById("field-href");
1578
+ const fieldCols = document.getElementById("field-cols");
1579
+ const fieldImg = document.getElementById("field-img");
1580
+ const rowCta = document.getElementById("row-cta");
1581
+ const inspCtaLabel = document.getElementById("insp-cta-label");
1582
+ const btnRemoveCta = document.getElementById("btn-remove-cta");
1583
+ const inspCtaAlign = document.getElementById("insp-cta-align");
1584
+ const fieldCtaAlign = document.getElementById("field-cta-align");
1585
+
1586
+ // Hero CTA controls
1587
+ const heroCtasBox = document.getElementById("hero-ctas");
1588
+ const heroCta1Label = document.getElementById("hero-cta1-label");
1589
+ const heroCta1Href = document.getElementById("hero-cta1-href");
1590
+ const heroCta2Label = document.getElementById("hero-cta2-label");
1591
+ const heroCta2Href = document.getElementById("hero-cta2-href");
1592
+
1593
+ const hrSecStyle = document.getElementById("hr-sec-style");
1594
+ const secStyleBox = document.getElementById("sec-style-box");
1595
+ const secStyleOn = document.getElementById("sec-style-on");
1596
+ const secBg = document.getElementById("sec-bg");
1597
+ const secPad = document.getElementById("sec-pad");
1598
+ const secAlign = document.getElementById("sec-align");
1599
+
1600
+
1601
+ function openInspectorForSelection(){
1602
+ if (heroCtasBox) heroCtasBox.style.display = "none";
1603
+ if (!selected.kind){
1604
+ inspBox.style.display="none";
1605
+ inspEmpty.style.display="block";
1606
+ return;
1607
+ }
1608
+ inspBox.style.display="block";
1609
+ inspEmpty.style.display="none";
1610
+
1611
+ if (selected.kind === "section"){
1612
+ const s = findSection(selected.sid);
1613
+ if (!s) return;
1614
+
1615
+ inspKind.value = "Section · " + sectionLabel(s.type);
1616
+ inspTitle.value = safeText(s.title || "");
1617
+ inspText.value = safeText(s.text || "");
1618
+ inspCols.value = String(s.cols || 1);
1619
+ inspImg.value = safeText(s.imageUrl || "");
1620
+
1621
+ // Section fields
1622
+ fieldCols.style.display = "block";
1623
+ // Only show section image for hero
1624
+ fieldImg.style.display = (String(s.type||"").toLowerCase() === "hero") ? "block" : "none";
1625
+ fieldHref.style.display = "none";
1626
+
1627
+ // Hero CTA editor (only for hero sections)
1628
+ const isHero = (String(s.type || "").toLowerCase() === "hero");
1629
+ if (heroCtasBox) heroCtasBox.style.display = isHero ? "block" : "none";
1630
+
1631
+ if (isHero){
1632
+ const featId = firstSectionIdByType("features");
1633
+ const ctaId = firstSectionIdByType("cta");
1634
+ const def1Href = featId ? ("#" + featId) : "";
1635
+ const def2Href = ctaId ? ("#" + ctaId) : "";
1636
+
1637
+ // IMPORTANT: preserve blank strings (blank URL = hidden button)
1638
+ const has1 = Object.prototype.hasOwnProperty.call(s, "heroCta1Href");
1639
+ const has2 = Object.prototype.hasOwnProperty.call(s, "heroCta2Href");
1640
+
1641
+ if (heroCta1Label) heroCta1Label.value = Object.prototype.hasOwnProperty.call(s, "heroCta1Label")
1642
+ ? safeText(s.heroCta1Label)
1643
+ : "Explore features";
1644
+
1645
+ if (heroCta1Href) heroCta1Href.value = has1
1646
+ ? safeText(s.heroCta1Href)
1647
+ : def1Href;
1648
+
1649
+ if (heroCta2Label) heroCta2Label.value = Object.prototype.hasOwnProperty.call(s, "heroCta2Label")
1650
+ ? safeText(s.heroCta2Label)
1651
+ : "Talk to us";
1652
+
1653
+ if (heroCta2Href) heroCta2Href.value = has2
1654
+ ? safeText(s.heroCta2Href)
1655
+ : def2Href;
1656
+ }
1657
+
1658
+ inspHref.value = "";
1659
+ if (fieldCtaAlign) fieldCtaAlign.style.display = "none";
1660
+ if (inspCtaAlign) inspCtaAlign.value = "";
1661
+
1662
+ // Sprint 4: per-section style UI
1663
+ if (hrSecStyle) hrSecStyle.style.display = "block";
1664
+ if (secStyleBox) secStyleBox.style.display = "block";
1665
+
1666
+ const st = (s.style && typeof s.style === "object") ? s.style : {};
1667
+ const enabled = !!st.enabled;
1668
+
1669
+ if (secStyleOn) secStyleOn.checked = enabled;
1670
+
1671
+ if (secBg) secBg.disabled = !enabled;
1672
+ if (secPad) secPad.disabled = !enabled;
1673
+ if (secAlign) secAlign.disabled = !enabled;
1674
+
1675
+ if (secBg) secBg.value = (st.bg && String(st.bg).startsWith("#")) ? st.bg : "#ffffff";
1676
+ if (secPad) secPad.value = st.pad || "";
1677
+ if (secAlign) secAlign.value = st.align || "";
1678
+
1679
+ } else {
1680
+ const it = findItem(selected.sid, selected.iid);
1681
+ if (!it) return;
1682
+
1683
+ const t = String(it.type || "").toLowerCase();
1684
+
1685
+ inspKind.value = "Item · " + safeText(it.type);
1686
+ inspTitle.value = safeText(it.title || "");
1687
+ inspText.value = safeText(it.text || "");
1688
+ inspCols.value = "1";
1689
+
1690
+ // image
1691
+ inspImg.value = (t === "button") ? "" : safeText(it.imageUrl || "");
1692
+
1693
+ // CTA visibility rules
1694
+ const isStandaloneButton = (t === "button");
1695
+ const isCardLike = (t === "card" || t === "image");
1696
+
1697
+ const hasCta = isCardLike && (
1698
+ String(it.href || "").trim().length > 0 ||
1699
+ String(it.ctaLabel || "").trim().length > 0
1700
+ );
1701
+
1702
+ // show/hide the controls
1703
+ fieldCols.style.display = "none";
1704
+ fieldImg.style.display = (t === "button") ? "none" : "block";
1705
+
1706
+ fieldHref.style.display = (isStandaloneButton || hasCta) ? "block" : "none";
1707
+ if (fieldCtaAlign) fieldCtaAlign.style.display = (isCardLike && hasCta) ? "block" : "none";
1708
+ rowCta.style.display = (isCardLike && hasCta) ? "flex" : "none";
1709
+
1710
+ // populate values
1711
+ inspHref.value = (isStandaloneButton || hasCta) ? safeText(it.href || "") : "";
1712
+ inspCtaLabel.value = (isCardLike && hasCta) ? safeText(it.ctaLabel || "Read more") : "";
1713
+ if (inspCtaAlign) inspCtaAlign.value = (isCardLike && hasCta) ? safeText(it.ctaAlign || "") : "";
1714
+ inspCtaAlign.value = (isCardLike && hasCta) ? (String(it.ctaAlign || "left").trim() || "left") : "left";
1715
+ }
1716
+ }
1717
+ document.getElementById("btn-apply").addEventListener("click", ()=>{
1718
+ if (!selected.kind) return;
1719
+
1720
+ if (selected.kind === "section"){
1721
+ const s = findSection(selected.sid);
1722
+ if (!s) return;
1723
+
1724
+ s.title = inspTitle.value;
1725
+ s.text = inspText.value;
1726
+ s.cols = parseInt(inspCols.value || "1", 10);
1727
+ s.imageUrl = inspImg.value;
1728
+
1729
+ // Save hero CTAs into layout state (blank URL = hide button)
1730
+ if (String(s.type || "").toLowerCase() === "hero"){
1731
+ if (heroCta1Label) s.heroCta1Label = (heroCta1Label.value || "").trim();
1732
+ if (heroCta1Href) s.heroCta1Href = (heroCta1Href.value || "").trim();
1733
+
1734
+ if (heroCta2Label) s.heroCta2Label = (heroCta2Label.value || "").trim();
1735
+ if (heroCta2Href) s.heroCta2Href = (heroCta2Href.value || "").trim();
1736
+ }
1737
+
1738
+ } else {
1739
+ const it = findItem(selected.sid, selected.iid);
1740
+ if (!it) return;
1741
+
1742
+ if (hrSecStyle) hrSecStyle.style.display = "none";
1743
+ if (secStyleBox) secStyleBox.style.display = "none";
1744
+
1745
+ const t = String(it.type || "").toLowerCase();
1746
+
1747
+ it.title = inspTitle.value;
1748
+ it.text = inspText.value;
1749
+
1750
+ if (t === "button"){
1751
+ it.href = (inspHref.value || "").trim();
1752
+ it.imageUrl = "";
1753
+ } else {
1754
+ it.imageUrl = inspImg.value;
1755
+
1756
+ // Keep hero section background image in sync with the hero item image
1757
+ syncHeroSectionImage(selected.sid, it.imageUrl);
1758
+
1759
+ // Card/image CTA link + label (only if CTA is attached)
1760
+ const isCardLike = (t === "card" || t === "image");
1761
+ const hasCta = isCardLike && (
1762
+ String(it.href || "").trim().length > 0 ||
1763
+ String(it.ctaLabel || "").trim().length > 0
1764
+ );
1765
+
1766
+ if (hasCta) {
1767
+ const newHref = (inspHref.value || "").trim();
1768
+ const newLbl = (inspCtaLabel.value || "Read more").trim() || "Read more";
1769
+ const newAlign = (inspCtaAlign ? String(inspCtaAlign.value || "").trim().toLowerCase() : "");
1770
+
1771
+ if (!newHref) {
1772
+ it.href = "";
1773
+ delete it.ctaLabel;
1774
+ delete it.ctaAlign;
1775
+ } else {
1776
+ it.href = newHref;
1777
+ it.ctaLabel = newLbl;
1778
+
1779
+ if (!newAlign || newAlign === "left") {
1780
+ delete it.ctaAlign;
1781
+ } else if (newAlign === "center" || newAlign === "right" || newAlign === "full") {
1782
+ it.ctaAlign = newAlign;
1783
+ } else {
1784
+ delete it.ctaAlign;
1785
+ }
1786
+ }
1787
+ }
1788
+ }
1789
+ }
1790
+ render();
1791
+ setStatus("Applied", true);
1792
+ setTimeout(()=>setStatus("Ready"), 800);
1793
+ });
1794
+
1795
+ document.getElementById("btn-delete").addEventListener("click", ()=>{
1796
+ if (!selected.kind) return;
1797
+
1798
+ if (selected.kind === "section"){
1799
+ state.sections = state.sections.filter(s => s.id !== selected.sid);
1800
+ }else{
1801
+ const s = findSection(selected.sid);
1802
+ if (s) s.items = (s.items || []).filter(i => i.id !== selected.iid);
1803
+ }
1804
+ clearSelection();
1805
+ render();
1806
+ setStatus("Deleted", true);
1807
+ setTimeout(()=>setStatus("Ready"), 800);
1808
+ });
1809
+
1810
+ btnRemoveCta?.addEventListener("click", ()=>{
1811
+ if (selected.kind !== "item") return;
1812
+ const it = findItem(selected.sid, selected.iid);
1813
+ if (!it) return;
1814
+
1815
+ it.href = "";
1816
+ delete it.ctaLabel;
1817
+
1818
+ render();
1819
+ openInspectorForSelection();
1820
+ setStatus("Button removed", true);
1821
+ setTimeout(()=>setStatus("Ready"), 900);
1822
+ });
1823
+
1824
+ // ----------------------------
1825
+ // Tabs
1826
+ // ----------------------------
1827
+ document.querySelectorAll(".tabbtn").forEach(btn=>{
1828
+ btn.addEventListener("click", ()=>{
1829
+ document.querySelectorAll(".tabbtn").forEach(b=>b.classList.remove("active"));
1830
+ document.querySelectorAll(".tab").forEach(t=>t.classList.remove("active"));
1831
+ btn.classList.add("active");
1832
+ document.getElementById("tab-" + btn.dataset.tab).classList.add("active");
1833
+ });
1834
+ });
1835
+
1836
+ // ----------------------------
1837
+ // Design (theme tokens saved into layout JSON)
1838
+ // ----------------------------
1839
+ const designFontBody = document.getElementById("design-font-body");
1840
+ const designFontHead = document.getElementById("design-font-heading");
1841
+
1842
+ const designAccentOn = document.getElementById("design-accent-on");
1843
+ const designAccent = document.getElementById("design-accent");
1844
+ const designTextOn = document.getElementById("design-text-on");
1845
+ const designText = document.getElementById("design-text");
1846
+ const designMutedOn = document.getElementById("design-muted-on");
1847
+ const designMuted = document.getElementById("design-muted");
1848
+ const designBgOn = document.getElementById("design-bg-on");
1849
+ const designBg = document.getElementById("design-bg");
1850
+
1851
+ const btnDesignReset = document.getElementById("btn-design-reset");
1852
+
1853
+ const DESIGN_DEFAULTS = {
1854
+ accent: "#6366f1",
1855
+ fg: "#0f172a",
1856
+ mut: "#475569",
1857
+ bg: "#f8fafc"
1858
+ };
1859
+
1860
+ function _ensureTheme(){
1861
+ if (!state || typeof state !== "object") return;
1862
+ if (!state.theme || typeof state.theme !== "object") state.theme = {};
1863
+ }
1864
+ function _getTheme(){
1865
+ _ensureTheme();
1866
+ return (state && state.theme && typeof state.theme === "object") ? state.theme : {};
1867
+ }
1868
+
1869
+ function _syncDesignUiFromState(){
1870
+ const t = _getTheme();
1871
+
1872
+ // fonts (empty = default)
1873
+ designFontBody.value = (t.fontBody || t.bodyFont || t.font_body || "") || "";
1874
+ designFontHead.value = (t.fontHeading || t.headingFont || t.font_heading || "") || "";
1875
+
1876
+ // toggles
1877
+ designAccentOn.checked = !!(t.accent);
1878
+ designTextOn.checked = !!(t.fg);
1879
+ designMutedOn.checked = !!(t.mut);
1880
+ designBgOn.checked = !!(t.bg);
1881
+
1882
+ // values (show defaults when off)
1883
+ designAccent.value = (t.accent || DESIGN_DEFAULTS.accent);
1884
+ designText.value = (t.fg || DESIGN_DEFAULTS.fg);
1885
+ designMuted.value = (t.mut || DESIGN_DEFAULTS.mut);
1886
+ designBg.value = (t.bg || DESIGN_DEFAULTS.bg);
1887
+
1888
+ // enable/disable colour pickers
1889
+ designAccent.disabled = !designAccentOn.checked;
1890
+ designText.disabled = !designTextOn.checked;
1891
+ designMuted.disabled = !designMutedOn.checked;
1892
+ designBg.disabled = !designBgOn.checked;
1893
+ }
1894
+
1895
+
1896
+ function _ensureSectionStyle(sec){
1897
+ if (!sec || typeof sec !== "object") return;
1898
+ if (!sec.style || typeof sec.style !== "object") sec.style = {};
1899
+ }
1900
+
1901
+ function _updateSelectedSectionStyle(){
1902
+ if (!selected || selected.kind !== "section") return;
1903
+ const s = findSection(selected.sid);
1904
+ if (!s) return;
1905
+
1906
+ _ensureSectionStyle(s);
1907
+
1908
+ const enabled = !!(secStyleOn && secStyleOn.checked);
1909
+ s.style.enabled = enabled;
1910
+
1911
+ if (!enabled){
1912
+ delete s.style.bg;
1913
+ delete s.style.pad;
1914
+ delete s.style.align;
1915
+ render();
1916
+ return;
1917
+ }
1918
+
1919
+ const bg = (secBg && secBg.value) ? String(secBg.value).trim() : "";
1920
+ const pad = (secPad && secPad.value) ? String(secPad.value).trim() : "";
1921
+ const align = (secAlign && secAlign.value) ? String(secAlign.value).trim() : "";
1922
+
1923
+ if (bg && bg.startsWith("#")) s.style.bg = bg; else delete s.style.bg;
1924
+ if (pad) s.style.pad = pad; else delete s.style.pad;
1925
+ if (align) s.style.align = align; else delete s.style.align;
1926
+
1927
+ render();
1928
+ }
1929
+
1930
+ secStyleOn?.addEventListener("change", ()=>{
1931
+ const on = !!secStyleOn.checked;
1932
+ if (secBg) secBg.disabled = !on;
1933
+ if (secPad) secPad.disabled = !on;
1934
+ if (secAlign) secAlign.disabled = !on;
1935
+ _updateSelectedSectionStyle();
1936
+ });
1937
+
1938
+ secBg?.addEventListener("input", _updateSelectedSectionStyle);
1939
+ secPad?.addEventListener("change", _updateSelectedSectionStyle);
1940
+ secAlign?.addEventListener("change", _updateSelectedSectionStyle);
1941
+
1942
+
1943
+ function _syncStateFromDesignUi(){
1944
+ _ensureTheme();
1945
+ const t = state.theme;
1946
+
1947
+ // fonts
1948
+ const fb = (designFontBody.value || "").trim();
1949
+ const fh = (designFontHead.value || "").trim();
1950
+
1951
+ if (fb) t.fontBody = fb;
1952
+ else { delete t.fontBody; delete t.bodyFont; delete t.font_body; }
1953
+
1954
+ if (fh) t.fontHeading = fh;
1955
+ else { delete t.fontHeading; delete t.headingFont; delete t.font_heading; }
1956
+
1957
+ // colours (only store if toggle is on)
1958
+ if (designAccentOn.checked) t.accent = designAccent.value || DESIGN_DEFAULTS.accent;
1959
+ else delete t.accent;
1960
+
1961
+ if (designTextOn.checked) t.fg = designText.value || DESIGN_DEFAULTS.fg;
1962
+ else delete t.fg;
1963
+
1964
+ if (designMutedOn.checked) t.mut = designMuted.value || DESIGN_DEFAULTS.mut;
1965
+ else delete t.mut;
1966
+
1967
+ if (designBgOn.checked) t.bg = designBg.value || DESIGN_DEFAULTS.bg;
1968
+ else delete t.bg;
1969
+
1970
+ setStatus("Design updated (draft)", true);
1971
+ }
1972
+
1973
+ function _wireToggle(onEl, inputEl){
1974
+ onEl.addEventListener("change", ()=>{
1975
+ inputEl.disabled = !onEl.checked;
1976
+ _syncStateFromDesignUi();
1977
+ });
1978
+ inputEl.addEventListener("input", _syncStateFromDesignUi);
1979
+ inputEl.addEventListener("change", _syncStateFromDesignUi);
1980
+ }
1981
+
1982
+ designFontBody.addEventListener("change", _syncStateFromDesignUi);
1983
+ designFontHead.addEventListener("change", _syncStateFromDesignUi);
1984
+
1985
+ _wireToggle(designAccentOn, designAccent);
1986
+ _wireToggle(designTextOn, designText);
1987
+ _wireToggle(designMutedOn, designMuted);
1988
+ _wireToggle(designBgOn, designBg);
1989
+
1990
+ btnDesignReset.addEventListener("click", ()=>{
1991
+ if (state && typeof state === "object") delete state.theme;
1992
+ _syncDesignUiFromState();
1993
+ setStatus("Design reset (draft)", true);
1994
+ });
1995
+
1996
+ // init once on load
1997
+ _syncDesignUiFromState();
1998
+
1999
+ // ----------------------------
2000
+ // Media
2001
+ // ----------------------------
2002
+ const mediaList = document.getElementById("media-list");
2003
+ const uploadBox = document.getElementById("upload-box");
2004
+
2005
+ function tileHtml(m){
2006
+ const name = safeText(m.path || m.name || "");
2007
+ const url = safeText(m.url || "");
2008
+ return `
2009
+ <div class="media-tile" draggable="true" data-url="${url}" title="${name}">
2010
+ <img src="${url}" alt="">
2011
+ <div class="cap">${name}</div>
2012
+ </div>
2013
+ `;
2014
+ }
2015
+
2016
+ async function refreshMedia(){
2017
+ mediaList.innerHTML = `<div class="media-empty">Loading…</div>`;
2018
+ try{
2019
+ const r = await fetch("/admin/media/list.json", { credentials: "same-origin" });
2020
+ const j = await r.json();
2021
+ const imgs = (j.items || []).filter(x => x.kind === "image");
2022
+ if (!imgs.length){
2023
+ mediaList.innerHTML = `<div class="media-empty">No images yet.</div>`;
2024
+ return;
2025
+ }
2026
+ mediaList.innerHTML = `<div class="media-grid">${imgs.map(tileHtml).join("")}</div>`;
2027
+
2028
+ // wire dragstart
2029
+ mediaList.querySelectorAll(".media-tile").forEach(el=>{
2030
+ el.addEventListener("dragstart", (e)=>{
2031
+ e.dataTransfer.setData("text/plain", el.dataset.url || "");
2032
+ });
2033
+ el.addEventListener("click", ()=>{
2034
+ const url = (el.dataset.url || "").trim();
2035
+ if (!url) return;
2036
+
2037
+ // If a HERO section is selected, commit immediately to state
2038
+ if (setHeroImageNow(url)) return;
2039
+
2040
+ // Otherwise keep existing behaviour
2041
+ if (selected.kind === "item"){
2042
+ const it = findItem(selected.sid, selected.iid);
2043
+ if (it){
2044
+ it.imageUrl = url;
2045
+ render();
2046
+ openInspectorForSelection();
2047
+ }
2048
+ }else{
2049
+ inspImg.value = url;
2050
+ }
2051
+ });
2052
+
2053
+ });
2054
+
2055
+ }catch(e){
2056
+ console.warn(e);
2057
+ mediaList.innerHTML = `<div class="media-empty">Failed to load media list.</div>`;
2058
+ }
2059
+ }
2060
+
2061
+ document.getElementById("btn-refresh-media").addEventListener("click", refreshMedia);
2062
+ document.getElementById("btn-open-upload").addEventListener("click", ()=>{ uploadBox.style.display="block"; });
2063
+ document.getElementById("btn-close-upload").addEventListener("click", ()=>{ uploadBox.style.display="none"; });
2064
+
2065
+ document.getElementById("upload-form").addEventListener("submit", async (e)=>{
2066
+ e.preventDefault();
2067
+ const fd = new FormData(e.target);
2068
+ setStatus("Uploading…");
2069
+ try{
2070
+ const r = await fetch("/admin/upload_media", { method:"POST", body: fd, credentials:"same-origin" });
2071
+ const j = await r.json();
2072
+ setStatus("Uploaded", true);
2073
+ uploadBox.style.display="none";
2074
+ await refreshMedia();
2075
+ }catch(err){
2076
+ setStatus("Upload failed", false);
2077
+ }finally{
2078
+ setTimeout(()=>setStatus("Ready"), 800);
2079
+ }
2080
+ });
2081
+
2082
+ // ----------------------------
2083
+ // Save layout (DB table: page_layouts)
2084
+ // ----------------------------
2085
+ document.getElementById("btn-save-layout").addEventListener("click", async ()=>{
2086
+ setStatus("Saving layout…");
2087
+ try{
2088
+ const r = await fetch(`/admin/page_layouts/${encodeURIComponent(PAGE_NAME)}`, {
2089
+ method:"POST",
2090
+ credentials:"same-origin",
2091
+ headers: { "Content-Type":"application/json" },
2092
+ body: JSON.stringify(state)
2093
+ });
2094
+ const j = await r.json();
2095
+ if (!r.ok) throw new Error(j.error || "save failed");
2096
+ setStatus("Layout saved", true);
2097
+ }catch(e){
2098
+ setStatus("Save failed", false);
2099
+ }finally{
2100
+ setTimeout(()=>setStatus("Ready"), 900);
2101
+ }
2102
+ });
2103
+
2104
+ // ----------------------------
2105
+ // Publish layout (patch-only; keeps existing generated HTML/CSS intact)
2106
+ // ----------------------------
2107
+ document.getElementById("btn-publish-layout").addEventListener("click", async ()=>{
2108
+ setStatus("Publishing…");
2109
+ try{
2110
+ // 1) save layout first (so DB is up-to-date)
2111
+ const r1 = await fetch(`/admin/page_layouts/${encodeURIComponent(PAGE_NAME)}`, {
2112
+ method:"POST",
2113
+ credentials:"same-origin",
2114
+ headers: { "Content-Type":"application/json" },
2115
+ body: JSON.stringify(state)
2116
+ });
2117
+ const j1 = await r1.json();
2118
+ if (!r1.ok) throw new Error(j1.error || "save layout failed");
2119
+
2120
+ // 2) patch-publish (updates the live page HTML without regenerating)
2121
+ const r2 = await fetch(`/admin/page_layouts/${encodeURIComponent(PAGE_NAME)}/publish`, {
2122
+ method:"POST",
2123
+ credentials:"same-origin",
2124
+ headers: { "Content-Type":"application/json" },
2125
+ body: JSON.stringify(state)
2126
+ });
2127
+
2128
+ const t2 = await r2.text();
2129
+ let j2 = {};
2130
+ try { j2 = JSON.parse(t2); } catch {}
2131
+
2132
+ if (!r2.ok) throw new Error(j2.error || t2 || "publish failed");
2133
+
2134
+ setStatus(`Published (${j2.mode})`, true);
2135
+
2136
+ // open the live page
2137
+ window.location.href = `/page/${encodeURIComponent(PAGE_NAME)}?v=${Date.now()}`;
2138
+ }catch(e){
2139
+ console.error(e);
2140
+ setStatus(String(e.message || e), false);
2141
+ }finally{
2142
+ setTimeout(()=>setStatus("Ready"), 900);
2143
+ }
2144
+ });
2145
+
2146
+ // ----------------------------
2147
+ // Generate HTML from layout (client-side baseline generator)
2148
+ // ----------------------------
2149
+ function esc(s){
2150
+ return safeText(s).replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;");
2151
+ }
2152
+
2153
+ function layoutToHtml(st) {
2154
+ const pageName = (st && st.page) || PAGE_NAME || "";
2155
+ const rawSlug = (pageName || "").toString().toLowerCase().trim();
2156
+ let pageSlug = rawSlug.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2157
+ if (!pageSlug) pageSlug = "page";
2158
+
2159
+ const sections = Array.isArray(st.sections) ? st.sections : [];
2160
+
2161
+ // Map first section id for each type (for hero CTAs)
2162
+ const secIdByType = {};
2163
+ sections.forEach((s, idx) => {
2164
+ if (!s) return;
2165
+ const t = (s.type || "").toString().toLowerCase();
2166
+ const baseType = t || "section";
2167
+ const sid = (s.id && String(s.id)) || `sec_${idx}_${baseType}`;
2168
+ if (t && !secIdByType[t]) secIdByType[t] = sid;
2169
+ });
2170
+
2171
+ function sectionDomId(s, idx) {
2172
+ if (!s) return `sec_${idx}_section`;
2173
+ const t = (s.type || "").toString().toLowerCase() || "section";
2174
+ return (s.id && String(s.id)) || `sec_${idx}_${t}`;
2175
+ }
2176
+
2177
+ const blocks = sections.map((s, index) => {
2178
+ const type = (s && s.type ? String(s.type).toLowerCase() : "section");
2179
+ const title = esc((s && s.title) || sectionLabel(type));
2180
+ const text = esc((s && s.text) || "");
2181
+ const colsRaw = (s && s.cols) || 1;
2182
+ const cols = Math.max(1, Math.min(5, parseInt(colsRaw, 10) || 1));
2183
+ const secId = sectionDomId(s, index);
2184
+ const items = Array.isArray(s && s.items) ? s.items : [];
2185
+
2186
+ // --------------------------
2187
+ // HERO = full-width banner
2188
+ // --------------------------
2189
+ if (type === "hero") {
2190
+ let heroImg = ((s && s.imageUrl) || "").toString().trim();
2191
+ if (!heroImg) {
2192
+ const first = items.find(it => it && typeof it.imageUrl === "string" && it.imageUrl.trim());
2193
+ if (first) heroImg = first.imageUrl.trim();
2194
+ }
2195
+ const bgStyle = heroImg
2196
+ ? `background-image:url('${esc(heroImg)}');background-size:cover;background-position:center;background-repeat:no-repeat;`
2197
+ : `background:radial-gradient(circle at 0 0,#1f2937,#020617);`;
2198
+
2199
+ const featuresAnchor = secIdByType["features"] || "sec_features";
2200
+ const ctaAnchor = secIdByType["cta"] || "sec_cta";
2201
+ const heroHeading = title || esc(pageName) || "Untitled page";
2202
+
2203
+ return `
2204
+ <section id="${esc(secId)}" data-section-type="hero" style="position:relative;width:100%;min-height:380px;display:flex;align-items:flex-end;${bgStyle}">
2205
+ <div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(15,23,42,.18) 0%,rgba(15,23,42,.82) 65%,rgba(15,23,42,.98) 100%);"></div>
2206
+ <div style="position:relative;z-index:1;width:100%;padding:72px 18px 48px;box-sizing:border-box;">
2207
+ <div style="max-width:760px;margin:0 auto;border-radius:18px;border:1px solid rgba(148,163,184,.4);background:rgba(15,23,42,.85);backdrop-filter:blur(14px);padding:18px 18px 20px;">
2208
+ <p style="margin:0 0 6px;font-size:.9rem;color:#a5b4fc;text-transform:uppercase;letter-spacing:.18em;opacity:.95;">${esc(pageName || "")}</p>
2209
+ <h1 style="margin:0 0 10px;font-size:clamp(2rem,3.4vw,3.1rem);line-height:1.1;color:#e5e7eb;">${heroHeading}</h1>
2210
+ ${text ? `<p style="margin:0;font-size:1.05rem;line-height:1.65;color:#cbd5e1;">${text}</p>` : ""}
2211
+ <div style="display:flex;flex-wrap:wrap;gap:10px;margin-top:18px;">
2212
+ <a href="#${esc(featuresAnchor)}" style="display:inline-flex;align-items:center;gap:8px;border-radius:999px;padding:10px 16px;border:1px solid rgba(129,140,248,.7);background:rgba(79,70,229,.95);color:#e5e7eb;text-decoration:none;font-weight:600;">Explore features</a>
2213
+ <a href="#${esc(ctaAnchor)}" style="display:inline-flex;align-items:center;gap:8px;border-radius:999px;padding:10px 16px;border:1px solid rgba(148,163,184,.6);background:rgba(15,23,42,.8);color:#cbd5e1;text-decoration:none;">Talk to us</a>
2214
+ </div>
2215
+ </div>
2216
+ </div>
2217
+ </section>
2218
+ `.trim();
2219
+ }
2220
+
2221
+ // --------------------------
2222
+ // FAQ = accordion
2223
+ // --------------------------
2224
+ if (type === "faq") {
2225
+ const qaHtml = items.map(it => {
2226
+ const q = esc((it && it.title) || "");
2227
+ const a = esc((it && it.text) || "");
2228
+ if (!q && !a) return "";
2229
+ return `
2230
+ <details style="border:1px solid rgba(148,163,184,.35);border-radius:14px;padding:12px 14px;background:rgba(15,23,42,.9);">
2231
+ <summary style="cursor:pointer;font-weight:600;color:#e5e7eb;">${q}</summary>
2232
+ ${a ? `<div style="margin-top:8px;color:#cbd5e1;line-height:1.6;">${a}</div>` : ""}
2233
+ </details>
2234
+ `.trim();
2235
+ }).filter(Boolean).join("\n");
2236
+ return `
2237
+ <section id="${esc(secId)}" data-section-type="faq" style="padding:56px 0;">
2238
+ <div style="max-width:1120px;margin:0 auto;padding:0 18px;">
2239
+ ${title ? `<h2 style="margin:0 0 10px;font-size:1.6rem;color:#e5e7eb;">${title}</h2>` : ""}
2240
+ ${text ? `<p style="margin:0 0 16px;color:#9ca3af;line-height:1.6;">${text}</p>` : ""}
2241
+ <div style="display:flex;flex-direction:column;gap:10px;">
2242
+ ${qaHtml}
2243
+ </div>
2244
+ </div>
2245
+ </section>
2246
+ `.trim();
2247
+ }
2248
+
2249
+ // --------------------------
2250
+ // Testimonials = quote cards
2251
+ // --------------------------
2252
+ if (type === "testimonials") {
2253
+ const tCols = Math.min(3, Math.max(1, cols));
2254
+ const cardHtml = items.map(it => {
2255
+ const quote = esc((it && it.text) || "");
2256
+ const who = esc((it && it.title) || "");
2257
+ if (!quote) return "";
2258
+ return `
2259
+ <div style="border:1px solid rgba(148,163,184,.35);border-radius:18px;padding:16px;background:rgba(15,23,42,.9);">
2260
+ <div style="font-style:italic;color:#e5e7eb;line-height:1.7;">“${quote}”</div>
2261
+ ${who ? `<div style="margin-top:10px;font-weight:600;color:#cbd5e1;">${who}</div>` : ""}
2262
+ </div>
2263
+ `.trim();
2264
+ }).filter(Boolean).join("\n");
2265
+ return `
2266
+ <section id="${esc(secId)}" data-section-type="testimonials" style="padding:56px 0;">
2267
+ <div style="max-width:1120px;margin:0 auto;padding:0 18px;">
2268
+ ${title ? `<h2 style="margin:0 0 10px;font-size:1.6rem;color:#e5e7eb;">${title}</h2>` : ""}
2269
+ ${text ? `<p style="margin:0 0 16px;color:#9ca3af;line-height:1.6;">${text}</p>` : ""}
2270
+ <div style="display:grid;gap:12px;grid-template-columns:repeat(${tCols}, minmax(0,1fr));">
2271
+ ${cardHtml}
2272
+ </div>
2273
+ </div>
2274
+ </section>
2275
+ `.trim();
2276
+ }
2277
+
2278
+ // --------------------------
2279
+ // Default (features, gallery, cta, richtext, etc.)
2280
+ // --------------------------
2281
+ const useCols = type === "richtext" ? 1 : cols;
2282
+ const cards = items.map(it => {
2283
+ const itTitle = esc((it && it.title) || "");
2284
+ const itText = esc((it && it.text) || "");
2285
+ const img = (it && it.imageUrl ? String(it.imageUrl) : "").trim();
2286
+ const imgHtml = img
2287
+ ? `<img src="${esc(img)}" alt="${itTitle}" loading="lazy" decoding="async" style="width:100%;height:auto;border-radius:12px;margin-bottom:10px;">`
2288
+ : "";
2289
+ return `
2290
+ <div style="border:1px solid rgba(148,163,184,.35);border-radius:18px;padding:14px;background:rgba(15,23,42,.9);">
2291
+ ${imgHtml}
2292
+ ${itTitle ? `<h3 style="margin:0 0 6px;font-size:1.05rem;color:#e5e7eb;">${itTitle}</h3>` : ""}
2293
+ ${itText ? `<p style="margin:0;color:#cbd5e1;line-height:1.55;">${itText}</p>` : ""}
2294
+ </div>
2295
+ `.trim();
2296
+ }).join("\n");
2297
+
2298
+ const body = cards || (text ? `<p style="margin:0;color:#cbd5e1;line-height:1.6;">${text}</p>` : "");
2299
+ return `
2300
+ <section id="${esc(secId)}" data-section-type="${esc(type)}" style="padding:56px 0;">
2301
+ <div style="max-width:1120px;margin:0 auto;padding:0 18px;">
2302
+ ${title ? `<h2 style="margin:0 0 10px;font-size:1.6rem;color:#e5e7eb;">${title}</h2>` : ""}
2303
+ ${text && cards ? `<p style="margin:0 0 14px;color:#9ca3af;line-height:1.6;">${text}</p>` : ""}
2304
+ ${cards ? `<div style="display:grid;gap:12px;grid-template-columns:repeat(${useCols}, minmax(0,1fr));">${cards}</div>` : body}
2305
+ </div>
2306
+ </section>
2307
+ `.trim();
2308
+ });
2309
+
2310
+ const inner = blocks.filter(Boolean).join("\n\n");
2311
+ if (!inner) return "";
2312
+ return `<div id="smx-page-${pageSlug}" data-page-name="${esc(pageName || "")}">\n${inner}\n</div>`;
2313
+ }
2314
+
2315
+ document.getElementById("btn-generate-html").addEventListener("click", async ()=>{
2316
+ setStatus("Compiling…");
2317
+ try{
2318
+ const r = await fetch(`/admin/page_layouts/${encodeURIComponent(PAGE_NAME)}/compile`, {
2319
+ method:"POST",
2320
+ credentials:"same-origin",
2321
+ headers: { "Content-Type":"application/json" },
2322
+ body: JSON.stringify(state)
2323
+ });
2324
+ const t = await r.text();
2325
+ let j = {};
2326
+ try { j = JSON.parse(t); } catch {}
2327
+
2328
+ if (!r.ok){
2329
+ const msg = (j && j.error) ? j.error : `HTTP ${r.status}: ${t.slice(0, 200)}`;
2330
+ throw new Error(msg);
2331
+ }
2332
+
2333
+ const html = j.html || "";
2334
+ if (window.__cm){
2335
+ window.__cm.setValue(html);
2336
+ window.__cm.save();
2337
+ }else{
2338
+ document.getElementById("page_content").value = html;
2339
+ }
2340
+ setStatus("HTML generated", true);
2341
+ }catch(err){
2342
+ setStatus(String(err.message || err), false);
2343
+ }finally{
2344
+ setTimeout(()=>setStatus("Ready"), 900);
2345
+ }
2346
+ });
2347
+
2348
+ // Clear canvas
2349
+ document.getElementById("btn-clear").addEventListener("click", ()=>{
2350
+ state.sections = [];
2351
+ clearSelection();
2352
+ render();
2353
+ });
2354
+
2355
+ // ----------------------------
2356
+ // CodeMirror init
2357
+ // ----------------------------
2358
+ document.addEventListener("DOMContentLoaded", function(){
2359
+ const textarea = document.getElementById("page_content");
2360
+ const statusEl = document.getElementById("cm-status");
2361
+ wireCardActionPalette();
2362
+ if (!textarea || typeof CodeMirror === "undefined"){
2363
+ statusEl.textContent = "Plain textarea mode";
2364
+ statusEl.classList.add("fail");
2365
+ render(); // builder must still work
2366
+ return;
2367
+ }
2368
+
2369
+ const cm = CodeMirror.fromTextArea(textarea, {
2370
+ mode: "htmlmixed",
2371
+ theme: "smx-dark",
2372
+ lineNumbers: true,
2373
+ lineWrapping: true,
2374
+ autoCloseTags: true,
2375
+ autoCloseBrackets: true,
2376
+ matchBrackets: true,
2377
+ tabSize: 2,
2378
+ indentUnit: 2,
2379
+ indentWithTabs: false
2380
+ });
2381
+
2382
+ cm.setSize("100%", 520);
2383
+ window.__cm = cm;
2384
+
2385
+ statusEl.textContent = "Code editor active";
2386
+ statusEl.classList.add("ok");
2387
+
2388
+ // initial render
2389
+ render();
2390
+ });
2391
+
2392
+ // Auto-load media list once
2393
+ refreshMedia();
2394
+ </script>
2395
+ <script>
2396
+ async function pxSearch(){
2397
+ const q = (document.getElementById("px-q").value || "").trim();
2398
+ const orientation = document.getElementById("px-orientation").value;
2399
+ const image_type = document.getElementById("px-type").value;
2400
+ const out = document.getElementById("px-out");
2401
+
2402
+ if (!q){
2403
+ out.textContent = "Type a search phrase first.";
2404
+ return;
2405
+ }
2406
+
2407
+ out.textContent = "Searching…";
2408
+ setStatus("Searching…");
2409
+
2410
+ try{
2411
+ const r = await fetch(`/admin/pixabay/search.json?q=${encodeURIComponent(q)}&orientation=${encodeURIComponent(orientation)}&image_type=${encodeURIComponent(image_type)}`, { credentials:"same-origin" });
2412
+ const j = await r.json();
2413
+
2414
+ if (!r.ok){
2415
+ out.textContent = j.error || "Search failed.";
2416
+ setStatus("Search failed", false);
2417
+ return;
2418
+ }
2419
+
2420
+ const items = j.items || [];
2421
+ if (!items.length){
2422
+ out.textContent = "No results found.";
2423
+ setStatus("No results", false);
2424
+ return;
2425
+ }
2426
+
2427
+ out.innerHTML = `
2428
+ <div class="muted">Click an image to import locally. It will appear in your Media tab afterwards.</div>
2429
+ <div class="hr"></div>
2430
+ <div class="media-grid">
2431
+ ${items.map(x => `
2432
+ <div class="media-tile" data-id="${x.id}" title="${(x.tags || '')}">
2433
+ <img src="${x.preview_url}" alt="">
2434
+ <div class="cap">ID: ${x.id}</div>
2435
+ </div>
2436
+ `).join("")}
2437
+ </div>
2438
+ `;
2439
+
2440
+ function isHeroSelection(){
2441
+ try {
2442
+ if (!selected) return false;
2443
+
2444
+ // If an item is selected, inspect its parent section
2445
+ if (selected.kind === "item"){
2446
+ const sec = findSection(selected.sid);
2447
+ return !!sec && String(sec.type).toLowerCase() === "hero";
2448
+ }
2449
+
2450
+ // If a section is selected
2451
+ if (selected.kind === "section"){
2452
+ const sec = findSection(selected.sid);
2453
+ return !!sec && String(sec.type).toLowerCase() === "hero";
2454
+ }
2455
+
2456
+ return false;
2457
+ } catch (e) {
2458
+ return false;
2459
+ }
2460
+ }
2461
+
2462
+ // Keep Hero section.imageUrl in sync with the hero item's imageUrl
2463
+ function syncHeroSectionImage(sid, newUrl){
2464
+ const sec = findSection(sid);
2465
+ if (!sec) return;
2466
+
2467
+ const t = String(sec.type || sec.section_type || sec.kind || "").toLowerCase();
2468
+ if (t === "hero"){
2469
+ sec.imageUrl = newUrl || "";
2470
+ }
2471
+ }
2472
+
2473
+ out.querySelectorAll(".media-tile").forEach(tile=>{
2474
+ tile.addEventListener("click", async ()=>{
2475
+ const id = tile.getAttribute("data-id");
2476
+ if (!id) return;
2477
+
2478
+ const min_width = isHeroSelection() ? 1920 : 0;
2479
+
2480
+ setStatus("Importing…");
2481
+ try{
2482
+ const rr = await fetch("/admin/pixabay/import.json", {
2483
+ method:"POST",
2484
+ credentials:"same-origin",
2485
+ headers:{ "Content-Type":"application/json" },
2486
+ body: JSON.stringify({ id, min_width })
2487
+ });
2488
+ const jj = await rr.json();
2489
+
2490
+ if (!rr.ok){
2491
+ setStatus("Import failed", false);
2492
+ alert(jj.error || "Import failed");
2493
+ return;
2494
+ }
2495
+
2496
+ // If a HERO section is selected, commit immediately to state
2497
+ if (setHeroImageNow(jj.url)) {
2498
+ await refreshMedia();
2499
+ return;
2500
+ }
2501
+
2502
+ if (selected.kind === "item"){
2503
+ const it = findItem(selected.sid, selected.iid);
2504
+ if (it){
2505
+ it.imageUrl = jj.url;
2506
+ syncHeroSectionImage(selected.sid, jj.url);
2507
+ render();
2508
+ openInspectorForSelection();
2509
+ }
2510
+ } else {
2511
+ inspImg.value = jj.url;
2512
+ }
2513
+
2514
+ setStatus("Imported", true);
2515
+ await refreshMedia();
2516
+ setTimeout(()=>setStatus("Ready"), 900);
2517
+ }catch(e){
2518
+ setStatus("Import failed", false);
2519
+ }
2520
+ });
2521
+ });
2522
+
2523
+ setStatus("Results", true);
2524
+ setTimeout(()=>setStatus("Ready"), 900);
2525
+
2526
+ }catch(e){
2527
+ out.textContent = "Search failed.";
2528
+ setStatus("Search failed", false);
2529
+ }
2530
+ }
2531
+
2532
+ document.getElementById("btn-px-search")?.addEventListener("click", pxSearch);
2533
+ </script>
2534
+ </body>
2535
+ </html>