@mahe_pkm/buzl-html-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2005 @@
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.0">
6
+ <title>Admin – Visual Editor</title>
7
+
8
+ <!-- GrapeJS Core CSS -->
9
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/grapesjs@0.22.2/dist/css/grapes.min.css">
10
+
11
+ <!-- Font Awesome (used by preset-webpage for toolbar icons) -->
12
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
13
+
14
+ <style>
15
+ * { box-sizing: border-box; margin: 0; padding: 0; }
16
+ body {
17
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
+ height: 100vh;
19
+ display: flex;
20
+ flex-direction: column;
21
+ overflow: hidden;
22
+ }
23
+
24
+ /* ── Top Bar ─────────────────────────────────── */
25
+ .admin-topbar {
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: space-between;
29
+ padding: 0 12px;
30
+ background: #3b2b2b;
31
+ border-bottom: 1px solid #5e4949;
32
+ height: 40px;
33
+ flex-shrink: 0;
34
+ }
35
+ .admin-topbar .brand {
36
+ display: flex; align-items: center; gap: 8px;
37
+ font-size: 14px; font-weight: 600; color: #D4AF37;
38
+ }
39
+ .admin-topbar .brand span { color: #ccc; font-weight: 400; font-size: 12px; }
40
+ .admin-topbar .controls { display: flex; align-items: center; gap: 8px; }
41
+ .admin-topbar select {
42
+ background: #463a3c; border: 1px solid #6b5555; color: #ddd;
43
+ padding: 4px 10px; border-radius: 4px; font-size: 12px; cursor: pointer;
44
+ }
45
+ .admin-topbar button {
46
+ padding: 5px 14px; border: none; border-radius: 4px;
47
+ font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.2s;
48
+ }
49
+ .btn-save { background: #D4AF37; color: #1a1a2e; }
50
+ .btn-save:hover { background: #E0C564; }
51
+ .btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
52
+ .btn-live { background: transparent; border: 1px solid #6b5555 !important; color: #ddd; }
53
+ .btn-live:hover { border-color: #D4AF37 !important; color: #D4AF37; }
54
+ .btn-live.active { background: #D4AF37; border-color: #D4AF37 !important; color: #1a1a2e; }
55
+ .btn-live:disabled,
56
+ .btn-live:disabled:hover {
57
+ border-color: #5e4949 !important;
58
+ color: #8d7c7c;
59
+ cursor: not-allowed;
60
+ opacity: 0.55;
61
+ }
62
+ .btn-preview { background: transparent; border: 1px solid #6b5555 !important; color: #ddd; }
63
+ .btn-preview:hover { border-color: #D4AF37 !important; color: #D4AF37; }
64
+ .save-status { font-size: 11px; color: #888; min-width: 80px; text-align: right; }
65
+ .save-status.success { color: #4caf50; }
66
+ .save-status.error { color: #f44336; }
67
+ .save-status.saving { color: #D4AF37; }
68
+
69
+ /* ── GrapeJS Container ───────────────────────── */
70
+ #gjs { flex: 1; overflow: hidden; }
71
+
72
+ /* ── GrapeJS Theme: exact demo colors ─────────── */
73
+ .gjs-one-bg { background-color: #463a3c; }
74
+ .gjs-one-color { color: #463a3c; }
75
+ .gjs-one-color-h:hover { color: #463a3c; }
76
+ .gjs-two-bg { background-color: #b9a5a6; }
77
+ .gjs-two-color { color: #b9a5a6; }
78
+ .gjs-two-color-h:hover { color: #b9a5a6; }
79
+ .gjs-three-bg { background-color: #804f7b; }
80
+ .gjs-three-color { color: #804f7b; }
81
+ .gjs-three-color-h:hover { color: #804f7b; }
82
+ .gjs-four-bg { background-color: #d97aa6; }
83
+ .gjs-four-color { color: #d97aa6; }
84
+ .gjs-four-color-h:hover { color: #d97aa6; }
85
+
86
+ .gjs-logo-version { background-color: #756467; }
87
+
88
+ .gjs-pn-commands { min-height: 40px; }
89
+ .gjs-pn-btn.gjs-pn-active { box-shadow: none; }
90
+
91
+ .CodeMirror { min-height: 450px; margin-bottom: 8px; }
92
+ .grp-handler-close { background-color: transparent; color: #ddd; }
93
+ .grp-handler-cp-wrap { border-color: transparent; }
94
+
95
+ /* Flex icons (using inline SVG data URIs instead of external images) */
96
+ .icons-flex {
97
+ background-size: 70% 65% !important;
98
+ height: 15px;
99
+ width: 17px;
100
+ opacity: 0.9;
101
+ }
102
+
103
+ /* ── AI Chat Thread Modal ──────────────────── */
104
+ .ai-modal-overlay {
105
+ position: fixed; inset: 0;
106
+ background: rgba(0,0,0,0.6);
107
+ display: flex; align-items: center; justify-content: center;
108
+ z-index: 10000;
109
+ }
110
+ .ai-modal-overlay.hidden { display: none; }
111
+ .ai-modal {
112
+ background: #3b2b2b; border: 1px solid #5e4949; border-radius: 8px;
113
+ width: 520px; max-width: 90vw; max-height: 80vh;
114
+ display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.4);
115
+ overflow: hidden;
116
+ }
117
+ .ai-modal-header {
118
+ padding: 14px 16px; border-bottom: 1px solid #5e4949;
119
+ display: flex; align-items: center; justify-content: space-between;
120
+ }
121
+ .ai-modal-header h3 {
122
+ margin: 0; color: #D4AF37; font-size: 15px; font-weight: 600;
123
+ display: flex; align-items: center; gap: 8px;
124
+ }
125
+ .ai-modal-header .ai-context-tag {
126
+ font-size: 11px; color: #888; font-weight: 400;
127
+ max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
128
+ }
129
+
130
+ /* Existing content preview */
131
+ .ai-existing-content {
132
+ padding: 10px 16px; border-bottom: 1px solid #5e4949;
133
+ background: #352828; display: none;
134
+ }
135
+ .ai-existing-content.visible { display: block; }
136
+ .ai-existing-content-label {
137
+ font-size: 10px; color: #888; text-transform: uppercase;
138
+ letter-spacing: 0.5px; margin-bottom: 4px;
139
+ }
140
+ .ai-existing-content-text {
141
+ font-size: 12px; color: #bbb; line-height: 1.5;
142
+ overflow: hidden; display: -webkit-box;
143
+ -webkit-line-clamp: 3; -webkit-box-orient: vertical;
144
+ word-break: break-word;
145
+ }
146
+
147
+ /* Chat thread area */
148
+ .ai-chat-thread {
149
+ flex: 1; overflow-y: auto; padding: 12px 16px;
150
+ display: flex; flex-direction: column; gap: 10px;
151
+ min-height: 180px; max-height: 50vh;
152
+ }
153
+ .ai-chat-thread:empty::before {
154
+ content: 'Type a message below to start generating text…';
155
+ color: #666; font-size: 13px; font-style: italic;
156
+ align-self: center; margin-top: 40px;
157
+ }
158
+ .ai-msg {
159
+ max-width: 85%; padding: 8px 12px; border-radius: 10px;
160
+ font-size: 13px; line-height: 1.5; word-break: break-word;
161
+ white-space: pre-wrap;
162
+ }
163
+ .ai-msg-user {
164
+ align-self: flex-end; background: #5a3d6a; color: #eee;
165
+ border-bottom-right-radius: 3px;
166
+ }
167
+ .ai-msg-assistant {
168
+ align-self: flex-start; background: #463a3c; color: #eee;
169
+ border: 1px solid #5e4949; border-bottom-left-radius: 3px;
170
+ }
171
+ .ai-msg-error {
172
+ align-self: flex-start; background: #4a2020; color: #f88;
173
+ border: 1px solid #6b3333; border-bottom-left-radius: 3px;
174
+ font-size: 12px;
175
+ }
176
+ .ai-msg-typing {
177
+ align-self: flex-start; background: #463a3c; color: #888;
178
+ border: 1px solid #5e4949; border-bottom-left-radius: 3px;
179
+ font-style: italic;
180
+ }
181
+
182
+ /* Input bar */
183
+ .ai-input-bar {
184
+ display: flex; gap: 8px; padding: 12px 16px;
185
+ border-top: 1px solid #5e4949; align-items: flex-end;
186
+ }
187
+ .ai-input-bar textarea {
188
+ flex: 1; min-height: 38px; max-height: 100px; background: #463a3c;
189
+ border: 1px solid #6b5555; color: #eee; border-radius: 6px;
190
+ padding: 8px 10px; font-size: 13px; font-family: inherit;
191
+ resize: none; outline: none; line-height: 1.4;
192
+ }
193
+ .ai-input-bar textarea:focus { border-color: #D4AF37; }
194
+ .ai-input-bar textarea:disabled { opacity: 0.5; }
195
+
196
+ /* Action buttons bar */
197
+ .ai-btn-bar {
198
+ display: flex; gap: 8px; padding: 0 16px 14px 16px;
199
+ justify-content: flex-end;
200
+ }
201
+ .ai-btn-bar button {
202
+ padding: 6px 16px; border: none; border-radius: 4px;
203
+ font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.15s;
204
+ }
205
+ .btn-ai-send { background: #D4AF37; color: #1a1a2e; }
206
+ .btn-ai-send:hover { background: #E0C564; }
207
+ .btn-ai-send:disabled { opacity: 0.5; cursor: not-allowed; }
208
+ .btn-ai-use {
209
+ background: #4caf50; color: #fff; display: none;
210
+ }
211
+ .btn-ai-use:hover { background: #5cbf60; }
212
+ .btn-ai-use.visible { display: inline-block; }
213
+ .btn-ai-cancel {
214
+ background: transparent; border: 1px solid #6b5555 !important; color: #ddd;
215
+ }
216
+ .btn-ai-cancel:hover { border-color: #D4AF37 !important; color: #D4AF37; }
217
+ .btn-ai-upload { background: #5a3d6a; color: #fff; }
218
+ .btn-ai-upload:hover { background: #6f4c82; }
219
+ .btn-ai-upload:disabled { opacity: 0.5; cursor: not-allowed; }
220
+
221
+ /* ── AI Image Generation Modal (Chat Thread) ───────────── */
222
+ .img-ai-modal-overlay {
223
+ position: fixed; inset: 0;
224
+ background: rgba(0,0,0,0.6);
225
+ display: flex; align-items: center; justify-content: center;
226
+ z-index: 10000;
227
+ }
228
+ .img-ai-modal-overlay.hidden { display: none; }
229
+ .img-ai-modal {
230
+ background: #3b2b2b; border: 1px solid #5e4949; border-radius: 8px;
231
+ width: 560px; max-width: 92vw; max-height: 82vh;
232
+ display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.4);
233
+ overflow: hidden;
234
+ }
235
+ .img-ai-modal-header {
236
+ padding: 14px 16px; border-bottom: 1px solid #5e4949;
237
+ display: flex; align-items: center; justify-content: space-between;
238
+ }
239
+ .img-ai-modal-header h3 {
240
+ margin: 0; color: #D4AF37; font-size: 15px; font-weight: 600;
241
+ display: flex; align-items: center; gap: 8px;
242
+ }
243
+ .img-ai-context-tag {
244
+ font-size: 11px; color: #888; background: #2a1e1e;
245
+ padding: 3px 8px; border-radius: 10px; max-width: 200px;
246
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
247
+ }
248
+ /* ── Thread area ── */
249
+ .img-ai-thread {
250
+ flex: 1; overflow-y: auto; padding: 12px 16px;
251
+ min-height: 200px; max-height: 50vh;
252
+ background: #2a1e1e; display: flex; flex-direction: column; gap: 10px;
253
+ }
254
+ .img-ai-thread-empty {
255
+ color: #666; font-size: 13px; font-style: italic;
256
+ margin: auto; text-align: center;
257
+ }
258
+ /* ── Thread message bubbles ── */
259
+ .img-ai-msg {
260
+ display: flex; flex-direction: column; max-width: 90%;
261
+ animation: imgAiFadeIn 0.25s ease;
262
+ }
263
+ @keyframes imgAiFadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
264
+ .img-ai-msg.user { align-self: flex-end; }
265
+ .img-ai-msg.assistant { align-self: flex-start; }
266
+ .img-ai-msg.error { align-self: flex-start; }
267
+ .img-ai-msg-bubble {
268
+ padding: 8px 12px; border-radius: 10px; font-size: 13px; line-height: 1.45;
269
+ }
270
+ .img-ai-msg.user .img-ai-msg-bubble {
271
+ background: #5e4949; color: #eee; border-bottom-right-radius: 3px;
272
+ }
273
+ .img-ai-msg.assistant .img-ai-msg-bubble {
274
+ background: #3b3230; border: 1px solid #5e4949; padding: 6px;
275
+ border-bottom-left-radius: 3px;
276
+ }
277
+ .img-ai-msg.error .img-ai-msg-bubble {
278
+ background: rgba(244,67,54,0.12); border: 1px solid rgba(244,67,54,0.3);
279
+ color: #f44336; font-size: 12px;
280
+ }
281
+ .img-ai-msg.assistant img {
282
+ max-width: 100%; max-height: 280px; border-radius: 6px;
283
+ object-fit: contain; cursor: pointer; display: block;
284
+ }
285
+ .img-ai-msg.assistant img:hover { opacity: 0.9; }
286
+ .img-ai-msg-meta {
287
+ font-size: 10px; color: #666; margin-top: 3px; padding: 0 4px;
288
+ }
289
+ .img-ai-msg.user .img-ai-msg-meta { text-align: right; }
290
+ /* ── Spinner (inline in thread) ── */
291
+ .img-ai-msg.loading .img-ai-msg-bubble {
292
+ background: #3b3230; border: 1px solid #5e4949;
293
+ display: flex; align-items: center; gap: 10px; padding: 12px 16px;
294
+ }
295
+ .img-ai-msg.loading .loader {
296
+ width: 24px; height: 24px;
297
+ border: 2px solid #5e4949; border-top-color: #D4AF37;
298
+ border-radius: 50%; animation: spin 0.8s linear infinite;
299
+ }
300
+ .img-ai-msg.loading span { color: #888; font-size: 12px; }
301
+ .img-ai-msg.loading .btn-stop-inline {
302
+ margin-left: auto; background: transparent; border: 1px solid #6b5555;
303
+ color: #ccc; border-radius: 4px; padding: 3px 10px; font-size: 11px;
304
+ cursor: pointer; white-space: nowrap;
305
+ }
306
+ .img-ai-msg.loading .btn-stop-inline:hover { border-color: #f44336; color: #f44336; }
307
+ /* Stop button in btn bar */
308
+ .btn-ai-stop {
309
+ background: transparent; border: 1px solid #f44336 !important; color: #f44336;
310
+ display: none;
311
+ }
312
+ .btn-ai-stop:hover { background: rgba(244,67,54,0.12); }
313
+ .btn-ai-stop.visible { display: inline-block; }
314
+ /* ── Input bar ── */
315
+ .img-ai-input-bar {
316
+ display: flex; gap: 8px; padding: 12px 16px;
317
+ border-top: 1px solid #5e4949; align-items: flex-end;
318
+ }
319
+ .img-ai-input-bar textarea {
320
+ flex: 1; min-height: 38px; max-height: 80px; background: #463a3c;
321
+ border: 1px solid #6b5555; color: #eee; border-radius: 6px;
322
+ padding: 8px 10px; font-size: 13px; font-family: inherit;
323
+ resize: none; outline: none; line-height: 1.4;
324
+ }
325
+ .img-ai-input-bar textarea:focus { border-color: #D4AF37; }
326
+ .img-ai-input-bar textarea:disabled { opacity: 0.5; }
327
+ .img-ai-btn-bar {
328
+ display: flex; gap: 8px; padding: 0 16px 14px 16px;
329
+ justify-content: flex-end;
330
+ }
331
+ .img-ai-btn-bar button {
332
+ padding: 6px 16px; border: none; border-radius: 4px;
333
+ font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.15s;
334
+ }
335
+
336
+ /* GrapeJS toolbar AI buttons */
337
+ .gjs-toolbar .fa-magic { color: #D4AF37; }
338
+ .gjs-toolbar .fa-picture-o { color: #D4AF37; }
339
+
340
+ /* Loading overlay */
341
+ .loading-overlay {
342
+ position: fixed; inset: 0;
343
+ background: rgba(59,43,43,0.95);
344
+ display: flex; flex-direction: column;
345
+ align-items: center; justify-content: center;
346
+ z-index: 9999; transition: opacity 0.3s;
347
+ }
348
+ .loading-overlay.hidden { opacity: 0; pointer-events: none; }
349
+ .loader {
350
+ width: 40px; height: 40px;
351
+ border: 3px solid #5e4949; border-top-color: #d97aa6;
352
+ border-radius: 50%; animation: spin 0.8s linear infinite;
353
+ }
354
+ @keyframes spin { to { transform: rotate(360deg); } }
355
+ .loading-overlay p { margin-top: 16px; font-size: 14px; color: #aaa; }
356
+ </style>
357
+ </head>
358
+ <body>
359
+
360
+ <!-- Loading Overlay -->
361
+ <div class="loading-overlay" id="loadingOverlay">
362
+ <div class="loader"></div>
363
+ <p>Loading editor…</p>
364
+ </div>
365
+
366
+ <!-- AI Chat Thread Modal -->
367
+ <div class="ai-modal-overlay hidden" id="aiModal">
368
+ <div class="ai-modal">
369
+ <div class="ai-modal-header">
370
+ <h3><span class="ai-icon">&#9889;</span> AI Text Generator</h3>
371
+ <span class="ai-context-tag" id="aiContextTag"></span>
372
+ </div>
373
+ <div class="ai-existing-content" id="aiExistingContent">
374
+ <div class="ai-existing-content-label">Existing content</div>
375
+ <div class="ai-existing-content-text" id="aiExistingText"></div>
376
+ </div>
377
+ <div class="ai-chat-thread" id="aiChatThread"></div>
378
+ <div class="ai-input-bar">
379
+ <textarea id="aiPrompt" rows="1" placeholder="Type a message…" spellcheck="false"></textarea>
380
+ </div>
381
+ <div class="ai-btn-bar">
382
+ <button class="btn-ai-cancel" onclick="aiClose()">Cancel</button>
383
+ <button class="btn-ai-use" id="aiUseBtn" onclick="aiUseText()">Use</button>
384
+ <button class="btn-ai-send" id="aiSendBtn" onclick="aiSend()">Send</button>
385
+ </div>
386
+ </div>
387
+ </div>
388
+
389
+ <!-- AI Image Generation Modal (Chat Thread) -->
390
+ <div class="img-ai-modal-overlay hidden" id="imgAiModal">
391
+ <div class="img-ai-modal">
392
+ <div class="img-ai-modal-header">
393
+ <h3><span class="ai-icon">&#127912;</span> AI Image Generator</h3>
394
+ <span class="img-ai-context-tag" id="imgAiContextTag"></span>
395
+ </div>
396
+ <div class="img-ai-thread" id="imgAiThread">
397
+ <div class="img-ai-thread-empty" id="imgAiThreadEmpty">
398
+ Describe the image you want to generate…
399
+ </div>
400
+ <!-- Thread messages will be appended here dynamically -->
401
+ </div>
402
+ <div class="img-ai-input-bar">
403
+ <textarea id="imgAiPrompt" rows="1" placeholder="e.g. A scenic Kerala backwater with a houseboat at sunset…" spellcheck="false"></textarea>
404
+ </div>
405
+ <div class="img-ai-btn-bar">
406
+ <input type="file" id="imgAiUploadInput" accept="image/*" hidden>
407
+ <button class="btn-ai-cancel" onclick="imgAiClose()">Close</button>
408
+ <button class="btn-ai-upload" id="imgAiUploadBtn" onclick="document.getElementById('imgAiUploadInput').click()">Upload Image</button>
409
+ <button class="btn-ai-use" id="imgAiUseBtn" onclick="imgAiUse()">Use</button>
410
+ <button class="btn-ai-stop" id="imgAiStopBtn" onclick="imgAiCancelGeneration()">Stop</button>
411
+ <button class="btn-ai-send" id="imgAiSendBtn" onclick="imgAiGenerate()">Send</button>
412
+ </div>
413
+ </div>
414
+ </div>
415
+
416
+ <!-- Top Bar -->
417
+ <div class="admin-topbar">
418
+ <div class="brand"><span id="siteBrandName">Admin</span> <span>Visual Editor</span></div>
419
+ <div class="controls">
420
+ <select id="pageSelect" title="Select page to edit"></select>
421
+ <button class="btn-live" id="liveEditBtn" onclick="toggleLiveEditMode()" title="Loading editor..." aria-pressed="false" disabled>Live Edit</button>
422
+ <button class="btn-preview" onclick="previewPage()" title="Preview in new tab">Preview</button>
423
+ <button class="btn-save" id="saveBtn" onclick="savePage()">Save</button>
424
+ <span class="save-status" id="saveStatus"></span>
425
+ </div>
426
+ </div>
427
+
428
+ <!-- GrapeJS Editor -->
429
+ <div id="gjs"></div>
430
+
431
+ <!-- ── Libraries (exact versions from demo) ────────────────── -->
432
+
433
+ <!-- GrapeJS Core v0.22.2 (same as demo) -->
434
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs@0.22.2"></script>
435
+
436
+ <!-- GrapeJS Plugins (same order and versions as demo) -->
437
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-blocks-basic@1.0.1"></script>
438
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-plugin-forms@2.0.5"></script>
439
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-component-countdown@1.0.1"></script>
440
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-plugin-export@1.0.11"></script>
441
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-tabs@1.0.6"></script>
442
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-custom-code@1.0.1"></script>
443
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-touch@0.1.1"></script>
444
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-parser-postcss@1.0.1"></script>
445
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-tooltip@0.1.7"></script>
446
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-tui-image-editor@0.1.3"></script>
447
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-typed@1.0.5"></script>
448
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-style-bg@2.0.1"></script>
449
+ <script src="https://cdn.jsdelivr.net/npm/grapesjs-preset-webpage@1.0.2"></script>
450
+
451
+ <script>
452
+ // ── State ───────────────────────────────────────
453
+ let editor = null;
454
+ let currentPage = null;
455
+ let currentHeadContent = '';
456
+ let currentCanvasResources = null;
457
+ let liveEditMode = false;
458
+ let liveEditDirty = false;
459
+ let liveEditReady = false;
460
+
461
+ const LIVE_EDIT_SELECTOR = [
462
+ 'h1','h2','h3','h4','h5','h6','p','span','li','a','button','summary',
463
+ 'figcaption','blockquote','cite','label','small','strong','em'
464
+ ].join(',');
465
+
466
+ // ─────────────────────────────────────────────────
467
+ // Custom plugin: merges Traits panel into Style Manager
468
+ // as a collapsible "Settings" sector (exactly like the demo)
469
+ // ─────────────────────────────────────────────────
470
+ function customDemoPlugin(editor) {
471
+ // Add i18n for background properties
472
+ editor.I18n.addMessages({
473
+ en: {
474
+ styleManager: {
475
+ properties: {
476
+ 'background-repeat': 'Repeat',
477
+ 'background-position': 'Position',
478
+ 'background-attachment': 'Attachment',
479
+ 'background-size': 'Size',
480
+ }
481
+ }
482
+ }
483
+ });
484
+
485
+ // ── Make <details>/<summary> FAQ accordion editable ──────
486
+ // GrapeJS doesn't have built-in types for these HTML5 elements.
487
+ // Without custom types, <summary> shows as an opaque "Summary" badge
488
+ // and <details> hides its answer content when collapsed in the canvas.
489
+ editor.DomComponents.addType('details', {
490
+ isComponent: el => el.tagName === 'DETAILS',
491
+ model: {
492
+ defaults: {
493
+ tagName: 'details',
494
+ droppable: true,
495
+ // Always open in editor so answer content is visible for editing
496
+ attributes: { open: true },
497
+ traits: [
498
+ {
499
+ type: 'checkbox', name: 'open', label: 'Open by default',
500
+ },
501
+ ],
502
+ },
503
+ init() {
504
+ // Force open in the canvas so editors can see & select all children
505
+ const el = this.getEl();
506
+ if (el) el.setAttribute('open', '');
507
+ this.on('change:attributes:open', () => {
508
+ const el = this.getEl();
509
+ if (el) el.setAttribute('open', '');
510
+ });
511
+ },
512
+ },
513
+ view: {
514
+ onRender() {
515
+ // Ensure the DOM element is open in the canvas
516
+ if (this.el) this.el.setAttribute('open', '');
517
+ },
518
+ },
519
+ });
520
+
521
+ editor.DomComponents.addType('summary', {
522
+ isComponent: el => el.tagName === 'SUMMARY',
523
+ model: {
524
+ defaults: {
525
+ tagName: 'summary',
526
+ droppable: true, // allow children (SVG chevron icon, spans, etc.)
527
+ editable: true, // allow inline text editing
528
+ highlightable: true,
529
+ hoverable: true,
530
+ selectable: true,
531
+ traits: [],
532
+ },
533
+ },
534
+ view: {
535
+ events: {
536
+ // Block native <summary> click-to-toggle so it doesn't
537
+ // collapse <details> and interfere with GrapeJS selection
538
+ click: 'handleClick',
539
+ dblclick: 'handleDblClick',
540
+ },
541
+ handleClick(e) {
542
+ e.preventDefault();
543
+ e.stopPropagation();
544
+ // Let GrapeJS handle selection
545
+ if (this.model && this.em) {
546
+ this.em.select(this.model);
547
+ }
548
+ },
549
+ handleDblClick(e) {
550
+ e.preventDefault();
551
+ e.stopPropagation();
552
+ // Activate the Rich Text Editor for inline editing
553
+ if (this.model && this.em) {
554
+ this.em.select(this.model);
555
+ const rte = this.em.get('RichTextEditor');
556
+ if (rte) {
557
+ // Use editor command to enable RTE on this component
558
+ this.em.get('Commands').run('core:component-enter');
559
+ }
560
+ }
561
+ },
562
+ onRender() {
563
+ // Also block via pointer-events on the native disclosure triangle
564
+ // and prevent toggle via CSS
565
+ if (this.el) {
566
+ this.el.style.cursor = 'default';
567
+ this.el.style.listStyle = 'none';
568
+ // Webkit hides a ::marker / disclosure widget — remove it
569
+ this.el.style.display = 'flex';
570
+ }
571
+ },
572
+ },
573
+ });
574
+
575
+ // ── Ensure link/anchor components have editable traits ──
576
+ editor.DomComponents.addType('link', {
577
+ isComponent: el => el.tagName === 'A',
578
+ model: {
579
+ defaults: {
580
+ traits: [
581
+ { type: 'text', name: 'href', label: 'URL', placeholder: 'https://...' },
582
+ {
583
+ type: 'select', name: 'target', label: 'Open in',
584
+ options: [
585
+ { value: '', name: 'This window' },
586
+ { value: '_blank', name: 'New window' },
587
+ ],
588
+ },
589
+ { type: 'text', name: 'title', label: 'Title' },
590
+ { type: 'text', name: 'data-ht-onclick', label: 'OnClick handler' },
591
+ ],
592
+ // Allow content editing (text inside the link)
593
+ editable: true,
594
+ },
595
+ },
596
+ });
597
+
598
+ // ── Override button component so forms plugin doesn't hijack FAQ buttons ──
599
+ // The forms plugin's button type has an init() that checks for a single
600
+ // textnode child; if the button has multiple children (like FAQ toggles
601
+ // with <span>question</span> + <span>+</span>), it falls back to the
602
+ // default text "Send" and REPLACES all children via __onTextChange().
603
+ // This override removes that destructive init/change behavior entirely.
604
+ editor.DomComponents.addType('button', {
605
+ isComponent: el => el.tagName === 'BUTTON',
606
+ model: {
607
+ defaults: {
608
+ tagName: 'button',
609
+ droppable: true,
610
+ highlightable: true,
611
+ attributes: { type: 'button' },
612
+ traits: [
613
+ {
614
+ type: 'select', name: 'type', label: 'Type',
615
+ options: [
616
+ { value: 'button', name: 'Button' },
617
+ { value: 'submit', name: 'Submit' },
618
+ { value: 'reset', name: 'Reset' },
619
+ ],
620
+ },
621
+ { type: 'text', name: 'data-ht-onclick', label: 'OnClick handler' },
622
+ ],
623
+ },
624
+ // Override init to do NOTHING — preserve original children as-is
625
+ init() {},
626
+ // Remove the destructive __onTextChange that replaces children with text
627
+ __onTextChange() {},
628
+ },
629
+ });
630
+
631
+ editor.onReady(() => {
632
+ const pn = editor.Panels;
633
+
634
+ // Activate both traits and style manager views
635
+ const btnTm = pn.getButton('views', 'open-tm');
636
+ const btnSm = pn.getButton('views', 'open-sm');
637
+ if (btnTm) btnTm.set('active', true);
638
+ if (btnSm) btnSm.set('active', true);
639
+
640
+ // Remove the standalone traits tab button
641
+ pn.removeButton('views', 'open-tm');
642
+
643
+ // Create a "Settings" collapsible sector inside Style Manager
644
+ // NOTE: starts OPEN (display:block) so traits are visible immediately
645
+ const settingsSector = document.createElement('div');
646
+ settingsSector.className = 'gjs-sm-sector no-select';
647
+ settingsSector.innerHTML = `
648
+ <div class="gjs-sm-sector-title" style="cursor:pointer;">
649
+ <span class="icon-settings fa fa-cog"></span>
650
+ <span class="gjs-sm-sector-label">Component Settings</span>
651
+ </div>
652
+ <div class="gjs-sm-properties" style="display: block;"></div>
653
+ `;
654
+
655
+ const propsContainer = settingsSector.querySelector('.gjs-sm-properties');
656
+ const traitsContainer = document.querySelector('.gjs-traits-cs');
657
+ const smSectors = document.querySelector('.gjs-sm-sectors');
658
+ const sectorTitle = settingsSector.querySelector('.gjs-sm-sector-title');
659
+
660
+ // Move traits into the settings sector
661
+ if (traitsContainer && propsContainer) {
662
+ propsContainer.appendChild(traitsContainer);
663
+ }
664
+
665
+ // Prepend settings sector at top of style manager
666
+ if (smSectors) {
667
+ smSectors.prepend(settingsSector);
668
+ }
669
+
670
+ // Toggle collapse on click
671
+ sectorTitle.onclick = () => {
672
+ const isHidden = propsContainer.style.display === 'none';
673
+ propsContainer.style.display = isHidden ? 'block' : 'none';
674
+ };
675
+
676
+ // ── Auto-switch to Style Manager when a component is selected ──
677
+ // This ensures users always see the traits/settings panel
678
+ editor.on('component:selected', () => {
679
+ const btnSm = pn.getButton('views', 'open-sm');
680
+ if (btnSm && !btnSm.get('active')) {
681
+ btnSm.set('active', true);
682
+ }
683
+ });
684
+
685
+ // Open blocks panel by default
686
+ const btnBlocks = pn.getButton('views', 'open-blocks');
687
+ if (btnBlocks) btnBlocks.set('active', true);
688
+ });
689
+
690
+ // ── AI toolbar buttons ─────────────────────────────────────────
691
+ editor.on('component:selected', (component) => {
692
+ const toolbar = component.get('toolbar');
693
+ const tagName = (component.get('tagName') || '').toLowerCase();
694
+
695
+ // Text generation for text-bearing components
696
+ if (!toolbar.some(btn => btn.id === 'ai-generate')) {
697
+ const textTags = ['h1','h2','h3','h4','h5','h6','p','span','a','li','td','th',
698
+ 'label','button','figcaption','blockquote','cite','em','strong',
699
+ 'small','summary','div'];
700
+ if (textTags.includes(tagName)) {
701
+ toolbar.push({
702
+ id: 'ai-generate',
703
+ attributes: { class: 'fa fa-magic', title: 'Generate text with AI' },
704
+ command: 'ai-generate-text',
705
+ });
706
+ }
707
+ }
708
+
709
+ // Image generation for <img> components
710
+ if (!toolbar.some(btn => btn.id === 'ai-generate-img')) {
711
+ if (tagName === 'img' || component.get('type') === 'image') {
712
+ toolbar.push({
713
+ id: 'ai-generate-img',
714
+ attributes: { class: 'fa fa-picture-o', title: 'Generate image with AI' },
715
+ command: 'ai-generate-image',
716
+ });
717
+ }
718
+ }
719
+
720
+ component.set('toolbar', toolbar);
721
+ });
722
+
723
+ // Register AI commands
724
+ editor.Commands.add('ai-generate-text', {
725
+ run(editor) { aiOpen(); },
726
+ });
727
+ editor.Commands.add('ai-generate-image', {
728
+ run(editor) { imgAiOpen(); },
729
+ });
730
+ }
731
+
732
+ // ── Boot ────────────────────────────────────────
733
+ document.addEventListener('DOMContentLoaded', async () => {
734
+ // Load site config and set branding
735
+ try {
736
+ const cfgRes = await fetch('/api/site-config');
737
+ if (cfgRes.ok) {
738
+ const cfg = await cfgRes.json();
739
+ const brandName = cfg.name || 'Admin';
740
+ document.getElementById('siteBrandName').textContent = brandName + ' Admin';
741
+ document.title = brandName + ' Admin – Visual Editor';
742
+ }
743
+ } catch (e) { /* keep defaults */ }
744
+
745
+ await loadPageList();
746
+
747
+ const select = document.getElementById('pageSelect');
748
+ if (select.options.length > 0) {
749
+ const pageData = await fetchPageData(select.value);
750
+ if (pageData) {
751
+ currentPage = pageData.filename;
752
+ currentHeadContent = pageData.headContent;
753
+ currentCanvasResources = pageData.canvasResources;
754
+ // initEditor now handles async resource loading internally;
755
+ // the overlay hides once the editor fires onReady + resources injected
756
+ initEditor(pageData);
757
+ }
758
+ }
759
+
760
+ // Small delay to let editor.onReady fire and resources inject
761
+ setTimeout(() => {
762
+ document.getElementById('loadingOverlay').classList.add('hidden');
763
+ }, 2500);
764
+ });
765
+
766
+ // ── Fetch page list ─────────────────────────────
767
+ async function loadPageList() {
768
+ try {
769
+ const res = await fetch('/api/pages');
770
+ const data = await res.json();
771
+ const select = document.getElementById('pageSelect');
772
+ select.innerHTML = '';
773
+ data.pages.forEach(page => {
774
+ const opt = document.createElement('option');
775
+ opt.value = page;
776
+ opt.textContent = page;
777
+ select.appendChild(opt);
778
+ });
779
+ select.addEventListener('change', () => switchPage(select.value));
780
+ } catch (err) {
781
+ console.error('Failed to load page list:', err);
782
+ }
783
+ }
784
+
785
+ // ── Fetch page data from API ────────────────────
786
+ async function fetchPageData(filename) {
787
+ try {
788
+ const res = await fetch(`/api/pages/${encodeURIComponent(filename)}`);
789
+ if (!res.ok) throw new Error('Failed to load');
790
+ return await res.json();
791
+ } catch (err) {
792
+ console.error('Failed to fetch page:', err);
793
+ return null;
794
+ }
795
+ }
796
+
797
+ // ── Initialize GrapeJS Editor (matching demo exactly) ──
798
+ function initEditor(pageData) {
799
+ const res = pageData.canvasResources;
800
+
801
+ // NOTE: Do NOT use canvas.styles/scripts — they cause race conditions
802
+ // with Tailwind CDN. We inject everything manually in the correct order.
803
+ editor = grapesjs.init({
804
+ container: '#gjs',
805
+ fromElement: false,
806
+ height: '100%',
807
+ width: 'auto',
808
+ storageManager: false,
809
+
810
+ canvas: {
811
+ styles: [],
812
+ scripts: [],
813
+ },
814
+
815
+ // ── Plugins: same list & order as demo, with custom plugin last ──
816
+ plugins: [
817
+ 'gjs-blocks-basic',
818
+ 'grapesjs-plugin-forms',
819
+ 'grapesjs-component-countdown',
820
+ 'grapesjs-plugin-export',
821
+ 'grapesjs-tabs',
822
+ 'grapesjs-custom-code',
823
+ 'grapesjs-touch',
824
+ 'grapesjs-parser-postcss',
825
+ 'grapesjs-tooltip',
826
+ 'grapesjs-tui-image-editor',
827
+ 'grapesjs-typed',
828
+ 'grapesjs-style-bg',
829
+ 'grapesjs-preset-webpage',
830
+ customDemoPlugin,
831
+ ],
832
+ pluginsOpts: {
833
+ 'gjs-blocks-basic': { flexGrid: true },
834
+ 'grapesjs-tui-image-editor': {
835
+ script: [
836
+ 'https://uicdn.toast.com/tui.code-snippet/v1.5.2/tui-code-snippet.min.js',
837
+ 'https://uicdn.toast.com/tui-color-picker/v2.2.7/tui-color-picker.min.js',
838
+ 'https://uicdn.toast.com/tui-image-editor/v3.15.2/tui-image-editor.min.js',
839
+ ],
840
+ style: [
841
+ 'https://uicdn.toast.com/tui-color-picker/v2.2.7/tui-color-picker.min.css',
842
+ 'https://uicdn.toast.com/tui-image-editor/v3.15.2/tui-image-editor.min.css',
843
+ ],
844
+ },
845
+ 'grapesjs-tabs': {
846
+ tabsBlock: { category: 'Extra' },
847
+ },
848
+ 'grapesjs-typed': {
849
+ block: {
850
+ category: 'Extra',
851
+ content: {
852
+ type: 'typed',
853
+ 'type-speed': 40,
854
+ strings: ['Text row one', 'Text row two', 'Text row three'],
855
+ },
856
+ },
857
+ },
858
+ 'grapesjs-preset-webpage': {
859
+ modalImportTitle: 'Import Template',
860
+ modalImportLabel: '<div style="margin-bottom: 10px; font-size: 13px;">Paste here your HTML/CSS and click Import</div>',
861
+ modalImportContent: (editor) => editor.getHtml() + '<style>' + editor.getCss() + '</style>',
862
+ },
863
+ 'grapesjs-plugin-forms': {
864
+ // Exclude 'button' block so the forms plugin doesn't
865
+ // hijack <button> elements (e.g. FAQ accordion toggles)
866
+ blocks: ['form', 'input', 'textarea', 'select', 'checkbox', 'radio', 'label'],
867
+ },
868
+ 'grapesjs-component-countdown': {},
869
+ 'grapesjs-plugin-export': {},
870
+ 'grapesjs-custom-code': {},
871
+ 'grapesjs-touch': {},
872
+ 'grapesjs-parser-postcss': {},
873
+ 'grapesjs-tooltip': {},
874
+ 'grapesjs-style-bg': {},
875
+ },
876
+
877
+ // Device manager
878
+ deviceManager: {
879
+ devices: [
880
+ { name: 'Desktop', width: '' },
881
+ { name: 'Tablet', width: '768px', widthMedia: '992px' },
882
+ { name: 'Mobile landscape', width: '568px', widthMedia: '768px' },
883
+ { name: 'Mobile portrait', width: '375px', widthMedia: '480px' },
884
+ ]
885
+ },
886
+
887
+ assetManager: {
888
+ assets: [],
889
+ upload: '/api/upload', // POST images to server
890
+ uploadName: 'files', // multer field name (GrapeJS default)
891
+ autoAdd: true, // auto-add to asset list after upload
892
+ multiUpload: true,
893
+ dropzone: true,
894
+ },
895
+ });
896
+
897
+ // ── Load canvas resources, THEN set components ──
898
+ // This is the KEY fix: we wait for the editor to be ready,
899
+ // inject ALL resources in strict order (stylesheets → inline CSS →
900
+ // external scripts → inline scripts), wait for each to load,
901
+ // and ONLY THEN set components so Tailwind can style them.
902
+ editor.onReady(async () => {
903
+ const frame = editor.Canvas.getFrameEl();
904
+ const doc = frame.contentDocument;
905
+
906
+ if (doc) {
907
+ await injectCanvasResources(doc, res, pageData.bodyAttributes);
908
+ }
909
+
910
+ // NOW load components after all styles/scripts are ready
911
+ loadComponentsSafe(pageData.bodyContent, pageData.gjsCss);
912
+ });
913
+
914
+ // ── Upload event logging & error handling ──
915
+ editor.on('asset:upload:start', () => {
916
+ console.log('📤 Upload started…');
917
+ });
918
+ editor.on('asset:upload:end', (response) => {
919
+ console.log('✅ Upload complete:', response);
920
+ });
921
+ editor.on('asset:upload:error', (err) => {
922
+ console.error('❌ Upload error:', err);
923
+ alert('Image upload failed: ' + (err.message || err));
924
+ });
925
+ editor.on('asset:upload:response', (response) => {
926
+ console.log('📦 Upload response:', response);
927
+ });
928
+
929
+ // ── Fallback: open file picker on dropzone click ──
930
+ // Some Windows browsers don't propagate clicks to the hidden
931
+ // file input inside GrapeJS's dropzone. This wires up a
932
+ // manual upload via a visible file input as a safety net.
933
+ editor.on('run:open-assets', () => {
934
+ setTimeout(() => {
935
+ const dropzone = document.querySelector('.gjs-am-file-uploader .gjs-am-assets-header, .gjs-dropzone');
936
+ if (!dropzone) return;
937
+
938
+ // Only add the fallback once
939
+ if (dropzone.dataset.fallbackAdded) return;
940
+ dropzone.dataset.fallbackAdded = 'true';
941
+
942
+ // Check if the native GrapeJS file input exists and is working
943
+ const nativeInput = dropzone.closest('.gjs-am-file-uploader')
944
+ ?.querySelector('input[type="file"]');
945
+
946
+ if (!nativeInput) {
947
+ // Create a fallback file input if GrapeJS didn't render one
948
+ const input = document.createElement('input');
949
+ input.type = 'file';
950
+ input.accept = 'image/*';
951
+ input.multiple = true;
952
+ input.style.cssText = 'position:absolute;inset:0;opacity:0;cursor:pointer;z-index:10;';
953
+ dropzone.style.position = 'relative';
954
+ dropzone.appendChild(input);
955
+
956
+ input.addEventListener('change', () => {
957
+ if (input.files.length > 0) uploadFilesManually(input.files);
958
+ input.value = '';
959
+ });
960
+ }
961
+ }, 300);
962
+ });
963
+
964
+ // Manual upload function as fallback
965
+ async function uploadFilesManually(files) {
966
+ const formData = new FormData();
967
+ for (const file of files) {
968
+ formData.append('files', file);
969
+ }
970
+ try {
971
+ const res = await fetch('/api/upload', { method: 'POST', body: formData });
972
+ const json = await res.json();
973
+ if (json.data && Array.isArray(json.data)) {
974
+ json.data.forEach(url => {
975
+ editor.AssetManager.add({ src: url, name: url.split('/').pop() });
976
+ });
977
+ console.log('✅ Manual upload complete:', json.data);
978
+ }
979
+ } catch (err) {
980
+ console.error('❌ Manual upload failed:', err);
981
+ alert('Image upload failed: ' + err.message);
982
+ }
983
+ }
984
+
985
+ // Ctrl+S to save
986
+ document.addEventListener('keydown', (e) => {
987
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
988
+ e.preventDefault();
989
+ savePage();
990
+ }
991
+ });
992
+ }
993
+
994
+ // ── Sequential async resource injection into canvas iframe ──
995
+ // Ensures: stylesheets load → inline CSS applied → external scripts
996
+ // load one-by-one → inline scripts run (e.g. Tailwind config)
997
+ function applyCanvasBodyDefaults(doc, bodyAttributes = {}) {
998
+ if (!doc) return;
999
+
1000
+ // Keep the editor canvas predictable/readable. The live site can still
1001
+ // switch themes, but the editing iframe should not inherit a dark saved
1002
+ // preference while GrapeJS is showing a white canvas background.
1003
+ doc.documentElement.setAttribute('data-theme', 'light');
1004
+ doc.documentElement.style.colorScheme = 'light';
1005
+
1006
+ if (!doc.body) return;
1007
+ const pageClasses = String(bodyAttributes.class || '').split(/\s+/).filter(Boolean);
1008
+ pageClasses.forEach(cls => doc.body.classList.add(cls));
1009
+ doc.body.style.background = 'var(--color-bg)';
1010
+ doc.body.style.color = 'var(--color-ink)';
1011
+ }
1012
+
1013
+ async function injectCanvasResources(doc, resources, bodyAttributes = {}) {
1014
+ if (!doc || !doc.head) return;
1015
+ if (doc.head.querySelector('[data-gjs-injected]')) return;
1016
+
1017
+ // Marker to prevent double injection
1018
+ const marker = doc.createElement('meta');
1019
+ marker.setAttribute('data-gjs-injected', 'true');
1020
+ doc.head.appendChild(marker);
1021
+
1022
+ // Set <base href> to the CURRENT PAGE'S OWN DIRECTORY so relative paths
1023
+ // resolve exactly as they do on the live site. Sub-pages reference images
1024
+ // relatively (e.g. images/foo.webp on /why-.../index.html); a hardcoded
1025
+ // "/" base would resolve those to /images/foo.webp (404 → broken images).
1026
+ // Using the page directory makes images/foo.webp -> /why-.../images/foo.webp,
1027
+ // while root-relative refs (../assets, /assets) still resolve correctly.
1028
+ const pageDir = (typeof currentPage === 'string' && currentPage.indexOf('/') !== -1)
1029
+ ? '/' + currentPage.replace(/[^/]*$/, '') // strip filename, keep trailing dir
1030
+ : '/';
1031
+ let baseEl = doc.head.querySelector('base');
1032
+ if (!baseEl) {
1033
+ baseEl = doc.createElement('base');
1034
+ doc.head.prepend(baseEl);
1035
+ }
1036
+ // Always update (switchPage reuses the iframe; a stale base must be refreshed)
1037
+ baseEl.href = pageDir;
1038
+ applyCanvasBodyDefaults(doc, bodyAttributes);
1039
+
1040
+ // Inject editor-only CSS overrides inside the canvas iframe.
1041
+ // These styles make <details>/<summary> behave as normal blocks
1042
+ // inside the editor — suppressing the native disclosure toggle,
1043
+ // keeping all <details> expanded, and making text selectable.
1044
+ const editorCSS = doc.createElement('style');
1045
+ editorCSS.setAttribute('data-gjs-editor-overrides', 'true');
1046
+ editorCSS.textContent = `
1047
+ /* Force all <details> open & remove toggle behaviour in editor */
1048
+ details { display: block !important; }
1049
+ details > *:not(summary) { display: block !important; }
1050
+
1051
+ /* Remove the native disclosure triangle from <summary> */
1052
+ summary { list-style: none !important; cursor: default !important; }
1053
+ summary::-webkit-details-marker { display: none !important; }
1054
+ summary::marker { display: none !important; content: "" !important; }
1055
+
1056
+ /* Prevent <summary> click from toggling <details> */
1057
+ summary { pointer-events: auto; }
1058
+
1059
+ html,
1060
+ body {
1061
+ background: var(--color-bg) !important;
1062
+ color: var(--color-ink) !important;
1063
+ }
1064
+
1065
+ body.buzl-live-edit-on [data-buzl-live-edit="true"] {
1066
+ outline: 1px dashed rgba(0, 64, 255, .38);
1067
+ outline-offset: 2px;
1068
+ cursor: text !important;
1069
+ }
1070
+
1071
+ body.buzl-live-edit-on [data-buzl-live-edit="true"]:focus {
1072
+ outline: 2px solid var(--color-ring);
1073
+ background: rgba(0, 64, 255, .06);
1074
+ }
1075
+
1076
+ /* Reveal animations rely on the live site's JS. Keep those blocks
1077
+ visible in the editor so section text can be selected and edited. */
1078
+ .reveal {
1079
+ opacity: 1 !important;
1080
+ transform: none !important;
1081
+ animation: none !important;
1082
+ }
1083
+
1084
+ /* Hide preloader/loading overlays — in the live site, JS hides these
1085
+ after page load, but GrapeJS strips body scripts so they stay visible
1086
+ and cover all editable content. Force-hide them in the editor. */
1087
+ #preloader,
1088
+ .loading-form,
1089
+ .preloader-close {
1090
+ display: none !important;
1091
+ }
1092
+ `;
1093
+ doc.head.appendChild(editorCSS);
1094
+
1095
+ // 1. Load external stylesheets (fonts, etc.) in parallel
1096
+ const linkPromises = (resources.cssLinks || []).map(href => {
1097
+ return new Promise(resolve => {
1098
+ const link = doc.createElement('link');
1099
+ link.rel = 'stylesheet';
1100
+ link.href = href;
1101
+ link.onload = resolve;
1102
+ link.onerror = resolve;
1103
+ doc.head.appendChild(link);
1104
+ });
1105
+ });
1106
+ await Promise.all(linkPromises);
1107
+
1108
+ // 2. Inject inline styles (CSS variables, custom component styles)
1109
+ (resources.inlineStyles || []).forEach(css => {
1110
+ const style = doc.createElement('style');
1111
+ style.textContent = css;
1112
+ doc.head.appendChild(style);
1113
+ });
1114
+
1115
+ // 3. Load external scripts ONE BY ONE in order
1116
+ // (Tailwind CDN must fully load before config script runs)
1117
+ for (const src of (resources.scriptSrcs || [])) {
1118
+ await new Promise(resolve => {
1119
+ const script = doc.createElement('script');
1120
+ script.src = src;
1121
+ script.onload = resolve;
1122
+ script.onerror = resolve;
1123
+ doc.head.appendChild(script);
1124
+ });
1125
+ }
1126
+
1127
+ // 4. NOW inject inline scripts (Tailwind config, etc.)
1128
+ // These run synchronously and can reference the loaded CDN
1129
+ (resources.inlineScripts || []).forEach(js => {
1130
+ if (js.includes('site-theme') && js.includes('data-theme')) return;
1131
+ const script = doc.createElement('script');
1132
+ script.textContent = js;
1133
+ doc.head.appendChild(script);
1134
+ });
1135
+ applyCanvasBodyDefaults(doc, bodyAttributes);
1136
+
1137
+ // 5. Inject editor-only JS into the canvas to block <summary> native toggle.
1138
+ // CSS alone can't fully prevent the click-to-toggle on <details>/<summary>;
1139
+ // we need an event listener to call preventDefault().
1140
+ const editorScript = doc.createElement('script');
1141
+ editorScript.setAttribute('data-gjs-editor-overrides', 'true');
1142
+ editorScript.textContent = `
1143
+ document.addEventListener('click', function(e) {
1144
+ var summary = e.target.closest('summary');
1145
+ if (summary) {
1146
+ e.preventDefault();
1147
+ e.stopPropagation();
1148
+ // Keep parent <details> forced open
1149
+ var details = summary.closest('details');
1150
+ if (details) details.setAttribute('open', '');
1151
+ }
1152
+ }, true);
1153
+
1154
+ // Also re-open any <details> that somehow got closed
1155
+ new MutationObserver(function(mutations) {
1156
+ mutations.forEach(function(m) {
1157
+ if (m.type === 'attributes' && m.attributeName === 'open') {
1158
+ var details = m.target;
1159
+ if (details.tagName === 'DETAILS' && !details.hasAttribute('open')) {
1160
+ details.setAttribute('open', '');
1161
+ }
1162
+ }
1163
+ });
1164
+ }).observe(document.body, { attributes: true, subtree: true, attributeFilter: ['open'] });
1165
+ `;
1166
+ doc.head.appendChild(editorScript);
1167
+ }
1168
+
1169
+ // ── Switch to a different page ──────────────────
1170
+ async function switchPage(filename) {
1171
+ if (!editor) return;
1172
+ setStatus('Loading…', 'saving');
1173
+
1174
+ const pageData = await fetchPageData(filename);
1175
+ if (!pageData) { setStatus('Load failed', 'error'); return; }
1176
+
1177
+ currentPage = pageData.filename;
1178
+ currentHeadContent = pageData.headContent;
1179
+ currentCanvasResources = pageData.canvasResources;
1180
+ liveEditMode = false;
1181
+ liveEditDirty = false;
1182
+ setLiveEditButton(false);
1183
+ setLiveEditReady(false);
1184
+
1185
+ // Clear injection marker so resources get re-injected
1186
+ const frame = editor.Canvas.getFrameEl();
1187
+ if (frame && frame.contentDocument) {
1188
+ const marker = frame.contentDocument.head.querySelector('[data-gjs-injected]');
1189
+ if (marker) marker.remove();
1190
+ // Remove previously injected resources
1191
+ frame.contentDocument.head.querySelectorAll('link[rel="stylesheet"], style, script').forEach(el => el.remove());
1192
+
1193
+ // Re-inject resources for the new page
1194
+ await injectCanvasResources(frame.contentDocument, pageData.canvasResources, pageData.bodyAttributes);
1195
+ }
1196
+
1197
+ // Set components AFTER resources are loaded
1198
+ const ok = loadComponentsSafe(pageData.bodyContent, pageData.gjsCss);
1199
+
1200
+ if (ok) {
1201
+ setStatus('Loaded', 'success');
1202
+ setTimeout(refreshLiveEditTargets, 0);
1203
+ setTimeout(() => setStatus(''), 2000);
1204
+ }
1205
+ // on failure, loadComponentsSafe() has already shown the error in the status bar
1206
+ }
1207
+
1208
+ // ── Live Edit mode ──────────────────────────────
1209
+ // Lets non-technical editors click directly into visible text on the canvas.
1210
+ // Temporary contenteditable markers are stripped before saving.
1211
+ function getCanvasDocument() {
1212
+ return editor && editor.Canvas ? editor.Canvas.getDocument() : null;
1213
+ }
1214
+
1215
+ function setLiveEditButton(active) {
1216
+ const btn = document.getElementById('liveEditBtn');
1217
+ if (!btn) return;
1218
+ btn.classList.toggle('active', active);
1219
+ btn.textContent = active ? 'Live Edit On' : 'Live Edit';
1220
+ btn.setAttribute('aria-pressed', String(active));
1221
+ }
1222
+
1223
+ function setLiveEditReady(ready) {
1224
+ liveEditReady = Boolean(ready);
1225
+ const btn = document.getElementById('liveEditBtn');
1226
+ if (!btn) return;
1227
+
1228
+ btn.disabled = !liveEditReady;
1229
+ btn.title = liveEditReady ? 'Edit text directly on the canvas' : 'Loading editor...';
1230
+ if (!liveEditReady) {
1231
+ setLiveEditButton(false);
1232
+ }
1233
+ }
1234
+
1235
+ function isLiveEditableCandidate(el) {
1236
+ if (!el || !el.textContent || !el.textContent.trim()) return false;
1237
+ if (el.closest('svg, [aria-hidden="true"], script, style, noscript, input, textarea, select')) return false;
1238
+
1239
+ const childTextElement = Array.from(el.children || []).some(child => {
1240
+ return child.matches && child.matches(LIVE_EDIT_SELECTOR) &&
1241
+ child.textContent && child.textContent.trim() &&
1242
+ !child.closest('svg, [aria-hidden="true"]');
1243
+ });
1244
+ if (childTextElement) return false;
1245
+
1246
+ const win = el.ownerDocument && el.ownerDocument.defaultView;
1247
+ if (win) {
1248
+ const styles = win.getComputedStyle(el);
1249
+ if (styles.display === 'none' || styles.visibility === 'hidden') return false;
1250
+ }
1251
+
1252
+ return true;
1253
+ }
1254
+
1255
+ function ensureLiveEditHandlers(doc) {
1256
+ if (!doc || doc.__buzlLiveEditHandlers) return;
1257
+
1258
+ doc.addEventListener('input', (e) => {
1259
+ const target = e.target && e.target.closest && e.target.closest('[data-buzl-live-edit="true"]');
1260
+ if (!liveEditMode || !target) return;
1261
+ liveEditDirty = true;
1262
+ setStatus('Unsaved live edits', 'saving');
1263
+ }, true);
1264
+
1265
+ doc.addEventListener('click', (e) => {
1266
+ const target = e.target && e.target.closest && e.target.closest('[data-buzl-live-edit="true"]');
1267
+ if (!liveEditMode || !target) return;
1268
+ e.stopPropagation();
1269
+ if (target.closest('a, button')) e.preventDefault();
1270
+ }, true);
1271
+
1272
+ doc.addEventListener('keydown', (e) => {
1273
+ if (!liveEditMode) return;
1274
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
1275
+ e.preventDefault();
1276
+ savePage();
1277
+ }
1278
+ if (e.key === 'Escape' && doc.activeElement) {
1279
+ doc.activeElement.blur();
1280
+ }
1281
+ }, true);
1282
+
1283
+ doc.__buzlLiveEditHandlers = true;
1284
+ }
1285
+
1286
+ function refreshLiveEditTargets() {
1287
+ const doc = getCanvasDocument();
1288
+ if (!doc || !doc.body) return;
1289
+
1290
+ ensureLiveEditHandlers(doc);
1291
+ doc.body.classList.toggle('buzl-live-edit-on', liveEditMode);
1292
+
1293
+ doc.querySelectorAll('[data-buzl-live-edit="true"]').forEach(el => {
1294
+ el.removeAttribute('contenteditable');
1295
+ el.removeAttribute('spellcheck');
1296
+ el.removeAttribute('data-buzl-live-edit');
1297
+ });
1298
+
1299
+ if (!liveEditMode) return;
1300
+
1301
+ doc.querySelectorAll(LIVE_EDIT_SELECTOR).forEach(el => {
1302
+ if (!isLiveEditableCandidate(el)) return;
1303
+ el.setAttribute('contenteditable', 'true');
1304
+ el.setAttribute('spellcheck', 'true');
1305
+ el.setAttribute('data-buzl-live-edit', 'true');
1306
+ });
1307
+ }
1308
+
1309
+ function stripLiveEditAttributes(root) {
1310
+ root.querySelectorAll('[data-buzl-live-edit="true"]').forEach(el => {
1311
+ el.removeAttribute('contenteditable');
1312
+ el.removeAttribute('spellcheck');
1313
+ el.removeAttribute('data-buzl-live-edit');
1314
+ });
1315
+ root.querySelectorAll('*').forEach(el => {
1316
+ Array.from(el.attributes || []).forEach(attr => {
1317
+ if (attr.name.indexOf('data-gjs') === 0 || attr.name === 'data-highlightable') {
1318
+ el.removeAttribute(attr.name);
1319
+ }
1320
+ });
1321
+ });
1322
+ root.classList && root.classList.remove('buzl-live-edit-on');
1323
+ }
1324
+
1325
+ function getLiveCanvasHtml() {
1326
+ const doc = getCanvasDocument();
1327
+ if (!doc || !doc.body) return editor.getHtml();
1328
+ const clone = doc.body.cloneNode(true);
1329
+ stripLiveEditAttributes(clone);
1330
+ return clone.innerHTML;
1331
+ }
1332
+
1333
+ function setLiveEditMode(next) {
1334
+ if (!editor) return;
1335
+ next = Boolean(next);
1336
+ if (next === liveEditMode) return;
1337
+
1338
+ if (!next && liveEditMode && liveEditDirty) {
1339
+ try {
1340
+ const html = getLiveCanvasHtml();
1341
+ liveEditMode = false;
1342
+ liveEditDirty = false;
1343
+ setLiveEditButton(false);
1344
+ editor.setComponents(html);
1345
+ setTimeout(refreshLiveEditTargets, 0);
1346
+ setStatus('Live edits applied', 'success');
1347
+ setTimeout(() => setStatus(''), 1600);
1348
+ return;
1349
+ } catch (err) {
1350
+ console.error('Failed to apply live edits:', err);
1351
+ setStatus('Live edit apply failed', 'error');
1352
+ }
1353
+ }
1354
+
1355
+ liveEditMode = next;
1356
+ setLiveEditButton(liveEditMode);
1357
+ refreshLiveEditTargets();
1358
+ setStatus(liveEditMode ? 'Live Edit on' : 'Live Edit off', liveEditMode ? 'success' : '');
1359
+ setTimeout(() => {
1360
+ if (!liveEditDirty) setStatus('');
1361
+ }, 1600);
1362
+ }
1363
+
1364
+ function toggleLiveEditMode() {
1365
+ if (!liveEditReady) {
1366
+ setStatus('Editor still loading', 'saving');
1367
+ return;
1368
+ }
1369
+ setLiveEditMode(!liveEditMode);
1370
+ }
1371
+
1372
+ // ── Safely load page HTML into the editor ───────────────────────────────
1373
+ // GrapeJS parses inline <style> blocks via a strict PostCSS parser when
1374
+ // setComponents() runs. A single malformed <style> (e.g. literal <br/> in
1375
+ // CSS) throws a CssSyntaxError that aborts the WHOLE page → blank canvas
1376
+ // with no feedback. This wrapper surfaces the error in the save-status bar
1377
+ // and falls back to loading the page WITHOUT inline styles so the content,
1378
+ // images and links remain visible and editable.
1379
+ function loadComponentsSafe(bodyContent, gjsCss) {
1380
+ const statusEl = document.getElementById('saveStatus');
1381
+ try {
1382
+ editor.setComponents(bodyContent);
1383
+ if (gjsCss) editor.setStyle(gjsCss);
1384
+ setLiveEditReady(true);
1385
+ setTimeout(refreshLiveEditTargets, 0);
1386
+ if (statusEl) statusEl.title = '';
1387
+ return true;
1388
+ } catch (err) {
1389
+ const msg = String((err && err.message) || err).replace(/\s+/g, ' ').trim();
1390
+ console.error('Page failed to parse into the editor:', err);
1391
+ // Best-effort fallback: drop inline <style> blocks (the usual culprit)
1392
+ // and retry so the rest of the page is still editable.
1393
+ try {
1394
+ const stripped = bodyContent.replace(/<style[\s\S]*?<\/style>/gi, '');
1395
+ editor.setComponents(stripped);
1396
+ if (gjsCss) { try { editor.setStyle(gjsCss); } catch (e) { /* ignore */ } }
1397
+ setLiveEditReady(true);
1398
+ setTimeout(refreshLiveEditTargets, 0);
1399
+ setStatus('⚠ Bad CSS — loaded without page styles', 'error');
1400
+ if (statusEl) statusEl.title = 'A <style> block could not be parsed:\n' + msg
1401
+ + '\n\nThe page was loaded without its inline styles. Fix the CSS, then reload.';
1402
+ } catch (err2) {
1403
+ setStatus('⚠ Page failed to load', 'error');
1404
+ if (statusEl) statusEl.title = 'Could not parse this page:\n' + msg;
1405
+ }
1406
+ return false;
1407
+ }
1408
+ }
1409
+
1410
+ // ── Save current page ───────────────────────────
1411
+ async function savePage() {
1412
+ if (!currentPage || !editor) return;
1413
+
1414
+ const saveBtn = document.getElementById('saveBtn');
1415
+ saveBtn.disabled = true;
1416
+ setStatus('Saving…', 'saving');
1417
+
1418
+ try {
1419
+ const htmlContent = liveEditMode ? getLiveCanvasHtml() : editor.getHtml();
1420
+ const cssContent = editor.getCss();
1421
+
1422
+ const res = await fetch(`/api/pages/${encodeURIComponent(currentPage)}`, {
1423
+ method: 'POST',
1424
+ headers: { 'Content-Type': 'application/json' },
1425
+ body: JSON.stringify({ htmlContent, cssContent })
1426
+ });
1427
+
1428
+ const data = await res.json();
1429
+ if (data.success) {
1430
+ liveEditDirty = false;
1431
+ setStatus('Saved!', 'success');
1432
+ setTimeout(() => setStatus(''), 3000);
1433
+ } else {
1434
+ throw new Error(data.error || 'Save failed');
1435
+ }
1436
+ } catch (err) {
1437
+ console.error('Save error:', err);
1438
+ setStatus('Save failed', 'error');
1439
+ } finally {
1440
+ saveBtn.disabled = false;
1441
+ }
1442
+ }
1443
+
1444
+ // ── Preview in new tab ──────────────────────────
1445
+ function previewPage() {
1446
+ if (currentPage) window.open('/' + currentPage, '_blank');
1447
+ }
1448
+
1449
+ // ── Status helper ───────────────────────────────
1450
+ function setStatus(msg, cls = '') {
1451
+ const el = document.getElementById('saveStatus');
1452
+ el.textContent = msg;
1453
+ el.className = 'save-status ' + cls;
1454
+ }
1455
+
1456
+ // ─────────────────────────────────────────────────
1457
+ // AI Chat Thread
1458
+ // ─────────────────────────────────────────────────
1459
+
1460
+ let aiTargetComponent = null;
1461
+ let aiChatHistory = []; // { role, content } pairs sent to the API
1462
+ let aiLastAssistantText = ''; // last AI response (for the "Use" button)
1463
+ let aiGenerationContext = ''; // cached per-session
1464
+ let aiIsSending = false;
1465
+
1466
+ // ── Open ────────────────────────────────────────
1467
+ function aiOpen() {
1468
+ const selected = editor ? editor.getSelected() : null;
1469
+ if (!selected) {
1470
+ alert('Please select a text element first.');
1471
+ return;
1472
+ }
1473
+
1474
+ aiTargetComponent = selected;
1475
+ aiChatHistory = [];
1476
+ aiLastAssistantText = '';
1477
+ aiIsSending = false;
1478
+ aiGenerationContext = buildGenerationContext(selected);
1479
+
1480
+ // Show existing content in the preview panel and header tag
1481
+ const currentText = getComponentTextContent(selected);
1482
+ const existingPanel = document.getElementById('aiExistingContent');
1483
+ const existingText = document.getElementById('aiExistingText');
1484
+ const tag = document.getElementById('aiContextTag');
1485
+
1486
+ if (currentText.trim()) {
1487
+ existingText.textContent = currentText;
1488
+ existingPanel.classList.add('visible');
1489
+ tag.textContent = currentText.substring(0, 60) + (currentText.length > 60 ? '…' : '');
1490
+ tag.title = currentText.substring(0, 300);
1491
+ } else {
1492
+ existingText.textContent = '';
1493
+ existingPanel.classList.remove('visible');
1494
+ tag.textContent = '';
1495
+ tag.title = '';
1496
+ }
1497
+
1498
+ // Seed chat history with existing content (sent as context, not displayed in thread)
1499
+ if (currentText.trim()) {
1500
+ aiChatHistory.push({
1501
+ role: 'user',
1502
+ content: `The current text in this element is: "${currentText.substring(0, 500)}"`,
1503
+ });
1504
+ }
1505
+
1506
+ // Reset UI
1507
+ document.getElementById('aiChatThread').innerHTML = '';
1508
+ document.getElementById('aiPrompt').value = '';
1509
+ document.getElementById('aiPrompt').disabled = false;
1510
+ document.getElementById('aiSendBtn').disabled = false;
1511
+ document.getElementById('aiUseBtn').classList.remove('visible');
1512
+
1513
+ // Show modal
1514
+ document.getElementById('aiModal').classList.remove('hidden');
1515
+ setTimeout(() => document.getElementById('aiPrompt').focus(), 100);
1516
+ }
1517
+
1518
+ // ── Close (releases session memory) ─────────────
1519
+ function aiClose() {
1520
+ document.getElementById('aiModal').classList.add('hidden');
1521
+ // Release all session state
1522
+ aiTargetComponent = null;
1523
+ aiChatHistory = [];
1524
+ aiLastAssistantText = '';
1525
+ aiGenerationContext = '';
1526
+ aiIsSending = false;
1527
+ document.getElementById('aiChatThread').innerHTML = '';
1528
+ document.getElementById('aiExistingContent').classList.remove('visible');
1529
+ document.getElementById('aiExistingText').textContent = '';
1530
+ }
1531
+
1532
+ // ── Send message ────────────────────────────────
1533
+ async function aiSend() {
1534
+ const promptEl = document.getElementById('aiPrompt');
1535
+ const prompt = promptEl.value.trim();
1536
+ if (!prompt || aiIsSending) return;
1537
+
1538
+ // Add user bubble
1539
+ aiAppendBubble('user', prompt);
1540
+ promptEl.value = '';
1541
+ autoResizeTextarea(promptEl);
1542
+
1543
+ // Add to chat history
1544
+ aiChatHistory.push({ role: 'user', content: prompt });
1545
+
1546
+ // Lock UI
1547
+ aiIsSending = true;
1548
+ document.getElementById('aiSendBtn').disabled = true;
1549
+ promptEl.disabled = true;
1550
+
1551
+ // Show typing indicator
1552
+ const typingEl = aiAppendBubble('typing', 'Generating…');
1553
+
1554
+ try {
1555
+ const res = await fetch('/api/ai/generate-text', {
1556
+ method: 'POST',
1557
+ headers: { 'Content-Type': 'application/json' },
1558
+ body: JSON.stringify({
1559
+ messages: aiChatHistory,
1560
+ generation_context: aiGenerationContext,
1561
+ }),
1562
+ });
1563
+
1564
+ const data = await res.json();
1565
+ typingEl.remove();
1566
+
1567
+ if (!res.ok) {
1568
+ throw new Error(data.details || data.error || 'Generation failed');
1569
+ }
1570
+
1571
+ const text = data.text;
1572
+ aiLastAssistantText = text;
1573
+
1574
+ // Add assistant bubble
1575
+ aiAppendBubble('assistant', text);
1576
+
1577
+ // Add to history so subsequent messages have full context
1578
+ aiChatHistory.push({ role: 'assistant', content: text });
1579
+
1580
+ // Show "Use" button after first successful response
1581
+ document.getElementById('aiUseBtn').classList.add('visible');
1582
+
1583
+ } catch (err) {
1584
+ typingEl.remove();
1585
+ console.error('AI generation error:', err);
1586
+ aiAppendBubble('error', err.message);
1587
+ } finally {
1588
+ aiIsSending = false;
1589
+ document.getElementById('aiSendBtn').disabled = false;
1590
+ promptEl.disabled = false;
1591
+ promptEl.focus();
1592
+ }
1593
+ }
1594
+
1595
+ // ── Use: replace the element content with last AI response ──
1596
+ function aiUseText() {
1597
+ if (!aiTargetComponent || !aiLastAssistantText) return;
1598
+
1599
+ aiTargetComponent.components().reset();
1600
+ aiTargetComponent.set('content', aiLastAssistantText);
1601
+
1602
+ const el = aiTargetComponent.getEl();
1603
+ if (el) el.textContent = aiLastAssistantText;
1604
+
1605
+ aiTargetComponent.trigger('change:content');
1606
+ editor.trigger('component:update', aiTargetComponent);
1607
+
1608
+ aiClose();
1609
+ }
1610
+
1611
+ // ── Chat bubble helpers ─────────────────────────
1612
+ function aiAppendBubble(type, text) {
1613
+ const thread = document.getElementById('aiChatThread');
1614
+ const bubble = document.createElement('div');
1615
+ bubble.className = `ai-msg ai-msg-${type}`;
1616
+ bubble.textContent = text;
1617
+ thread.appendChild(bubble);
1618
+ thread.scrollTop = thread.scrollHeight;
1619
+ return bubble;
1620
+ }
1621
+
1622
+ // ── Auto-resize textarea ────────────────────────
1623
+ function autoResizeTextarea(el) {
1624
+ el.style.height = 'auto';
1625
+ el.style.height = Math.min(el.scrollHeight, 100) + 'px';
1626
+ }
1627
+
1628
+ // ── Build generation context ────────────────────
1629
+ function buildGenerationContext(comp) {
1630
+ if (!comp) return '';
1631
+ const parts = [];
1632
+
1633
+ const tagName = (comp.get('tagName') || '').toLowerCase();
1634
+ if (tagName) parts.push(`Element: <${tagName}>`);
1635
+
1636
+ const classes = comp.getClasses ? comp.getClasses() : [];
1637
+ if (classes.length > 0) parts.push(`Classes: ${classes.join(', ')}`);
1638
+
1639
+ let parent = comp.parent();
1640
+ const ancestors = [];
1641
+ while (parent) {
1642
+ const pTag = (parent.get('tagName') || '').toLowerCase();
1643
+ const pClasses = parent.getClasses ? parent.getClasses() : [];
1644
+ const pId = parent.getId ? parent.getId() : '';
1645
+ if (['section', 'header', 'footer', 'nav', 'main'].includes(pTag)) {
1646
+ let desc = `Parent <${pTag}>`;
1647
+ if (pId) desc += ` id="${pId}"`;
1648
+ if (pClasses.length > 0) desc += ` class="${pClasses.join(' ')}"`;
1649
+ ancestors.push(desc);
1650
+ }
1651
+ parent = parent.parent();
1652
+ }
1653
+ if (ancestors.length > 0) parts.push(`Section: ${ancestors.join(' > ')}`);
1654
+ if (currentPage) parts.push(`Page: ${currentPage}`);
1655
+
1656
+ return parts.join(' | ');
1657
+ }
1658
+
1659
+ // ── Helpers ─────────────────────────────────────
1660
+ function getComponentTextContent(comp) {
1661
+ if (!comp) return '';
1662
+ const el = comp.getEl();
1663
+ if (el) return el.textContent || '';
1664
+ return comp.get('content') || '';
1665
+ }
1666
+
1667
+ function escapeHtml(str) {
1668
+ const div = document.createElement('div');
1669
+ div.appendChild(document.createTextNode(str));
1670
+ return div.innerHTML;
1671
+ }
1672
+
1673
+ // ─────────────────────────────────────────────────
1674
+ // AI Image Generation
1675
+ // ─────────────────────────────────────────────────
1676
+
1677
+ // ── Image AI Chat Session State ────────────────
1678
+ let imgAiTargetComponent = null;
1679
+ let imgAiSessionId = null; // Current backend session ID
1680
+ let imgAiGeneratedUrl = ''; // URL of last generated image (for "Use" button)
1681
+ let imgAiIsGenerating = false;
1682
+
1683
+ // ── Open: create a new session ──────────────────
1684
+ async function imgAiOpen() {
1685
+ const selected = editor ? editor.getSelected() : null;
1686
+ if (!selected) {
1687
+ alert('Please select an image element first.');
1688
+ return;
1689
+ }
1690
+
1691
+ imgAiTargetComponent = selected;
1692
+ imgAiGeneratedUrl = '';
1693
+ imgAiIsGenerating = false;
1694
+ imgAiSessionId = null;
1695
+
1696
+ // Reset UI
1697
+ const threadEl = document.getElementById('imgAiThread');
1698
+ const emptyEl = document.getElementById('imgAiThreadEmpty');
1699
+ // Clear old thread messages (keep the empty placeholder)
1700
+ threadEl.querySelectorAll('.img-ai-msg').forEach(el => el.remove());
1701
+ emptyEl.style.display = '';
1702
+ document.getElementById('imgAiPrompt').value = '';
1703
+ document.getElementById('imgAiPrompt').disabled = false;
1704
+ document.getElementById('imgAiUseBtn').classList.remove('visible');
1705
+ document.getElementById('imgAiStopBtn').classList.remove('visible');
1706
+ document.getElementById('imgAiSendBtn').style.display = '';
1707
+ document.getElementById('imgAiSendBtn').textContent = 'Send';
1708
+ document.getElementById('imgAiSendBtn').disabled = false;
1709
+
1710
+ // Build generation context
1711
+ const generationContext = imgAiBuildContext(imgAiTargetComponent);
1712
+ const ctxTag = document.getElementById('imgAiContextTag');
1713
+ if (generationContext && ctxTag) {
1714
+ ctxTag.textContent = generationContext.substring(0, 60) + (generationContext.length > 60 ? '…' : '');
1715
+ ctxTag.title = generationContext;
1716
+ } else if (ctxTag) {
1717
+ ctxTag.textContent = '';
1718
+ }
1719
+
1720
+ // Create backend session
1721
+ try {
1722
+ const res = await fetch('/api/ai/image-session', {
1723
+ method: 'POST',
1724
+ headers: { 'Content-Type': 'application/json' },
1725
+ body: JSON.stringify({ generation_context: generationContext }),
1726
+ });
1727
+ const data = await res.json();
1728
+ imgAiSessionId = data.sessionId;
1729
+ } catch (err) {
1730
+ console.error('Failed to create image session:', err);
1731
+ }
1732
+
1733
+ document.getElementById('imgAiModal').classList.remove('hidden');
1734
+ setTimeout(() => document.getElementById('imgAiPrompt').focus(), 100);
1735
+ }
1736
+
1737
+ // ── Close (cancel any in-flight, delete session) ─
1738
+ function imgAiClose() {
1739
+ if (imgAiIsGenerating && imgAiSessionId) {
1740
+ fetch(`/api/ai/image-session/${imgAiSessionId}/cancel`, { method: 'POST' }).catch(() => {});
1741
+ }
1742
+ // Delete session on close
1743
+ if (imgAiSessionId) {
1744
+ fetch(`/api/ai/image-session/${imgAiSessionId}`, { method: 'DELETE' }).catch(() => {});
1745
+ }
1746
+ document.getElementById('imgAiModal').classList.add('hidden');
1747
+ imgAiTargetComponent = null;
1748
+ imgAiGeneratedUrl = '';
1749
+ imgAiIsGenerating = false;
1750
+ imgAiSessionId = null;
1751
+ }
1752
+
1753
+ // ── Append a message to the thread UI ───────────
1754
+ function imgAiAppendMsg(role, content, meta) {
1755
+ const threadEl = document.getElementById('imgAiThread');
1756
+ document.getElementById('imgAiThreadEmpty').style.display = 'none';
1757
+
1758
+ const msgDiv = document.createElement('div');
1759
+ msgDiv.className = `img-ai-msg ${role}`;
1760
+
1761
+ const bubble = document.createElement('div');
1762
+ bubble.className = 'img-ai-msg-bubble';
1763
+
1764
+ if (role === 'user') {
1765
+ bubble.textContent = content;
1766
+ } else if (role === 'assistant') {
1767
+ const img = document.createElement('img');
1768
+ img.src = content;
1769
+ img.alt = 'Generated image';
1770
+ img.onclick = () => window.open(content, '_blank');
1771
+ bubble.appendChild(img);
1772
+ } else if (role === 'error') {
1773
+ bubble.textContent = content;
1774
+ } else if (role === 'loading') {
1775
+ bubble.innerHTML = '<div class="loader"></div><span>Generating image…</span>';
1776
+ }
1777
+
1778
+ msgDiv.appendChild(bubble);
1779
+
1780
+ if (meta) {
1781
+ const metaDiv = document.createElement('div');
1782
+ metaDiv.className = 'img-ai-msg-meta';
1783
+ metaDiv.textContent = meta;
1784
+ msgDiv.appendChild(metaDiv);
1785
+ }
1786
+
1787
+ threadEl.appendChild(msgDiv);
1788
+ threadEl.scrollTop = threadEl.scrollHeight;
1789
+ return msgDiv;
1790
+ }
1791
+
1792
+ // ── Cancel just the current generation ────────────
1793
+ function imgAiCancelGeneration() {
1794
+ if (!imgAiIsGenerating || !imgAiSessionId) return;
1795
+ imgAiIsCancelled = true;
1796
+ fetch(`/api/ai/image-session/${imgAiSessionId}/cancel`, { method: 'POST' }).catch(() => {});
1797
+ }
1798
+
1799
+ let imgAiIsCancelled = false;
1800
+
1801
+ // ── Generate (session-based) ────────────────────
1802
+ async function imgAiGenerate() {
1803
+ const promptEl = document.getElementById('imgAiPrompt');
1804
+ const prompt = promptEl.value.trim();
1805
+ if (!prompt || imgAiIsGenerating || !imgAiSessionId) return;
1806
+
1807
+ // Lock UI
1808
+ imgAiIsGenerating = true;
1809
+ imgAiIsCancelled = false;
1810
+ promptEl.value = '';
1811
+ promptEl.disabled = true;
1812
+ document.getElementById('imgAiSendBtn').style.display = 'none';
1813
+ document.getElementById('imgAiStopBtn').classList.add('visible');
1814
+
1815
+ // Add user message to thread
1816
+ imgAiAppendMsg('user', prompt);
1817
+
1818
+ // Add loading spinner to thread (with inline stop button)
1819
+ const loadingMsg = imgAiAppendMsg('loading');
1820
+ const inlineStop = document.createElement('button');
1821
+ inlineStop.className = 'btn-stop-inline';
1822
+ inlineStop.textContent = 'Stop';
1823
+ inlineStop.onclick = () => imgAiCancelGeneration();
1824
+ loadingMsg.querySelector('.img-ai-msg-bubble').appendChild(inlineStop);
1825
+
1826
+ try {
1827
+ const res = await fetch(`/api/ai/image-session/${imgAiSessionId}/generate`, {
1828
+ method: 'POST',
1829
+ headers: { 'Content-Type': 'application/json' },
1830
+ body: JSON.stringify({ prompt }),
1831
+ });
1832
+
1833
+ const data = await res.json();
1834
+
1835
+ // Remove loading spinner
1836
+ loadingMsg.remove();
1837
+
1838
+ if (!res.ok) {
1839
+ throw new Error(data.details || data.error || 'Generation failed');
1840
+ }
1841
+
1842
+ imgAiGeneratedUrl = data.url;
1843
+
1844
+ // Add assistant image to thread
1845
+ const modelName = (data.model || '').split('/').pop();
1846
+ imgAiAppendMsg('assistant', data.url, modelName);
1847
+
1848
+ // Show Use button
1849
+ document.getElementById('imgAiUseBtn').classList.add('visible');
1850
+
1851
+ } catch (err) {
1852
+ // Remove loading spinner
1853
+ if (loadingMsg.parentNode) loadingMsg.remove();
1854
+ console.error('AI image generation error:', err);
1855
+ if (!imgAiIsCancelled) {
1856
+ imgAiAppendMsg('error', err.message);
1857
+ }
1858
+ } finally {
1859
+ imgAiIsGenerating = false;
1860
+ imgAiIsCancelled = false;
1861
+ promptEl.disabled = false;
1862
+ document.getElementById('imgAiSendBtn').style.display = '';
1863
+ document.getElementById('imgAiStopBtn').classList.remove('visible');
1864
+ promptEl.focus();
1865
+ }
1866
+ }
1867
+
1868
+ // ── Use: apply the last generated image ─────────
1869
+ function imgAiUse() {
1870
+ if (!imgAiTargetComponent || !imgAiGeneratedUrl) return;
1871
+
1872
+ imgAiTargetComponent.set('src', imgAiGeneratedUrl);
1873
+ imgAiTargetComponent.addAttributes({ src: imgAiGeneratedUrl });
1874
+
1875
+ const el = imgAiTargetComponent.getEl();
1876
+ if (el) el.setAttribute('src', imgAiGeneratedUrl);
1877
+
1878
+ imgAiTargetComponent.trigger('change:src');
1879
+ editor.trigger('component:update', imgAiTargetComponent);
1880
+
1881
+ imgAiClose();
1882
+ }
1883
+
1884
+ // ── Build context for image component ───────────
1885
+ function imgAiBuildContext(comp) {
1886
+ if (!comp) return '';
1887
+ const parts = [];
1888
+
1889
+ const src = comp.get('src') || comp.getAttributes().src || '';
1890
+ if (src && !src.startsWith('data:')) {
1891
+ parts.push(`Current image: ${src}`);
1892
+ }
1893
+
1894
+ const alt = comp.getAttributes().alt || '';
1895
+ if (alt) parts.push(`Alt text: ${alt}`);
1896
+
1897
+ const el = comp.getEl();
1898
+ if (el) {
1899
+ const w = el.naturalWidth || el.width || el.getAttribute('width');
1900
+ const h = el.naturalHeight || el.height || el.getAttribute('height');
1901
+ if (w && h) {
1902
+ parts.push(`Dimensions: ${w}x${h}`);
1903
+ // Compute aspect ratio for the image generation prompt
1904
+ const gcd = (a, b) => b === 0 ? a : gcd(b, a % b);
1905
+ const d = gcd(Number(w), Number(h));
1906
+ parts.push(`Aspect ratio: ${Number(w)/d}:${Number(h)/d}`);
1907
+ }
1908
+ }
1909
+
1910
+ let parent = comp.parent();
1911
+ while (parent) {
1912
+ const pTag = (parent.get('tagName') || '').toLowerCase();
1913
+ const pClasses = parent.getClasses ? parent.getClasses() : [];
1914
+ const pId = parent.getId ? parent.getId() : '';
1915
+ if (['section', 'header', 'footer', 'nav', 'main'].includes(pTag)) {
1916
+ let desc = `Parent <${pTag}>`;
1917
+ if (pId) desc += ` id="${pId}"`;
1918
+ if (pClasses.length > 0) desc += ` class="${pClasses.join(' ')}"`;
1919
+ parts.push(`Section: ${desc}`);
1920
+ break;
1921
+ }
1922
+ parent = parent.parent();
1923
+ }
1924
+
1925
+ if (currentPage) parts.push(`Page: ${currentPage}`);
1926
+ return parts.join(' | ');
1927
+ }
1928
+
1929
+ // ── Keyboard shortcuts ──────────────────────────
1930
+ document.addEventListener('keydown', (e) => {
1931
+ // Escape closes whichever modal is open
1932
+ if (e.key === 'Escape') {
1933
+ if (!document.getElementById('aiModal').classList.contains('hidden')) {
1934
+ aiClose();
1935
+ }
1936
+ if (!document.getElementById('imgAiModal').classList.contains('hidden')) {
1937
+ imgAiClose();
1938
+ }
1939
+ }
1940
+ // Enter in text AI prompt sends message
1941
+ if (e.key === 'Enter' && !e.shiftKey && e.target.id === 'aiPrompt') {
1942
+ e.preventDefault();
1943
+ aiSend();
1944
+ }
1945
+ // Enter in image AI prompt triggers generate
1946
+ if (e.key === 'Enter' && !e.shiftKey && e.target.id === 'imgAiPrompt') {
1947
+ e.preventDefault();
1948
+ imgAiGenerate();
1949
+ }
1950
+ });
1951
+
1952
+ // Auto-resize prompt textareas on input
1953
+ document.addEventListener('DOMContentLoaded', () => {
1954
+ const promptEl = document.getElementById('aiPrompt');
1955
+ if (promptEl) {
1956
+ promptEl.addEventListener('input', () => autoResizeTextarea(promptEl));
1957
+ }
1958
+ const imgPromptEl = document.getElementById('imgAiPrompt');
1959
+ if (imgPromptEl) {
1960
+ imgPromptEl.addEventListener('input', () => autoResizeTextarea(imgPromptEl));
1961
+ }
1962
+
1963
+ const imgUploadInput = document.getElementById('imgAiUploadInput');
1964
+ const imgUploadBtn = document.getElementById('imgAiUploadBtn');
1965
+ if (imgUploadInput && imgUploadBtn) {
1966
+ imgUploadInput.addEventListener('change', async (event) => {
1967
+ const file = event.target.files && event.target.files[0];
1968
+ event.target.value = '';
1969
+ if (!file || imgAiIsGenerating) return;
1970
+
1971
+ const formData = new FormData();
1972
+ formData.append('files', file);
1973
+ imgUploadBtn.disabled = true;
1974
+ imgAiAppendMsg('user', `Upload image: ${file.name}`);
1975
+ const loadingMsg = imgAiAppendMsg('loading');
1976
+ const loadingLabel = loadingMsg.querySelector('span');
1977
+ if (loadingLabel) loadingLabel.textContent = 'Uploading and optimizing image…';
1978
+
1979
+ try {
1980
+ const response = await fetch('/api/upload', {
1981
+ method: 'POST',
1982
+ body: formData,
1983
+ });
1984
+ const data = await response.json();
1985
+ if (!response.ok || !Array.isArray(data.data) || !data.data[0]) {
1986
+ throw new Error(data.error || 'Upload failed');
1987
+ }
1988
+
1989
+ loadingMsg.remove();
1990
+ imgAiGeneratedUrl = data.data[0];
1991
+ imgAiAppendMsg('assistant', imgAiGeneratedUrl, 'Uploaded and optimized');
1992
+ document.getElementById('imgAiUseBtn').classList.add('visible');
1993
+ } catch (err) {
1994
+ loadingMsg.remove();
1995
+ console.error('Manual image upload failed:', err);
1996
+ imgAiAppendMsg('error', `Upload failed: ${err.message}`);
1997
+ } finally {
1998
+ imgUploadBtn.disabled = false;
1999
+ }
2000
+ });
2001
+ }
2002
+ });
2003
+ </script>
2004
+ </body>
2005
+ </html>