@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.
- package/LICENSE +5 -0
- package/README.md +113 -0
- package/bin/buzl-editor.js +102 -0
- package/bin/buzl-site.js +77 -0
- package/dist/imageOptimizer.js +68 -0
- package/dist/imageService.js +242 -0
- package/dist/public/index.html +2005 -0
- package/dist/server.js +1045 -0
- package/dist/site-server.js +318 -0
- package/lib/cli.js +267 -0
- package/package.json +52 -0
- package/templates/config.example.json +6 -0
- package/templates/website_context.example.json +7 -0
|
@@ -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">⚡</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">🎨</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>
|