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.
- syntaxmatrix/__init__.py +3 -2
- syntaxmatrix/agentic/agents.py +1220 -169
- syntaxmatrix/agentic/agents_orchestrer.py +326 -0
- syntaxmatrix/agentic/code_tools_registry.py +27 -32
- syntaxmatrix/auth.py +142 -5
- syntaxmatrix/commentary.py +16 -16
- syntaxmatrix/core.py +192 -84
- syntaxmatrix/db.py +460 -4
- syntaxmatrix/{display.py → display_html.py} +2 -6
- syntaxmatrix/gpt_models_latest.py +1 -1
- syntaxmatrix/media/__init__.py +0 -0
- syntaxmatrix/media/media_pixabay.py +277 -0
- syntaxmatrix/models.py +1 -1
- syntaxmatrix/page_builder_defaults.py +183 -0
- syntaxmatrix/page_builder_generation.py +1122 -0
- syntaxmatrix/page_layout_contract.py +644 -0
- syntaxmatrix/page_patch_publish.py +1471 -0
- syntaxmatrix/preface.py +670 -0
- syntaxmatrix/profiles.py +28 -10
- syntaxmatrix/routes.py +1941 -593
- syntaxmatrix/selftest_page_templates.py +360 -0
- syntaxmatrix/settings/client_items.py +28 -0
- syntaxmatrix/settings/model_map.py +1022 -207
- syntaxmatrix/settings/prompts.py +328 -130
- syntaxmatrix/static/assets/hero-default.svg +22 -0
- syntaxmatrix/static/icons/bot-icon.png +0 -0
- syntaxmatrix/static/icons/favicon.png +0 -0
- syntaxmatrix/static/icons/logo.png +0 -0
- syntaxmatrix/static/icons/logo3.png +0 -0
- syntaxmatrix/templates/admin_branding.html +104 -0
- syntaxmatrix/templates/admin_features.html +63 -0
- syntaxmatrix/templates/admin_secretes.html +108 -0
- syntaxmatrix/templates/change_password.html +124 -0
- syntaxmatrix/templates/dashboard.html +296 -131
- syntaxmatrix/templates/dataset_resize.html +535 -0
- syntaxmatrix/templates/edit_page.html +2535 -0
- syntaxmatrix/utils.py +2728 -2835
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/METADATA +6 -2
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/RECORD +42 -25
- syntaxmatrix/generate_page.py +0 -634
- syntaxmatrix/static/icons/hero_bg.jpg +0 -0
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/WHEEL +0 -0
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/licenses/LICENSE.txt +0 -0
- {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=""Trebuchet MS", "Segoe UI", Arial, sans-serif">Trebuchet MS</option>
|
|
731
|
+
<option value="Georgia, "Times New Roman", serif">Georgia (serif)</option>
|
|
732
|
+
<option value=""Times New Roman", 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=""Trebuchet MS", "Segoe UI", Arial, sans-serif">Trebuchet MS</option>
|
|
743
|
+
<option value="Georgia, "Times New Roman", serif">Georgia (serif)</option>
|
|
744
|
+
<option value=""Times New Roman", 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("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""");
|
|
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>
|