vibespot 0.4.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 +33 -0
- package/README.md +118 -0
- package/assets/content-guide.md +445 -0
- package/assets/conversion-guide.md +693 -0
- package/assets/design-guide.md +380 -0
- package/assets/hubspot-rules.md +560 -0
- package/assets/page-types.md +116 -0
- package/bin/vibespot.mjs +11 -0
- package/dist/index.js +6552 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
- package/ui/chat.js +803 -0
- package/ui/dashboard.js +383 -0
- package/ui/dialog.js +117 -0
- package/ui/field-editor.js +292 -0
- package/ui/index.html +393 -0
- package/ui/preview.js +132 -0
- package/ui/settings.js +927 -0
- package/ui/setup.js +830 -0
- package/ui/styles.css +2552 -0
- package/ui/upload-panel.js +554 -0
package/ui/chat.js
ADDED
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat UI — WebSocket client, message rendering, streaming display.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// State
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
let ws = null;
|
|
10
|
+
let isStreaming = false;
|
|
11
|
+
let streamingMsgEl = null;
|
|
12
|
+
let streamBuffer = "";
|
|
13
|
+
let streamStartTime = 0;
|
|
14
|
+
let streamTimerInterval = null;
|
|
15
|
+
|
|
16
|
+
const messagesEl = document.getElementById("chat-messages");
|
|
17
|
+
const inputEl = document.getElementById("chat-input");
|
|
18
|
+
const sendBtn = document.getElementById("chat-send");
|
|
19
|
+
const statusText = document.getElementById("status-text");
|
|
20
|
+
const statusEngine = document.getElementById("status-engine");
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// WebSocket connection
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function connectWebSocket() {
|
|
27
|
+
// Close any existing connection to prevent duplicates and stale state
|
|
28
|
+
if (ws) {
|
|
29
|
+
ws.onclose = null; // prevent auto-reconnect from old socket
|
|
30
|
+
ws.close();
|
|
31
|
+
ws = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
35
|
+
ws = new WebSocket(`${protocol}//${location.host}`);
|
|
36
|
+
|
|
37
|
+
ws.onopen = () => {
|
|
38
|
+
setStatus("Connected");
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
ws.onmessage = (event) => {
|
|
42
|
+
const msg = JSON.parse(event.data);
|
|
43
|
+
handleWsMessage(msg);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
ws.onclose = () => {
|
|
47
|
+
setStatus("Disconnected — reconnecting...");
|
|
48
|
+
setTimeout(connectWebSocket, 2000);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
ws.onerror = () => {
|
|
52
|
+
setStatus("Connection error");
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function handleWsMessage(msg) {
|
|
57
|
+
// Route upload messages to upload-panel.js
|
|
58
|
+
if (msg.type && msg.type.startsWith("upload_")) {
|
|
59
|
+
if (typeof handleUploadWsMessage === "function") {
|
|
60
|
+
handleUploadWsMessage(msg);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
switch (msg.type) {
|
|
66
|
+
case "init":
|
|
67
|
+
document.getElementById("theme-name").textContent = msg.themeName || "—";
|
|
68
|
+
|
|
69
|
+
// Clear previous project's chat and module list
|
|
70
|
+
messagesEl.innerHTML = "";
|
|
71
|
+
document.getElementById("module-items").innerHTML = "";
|
|
72
|
+
document.getElementById("module-count").textContent = "0";
|
|
73
|
+
|
|
74
|
+
if (msg.modules && msg.modules.length > 0) {
|
|
75
|
+
updateModuleList(msg.modules);
|
|
76
|
+
refreshPreview();
|
|
77
|
+
}
|
|
78
|
+
statusEngine.textContent = msg.engine || "";
|
|
79
|
+
fetchHsAccountStatus();
|
|
80
|
+
|
|
81
|
+
// Restore chat history from server
|
|
82
|
+
if (msg.messages && msg.messages.length > 0) {
|
|
83
|
+
for (const m of msg.messages) {
|
|
84
|
+
if (m.role === "user") {
|
|
85
|
+
appendUserMessage(m.content);
|
|
86
|
+
} else if (m.role === "assistant") {
|
|
87
|
+
appendRestoredAssistantMessage(m.content);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
scrollToBottom();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Show/hide version history button
|
|
94
|
+
const historyBtn = document.getElementById("btn-history");
|
|
95
|
+
if (historyBtn) {
|
|
96
|
+
historyBtn.style.display = msg.gitAvailable ? "" : "none";
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
|
|
100
|
+
case "stream":
|
|
101
|
+
clearStreamStatus();
|
|
102
|
+
handleStreamChunk(msg.content);
|
|
103
|
+
break;
|
|
104
|
+
|
|
105
|
+
case "stream_status":
|
|
106
|
+
handleStreamStatus(msg.content);
|
|
107
|
+
break;
|
|
108
|
+
|
|
109
|
+
case "generation_complete":
|
|
110
|
+
clearStreamStatus();
|
|
111
|
+
finishStreaming();
|
|
112
|
+
break;
|
|
113
|
+
|
|
114
|
+
case "modules_updated":
|
|
115
|
+
if (msg.modules) {
|
|
116
|
+
updateModuleList(msg.modules);
|
|
117
|
+
}
|
|
118
|
+
refreshPreview();
|
|
119
|
+
break;
|
|
120
|
+
|
|
121
|
+
case "version_created":
|
|
122
|
+
if (historyPanelOpen) refreshHistoryPanel();
|
|
123
|
+
break;
|
|
124
|
+
|
|
125
|
+
case "parse_warning":
|
|
126
|
+
appendSystemMessage(msg.message || "Module changes could not be applied.");
|
|
127
|
+
break;
|
|
128
|
+
|
|
129
|
+
case "error":
|
|
130
|
+
finishStreaming();
|
|
131
|
+
appendAssistantError(msg.message);
|
|
132
|
+
setStatus("Error");
|
|
133
|
+
break;
|
|
134
|
+
|
|
135
|
+
case "pong":
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Sending messages
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
function sendMessage(text) {
|
|
145
|
+
if (!text.trim() || isStreaming || !ws || ws.readyState !== WebSocket.OPEN) return;
|
|
146
|
+
|
|
147
|
+
// Remove welcome screen
|
|
148
|
+
const welcome = messagesEl.querySelector(".chat__welcome");
|
|
149
|
+
if (welcome) welcome.remove();
|
|
150
|
+
|
|
151
|
+
// Show user message
|
|
152
|
+
appendUserMessage(text);
|
|
153
|
+
|
|
154
|
+
// Start streaming indicator
|
|
155
|
+
startStreaming();
|
|
156
|
+
|
|
157
|
+
// Send via WebSocket
|
|
158
|
+
ws.send(JSON.stringify({ type: "chat", message: text }));
|
|
159
|
+
|
|
160
|
+
// Clear input
|
|
161
|
+
inputEl.value = "";
|
|
162
|
+
inputEl.style.height = "auto";
|
|
163
|
+
setStatus("Generating...");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Message rendering
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
function appendUserMessage(text) {
|
|
171
|
+
const div = document.createElement("div");
|
|
172
|
+
div.className = "chat-msg chat-msg--user";
|
|
173
|
+
div.innerHTML = `<div class="chat-msg__bubble">${escapeHtml(text)}</div>`;
|
|
174
|
+
messagesEl.appendChild(div);
|
|
175
|
+
scrollToBottom();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function startStreaming() {
|
|
179
|
+
isStreaming = true;
|
|
180
|
+
streamBuffer = "";
|
|
181
|
+
sendBtn.disabled = true;
|
|
182
|
+
streamStartTime = Date.now();
|
|
183
|
+
|
|
184
|
+
// Show generating preview with spinner + fun messages
|
|
185
|
+
if (typeof showGeneratingPreview === "function") {
|
|
186
|
+
showGeneratingPreview();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const div = document.createElement("div");
|
|
190
|
+
div.className = "chat-msg chat-msg--assistant chat-msg--streaming";
|
|
191
|
+
div.innerHTML = `<div class="chat-msg__bubble"></div>`;
|
|
192
|
+
messagesEl.appendChild(div);
|
|
193
|
+
streamingMsgEl = div.querySelector(".chat-msg__bubble");
|
|
194
|
+
scrollToBottom();
|
|
195
|
+
|
|
196
|
+
// Start the running clock
|
|
197
|
+
startStreamTimer();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function handleStreamChunk(text) {
|
|
201
|
+
if (!streamingMsgEl) return;
|
|
202
|
+
streamBuffer += text;
|
|
203
|
+
|
|
204
|
+
// Render markdown-lite (code blocks, inline code, paragraphs)
|
|
205
|
+
streamingMsgEl.innerHTML = renderMarkdown(streamBuffer);
|
|
206
|
+
scrollToBottom();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function handleStreamStatus(status) {
|
|
210
|
+
if (!streamingMsgEl) startStreaming();
|
|
211
|
+
|
|
212
|
+
// Find or create the status element inside the streaming bubble
|
|
213
|
+
let statusEl = streamingMsgEl.querySelector(".stream-status");
|
|
214
|
+
if (!statusEl) {
|
|
215
|
+
statusEl = document.createElement("div");
|
|
216
|
+
statusEl.className = "stream-status";
|
|
217
|
+
statusEl.innerHTML = '<span class="stream-status__text"></span><span class="stream-status__timer"></span>';
|
|
218
|
+
streamingMsgEl.appendChild(statusEl);
|
|
219
|
+
}
|
|
220
|
+
const textEl = statusEl.querySelector(".stream-status__text");
|
|
221
|
+
if (textEl) textEl.textContent = status;
|
|
222
|
+
scrollToBottom();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function startStreamTimer() {
|
|
226
|
+
stopStreamTimer();
|
|
227
|
+
streamTimerInterval = setInterval(() => {
|
|
228
|
+
// Update the timer in the stream status element
|
|
229
|
+
const timerEl = streamingMsgEl && streamingMsgEl.querySelector(".stream-status__timer");
|
|
230
|
+
if (timerEl) {
|
|
231
|
+
timerEl.textContent = formatDuration(Date.now() - streamStartTime);
|
|
232
|
+
}
|
|
233
|
+
}, 1000);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function stopStreamTimer() {
|
|
237
|
+
if (streamTimerInterval) {
|
|
238
|
+
clearInterval(streamTimerInterval);
|
|
239
|
+
streamTimerInterval = null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function formatDuration(ms) {
|
|
244
|
+
const totalSec = Math.floor(ms / 1000);
|
|
245
|
+
if (totalSec < 60) return totalSec + "s";
|
|
246
|
+
const min = Math.floor(totalSec / 60);
|
|
247
|
+
const sec = totalSec % 60;
|
|
248
|
+
return min + "m " + (sec < 10 ? "0" : "") + sec + "s";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function clearStreamStatus() {
|
|
252
|
+
if (!streamingMsgEl) return;
|
|
253
|
+
const statusEl = streamingMsgEl.querySelector(".stream-status");
|
|
254
|
+
if (statusEl) statusEl.remove();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function finishStreaming() {
|
|
258
|
+
if (!isStreaming) return;
|
|
259
|
+
isStreaming = false;
|
|
260
|
+
sendBtn.disabled = false;
|
|
261
|
+
|
|
262
|
+
// Stop the timer and capture duration
|
|
263
|
+
stopStreamTimer();
|
|
264
|
+
const durationMs = Date.now() - streamStartTime;
|
|
265
|
+
const durationStr = formatDuration(durationMs);
|
|
266
|
+
|
|
267
|
+
clearStreamStatus();
|
|
268
|
+
|
|
269
|
+
// Remove streaming cursor
|
|
270
|
+
const streamingEl = messagesEl.querySelector(".chat-msg--streaming");
|
|
271
|
+
if (streamingEl) {
|
|
272
|
+
streamingEl.classList.remove("chat-msg--streaming");
|
|
273
|
+
|
|
274
|
+
// Add duration metadata beneath the bubble
|
|
275
|
+
const meta = document.createElement("div");
|
|
276
|
+
meta.className = "chat-msg__meta";
|
|
277
|
+
meta.textContent = durationStr;
|
|
278
|
+
streamingEl.appendChild(meta);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Final render of the full response
|
|
282
|
+
if (streamingMsgEl && streamBuffer) {
|
|
283
|
+
streamingMsgEl.innerHTML = renderMarkdown(streamBuffer);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
streamingMsgEl = null;
|
|
287
|
+
streamBuffer = "";
|
|
288
|
+
setStatus("Ready");
|
|
289
|
+
scrollToBottom();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function appendAssistantError(message) {
|
|
293
|
+
const div = document.createElement("div");
|
|
294
|
+
div.className = "chat-msg chat-msg--assistant";
|
|
295
|
+
div.innerHTML = `<div class="chat-msg__bubble" style="border-left: 3px solid var(--error);">
|
|
296
|
+
<strong>Error:</strong> ${escapeHtml(message)}
|
|
297
|
+
</div>`;
|
|
298
|
+
messagesEl.appendChild(div);
|
|
299
|
+
scrollToBottom();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Markdown-lite renderer (code blocks, inline code, bold, links)
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
function renderMarkdown(text) {
|
|
307
|
+
// Hide vibespot-modules JSON blocks (they're data, not display)
|
|
308
|
+
text = text.replace(/```vibespot-modules[\s\S]*?```/g, "");
|
|
309
|
+
|
|
310
|
+
// Code blocks: ```lang\n...\n```
|
|
311
|
+
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
|
312
|
+
return `<pre><code>${escapeHtml(code.trim())}</code></pre>`;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Inline code: `...`
|
|
316
|
+
text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
317
|
+
|
|
318
|
+
// Bold: **...**
|
|
319
|
+
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
320
|
+
|
|
321
|
+
// Italic: *...*
|
|
322
|
+
text = text.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "<em>$1</em>");
|
|
323
|
+
|
|
324
|
+
// Line breaks → paragraphs
|
|
325
|
+
const paragraphs = text.split(/\n\n+/).filter(Boolean);
|
|
326
|
+
if (paragraphs.length > 1) {
|
|
327
|
+
text = paragraphs.map((p) => {
|
|
328
|
+
if (p.startsWith("<pre>")) return p;
|
|
329
|
+
return `<p>${p.replace(/\n/g, "<br>")}</p>`;
|
|
330
|
+
}).join("");
|
|
331
|
+
} else {
|
|
332
|
+
text = text.replace(/\n/g, "<br>");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return text;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// Helpers
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
function escapeHtml(str) {
|
|
343
|
+
return str
|
|
344
|
+
.replace(/&/g, "&")
|
|
345
|
+
.replace(/</g, "<")
|
|
346
|
+
.replace(/>/g, ">")
|
|
347
|
+
.replace(/"/g, """);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function scrollToBottom() {
|
|
351
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function setStatus(text) {
|
|
355
|
+
statusText.textContent = text;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Restored / system messages
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
function appendRestoredAssistantMessage(text) {
|
|
363
|
+
const div = document.createElement("div");
|
|
364
|
+
div.className = "chat-msg chat-msg--assistant";
|
|
365
|
+
div.innerHTML = `<div class="chat-msg__bubble">${renderMarkdown(text)}</div>`;
|
|
366
|
+
messagesEl.appendChild(div);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function appendSystemMessage(text) {
|
|
370
|
+
const div = document.createElement("div");
|
|
371
|
+
div.className = "chat-msg chat-msg--system";
|
|
372
|
+
div.innerHTML = `<div class="chat-msg__system">${escapeHtml(text)}</div>`;
|
|
373
|
+
messagesEl.appendChild(div);
|
|
374
|
+
scrollToBottom();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Version history panel
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
let historyPanelOpen = false;
|
|
382
|
+
|
|
383
|
+
function toggleHistoryPanel() {
|
|
384
|
+
const panel = document.getElementById("history-panel");
|
|
385
|
+
if (!panel) return;
|
|
386
|
+
historyPanelOpen = !historyPanelOpen;
|
|
387
|
+
panel.classList.toggle("hidden", !historyPanelOpen);
|
|
388
|
+
if (historyPanelOpen) refreshHistoryPanel();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function refreshHistoryPanel() {
|
|
392
|
+
const list = document.getElementById("history-list");
|
|
393
|
+
if (!list) return;
|
|
394
|
+
list.innerHTML = '<div class="history__loading">Loading...</div>';
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
const res = await fetch("/api/history");
|
|
398
|
+
const data = await res.json();
|
|
399
|
+
|
|
400
|
+
if (!data.available) {
|
|
401
|
+
list.innerHTML = '<div class="history__empty">Git not available</div>';
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (data.commits.length === 0) {
|
|
405
|
+
list.innerHTML = '<div class="history__empty">No versions yet</div>';
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
list.innerHTML = "";
|
|
410
|
+
for (const commit of data.commits) {
|
|
411
|
+
const isInitial = commit.message.startsWith("Initial ");
|
|
412
|
+
const isRollback = commit.message.startsWith("Rollback to:");
|
|
413
|
+
|
|
414
|
+
const item = document.createElement("div");
|
|
415
|
+
item.className = "history-item" + (isRollback ? " history-item--rollback" : "");
|
|
416
|
+
item.innerHTML = `
|
|
417
|
+
<div class="history-item__header">
|
|
418
|
+
<span class="history-item__hash">${escapeHtml(commit.hash)}</span>
|
|
419
|
+
<span class="history-item__date">${timeAgoShort(commit.timestamp)}</span>
|
|
420
|
+
</div>
|
|
421
|
+
<div class="history-item__msg">${escapeHtml(commit.message)}</div>
|
|
422
|
+
${!isInitial ? `<button class="history-item__rollback" data-hash="${escapeHtml(commit.fullHash)}">Restore</button>` : ""}
|
|
423
|
+
`;
|
|
424
|
+
list.appendChild(item);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
list.querySelectorAll(".history-item__rollback").forEach((btn) => {
|
|
428
|
+
btn.addEventListener("click", () => doRollback(btn.dataset.hash));
|
|
429
|
+
});
|
|
430
|
+
} catch {
|
|
431
|
+
list.innerHTML = '<div class="history__empty">Error loading history</div>';
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function doRollback(hash) {
|
|
436
|
+
const ok = await vibeConfirm("Restore this version?", "Your current files will be replaced, but chat history is preserved.", { confirmLabel: "Restore", confirmClass: "btn--primary" });
|
|
437
|
+
if (!ok) return;
|
|
438
|
+
setStatus("Rolling back...");
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
const res = await fetch("/api/rollback", {
|
|
442
|
+
method: "POST",
|
|
443
|
+
headers: { "Content-Type": "application/json" },
|
|
444
|
+
body: JSON.stringify({ hash }),
|
|
445
|
+
});
|
|
446
|
+
const data = await res.json();
|
|
447
|
+
|
|
448
|
+
if (data.error) {
|
|
449
|
+
await vibeAlert(data.error, "Rollback failed");
|
|
450
|
+
setStatus("Ready");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (data.modules) updateModuleList(data.modules);
|
|
455
|
+
refreshPreview();
|
|
456
|
+
appendSystemMessage("Restored to version " + hash.slice(0, 7));
|
|
457
|
+
refreshHistoryPanel();
|
|
458
|
+
setStatus("Ready");
|
|
459
|
+
} catch (err) {
|
|
460
|
+
await vibeAlert(err.message, "Rollback failed");
|
|
461
|
+
setStatus("Ready");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function timeAgoShort(timestamp) {
|
|
466
|
+
const diff = Date.now() - timestamp;
|
|
467
|
+
const mins = Math.floor(diff / 60000);
|
|
468
|
+
if (mins < 1) return "now";
|
|
469
|
+
if (mins < 60) return mins + "m";
|
|
470
|
+
const hours = Math.floor(mins / 60);
|
|
471
|
+
if (hours < 24) return hours + "h";
|
|
472
|
+
const days = Math.floor(hours / 24);
|
|
473
|
+
return days + "d";
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
// Module list
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
function updateModuleList(moduleNames) {
|
|
481
|
+
const itemsEl = document.getElementById("module-items");
|
|
482
|
+
const countEl = document.getElementById("module-count");
|
|
483
|
+
|
|
484
|
+
countEl.textContent = moduleNames.length;
|
|
485
|
+
itemsEl.innerHTML = "";
|
|
486
|
+
|
|
487
|
+
for (const name of moduleNames) {
|
|
488
|
+
const item = document.createElement("div");
|
|
489
|
+
item.className = "module-item";
|
|
490
|
+
item.dataset.module = name;
|
|
491
|
+
item.innerHTML = `
|
|
492
|
+
<span class="module-item__drag">⠿</span>
|
|
493
|
+
<span class="module-item__name">${escapeHtml(name)}</span>
|
|
494
|
+
<span class="module-item__edit" title="Edit fields">⚙</span>
|
|
495
|
+
<span class="module-item__delete" title="Delete module">×</span>
|
|
496
|
+
`;
|
|
497
|
+
|
|
498
|
+
// Click to scroll to module in preview
|
|
499
|
+
item.querySelector(".module-item__name").addEventListener("click", () => {
|
|
500
|
+
scrollPreviewToModule(name);
|
|
501
|
+
highlightModuleItem(name);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// Click gear to open field editor
|
|
505
|
+
item.querySelector(".module-item__edit").addEventListener("click", (e) => {
|
|
506
|
+
e.stopPropagation();
|
|
507
|
+
openFieldEditor(name);
|
|
508
|
+
highlightModuleItem(name);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Click × to delete module
|
|
512
|
+
item.querySelector(".module-item__delete").addEventListener("click", (e) => {
|
|
513
|
+
e.stopPropagation();
|
|
514
|
+
confirmDeleteModule(name);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
itemsEl.appendChild(item);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Set up drag-and-drop reordering
|
|
521
|
+
setupDragReorder(itemsEl);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function highlightModuleItem(name) {
|
|
525
|
+
document.querySelectorAll(".module-item").forEach((el) => {
|
|
526
|
+
el.classList.toggle("active", el.dataset.module === name);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// Module library — add modules from other templates
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
async function toggleModuleLibraryDropdown() {
|
|
535
|
+
const dropdown = document.getElementById("module-library-dropdown");
|
|
536
|
+
if (!dropdown.classList.contains("hidden")) {
|
|
537
|
+
dropdown.classList.add("hidden");
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
const res = await fetch("/api/module-library");
|
|
543
|
+
const data = await res.json();
|
|
544
|
+
const currentModules = Array.from(document.querySelectorAll(".module-item"))
|
|
545
|
+
.map((el) => el.dataset.module);
|
|
546
|
+
|
|
547
|
+
// Filter to modules not already in current template
|
|
548
|
+
const available = (data.modules || []).filter(
|
|
549
|
+
(m) => !currentModules.includes(m.moduleName)
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
if (available.length === 0) {
|
|
553
|
+
dropdown.innerHTML = `<div class="module-library-dropdown__empty">No other modules available</div>`;
|
|
554
|
+
} else {
|
|
555
|
+
dropdown.innerHTML = available.map((m) =>
|
|
556
|
+
`<button class="module-library-dropdown__item" data-name="${escapeHtml(m.moduleName)}">
|
|
557
|
+
<span class="module-library-dropdown__name">${escapeHtml(m.moduleName)}</span>
|
|
558
|
+
<span class="module-library-dropdown__meta">${escapeHtml(m.usedIn.join(", "))}</span>
|
|
559
|
+
</button>`
|
|
560
|
+
).join("");
|
|
561
|
+
|
|
562
|
+
dropdown.querySelectorAll(".module-library-dropdown__item").forEach((btn) => {
|
|
563
|
+
btn.addEventListener("click", () => {
|
|
564
|
+
addModuleFromLibrary(btn.dataset.name);
|
|
565
|
+
dropdown.classList.add("hidden");
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
dropdown.classList.remove("hidden");
|
|
571
|
+
} catch (err) {
|
|
572
|
+
console.error("Failed to load module library:", err);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function addModuleFromLibrary(moduleName) {
|
|
577
|
+
try {
|
|
578
|
+
const session = await fetch("/api/session").then((r) => r.json());
|
|
579
|
+
const templateId = session.activeTemplateId || session.id;
|
|
580
|
+
|
|
581
|
+
// Use the templates/activate API to copy module
|
|
582
|
+
const res = await fetch(`/api/templates/${encodeURIComponent(templateId)}/add-module`, {
|
|
583
|
+
method: "POST",
|
|
584
|
+
headers: { "Content-Type": "application/json" },
|
|
585
|
+
body: JSON.stringify({ moduleName }),
|
|
586
|
+
});
|
|
587
|
+
const data = await res.json();
|
|
588
|
+
if (data.error) {
|
|
589
|
+
console.warn("Add module error:", data.error);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Refresh module list and preview
|
|
594
|
+
const modRes = await fetch("/api/modules");
|
|
595
|
+
const modData = await modRes.json();
|
|
596
|
+
updateModuleList(modData.modules.map((m) => m.moduleName));
|
|
597
|
+
if (typeof refreshPreview === "function") refreshPreview();
|
|
598
|
+
} catch (err) {
|
|
599
|
+
console.error("Failed to add module:", err);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Add module button listener
|
|
604
|
+
document.getElementById("btn-add-module").addEventListener("click", toggleModuleLibraryDropdown);
|
|
605
|
+
|
|
606
|
+
// Close dropdown when clicking outside
|
|
607
|
+
document.addEventListener("click", (e) => {
|
|
608
|
+
const dropdown = document.getElementById("module-library-dropdown");
|
|
609
|
+
const btn = document.getElementById("btn-add-module");
|
|
610
|
+
if (!dropdown.contains(e.target) && e.target !== btn) {
|
|
611
|
+
dropdown.classList.add("hidden");
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
async function confirmDeleteModule(moduleName) {
|
|
616
|
+
const ok = await vibeConfirm(`Delete "${escapeHtml(moduleName)}"?`, "This cannot be undone.", { confirmLabel: "Delete" });
|
|
617
|
+
if (!ok) return;
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
await fetch("/api/modules", {
|
|
621
|
+
method: "DELETE",
|
|
622
|
+
headers: { "Content-Type": "application/json" },
|
|
623
|
+
body: JSON.stringify({ moduleName }),
|
|
624
|
+
});
|
|
625
|
+
// Remove from list and refresh preview
|
|
626
|
+
const item = document.querySelector(`.module-item[data-module="${CSS.escape(moduleName)}"]`);
|
|
627
|
+
if (item) item.remove();
|
|
628
|
+
const countEl = document.getElementById("module-count");
|
|
629
|
+
countEl.textContent = document.querySelectorAll(".module-item").length;
|
|
630
|
+
refreshPreview();
|
|
631
|
+
} catch {
|
|
632
|
+
// silently fail
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// Drag-and-drop reordering
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
function setupDragReorder(container) {
|
|
641
|
+
let dragItem = null;
|
|
642
|
+
let dragY = 0;
|
|
643
|
+
|
|
644
|
+
container.querySelectorAll(".module-item__drag").forEach((handle) => {
|
|
645
|
+
handle.addEventListener("mousedown", (e) => {
|
|
646
|
+
dragItem = handle.closest(".module-item");
|
|
647
|
+
dragY = e.clientY;
|
|
648
|
+
dragItem.style.opacity = "0.5";
|
|
649
|
+
|
|
650
|
+
const onMove = (e) => {
|
|
651
|
+
const dy = e.clientY - dragY;
|
|
652
|
+
if (Math.abs(dy) > 30) {
|
|
653
|
+
const items = [...container.querySelectorAll(".module-item")];
|
|
654
|
+
const idx = items.indexOf(dragItem);
|
|
655
|
+
if (dy > 0 && idx < items.length - 1) {
|
|
656
|
+
container.insertBefore(items[idx + 1], dragItem);
|
|
657
|
+
} else if (dy < 0 && idx > 0) {
|
|
658
|
+
container.insertBefore(dragItem, items[idx - 1]);
|
|
659
|
+
}
|
|
660
|
+
dragY = e.clientY;
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const onUp = () => {
|
|
665
|
+
if (dragItem) dragItem.style.opacity = "1";
|
|
666
|
+
dragItem = null;
|
|
667
|
+
document.removeEventListener("mousemove", onMove);
|
|
668
|
+
document.removeEventListener("mouseup", onUp);
|
|
669
|
+
|
|
670
|
+
// Send new order to server
|
|
671
|
+
const newOrder = [...container.querySelectorAll(".module-item")].map(
|
|
672
|
+
(el) => el.dataset.module
|
|
673
|
+
);
|
|
674
|
+
fetch("/api/modules/reorder", {
|
|
675
|
+
method: "POST",
|
|
676
|
+
headers: { "Content-Type": "application/json" },
|
|
677
|
+
body: JSON.stringify({ order: newOrder }),
|
|
678
|
+
}).then(() => refreshPreview());
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
document.addEventListener("mousemove", onMove);
|
|
682
|
+
document.addEventListener("mouseup", onUp);
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
// Event listeners
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
// Send button
|
|
692
|
+
sendBtn.addEventListener("click", () => {
|
|
693
|
+
sendMessage(inputEl.value);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// Enter to send (Shift+Enter for newline)
|
|
697
|
+
inputEl.addEventListener("keydown", (e) => {
|
|
698
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
699
|
+
e.preventDefault();
|
|
700
|
+
sendMessage(inputEl.value);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
// Auto-grow textarea
|
|
705
|
+
inputEl.addEventListener("input", () => {
|
|
706
|
+
inputEl.style.height = "auto";
|
|
707
|
+
inputEl.style.height = Math.min(inputEl.scrollHeight, 150) + "px";
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// Starter template buttons
|
|
711
|
+
document.getElementById("starter-templates").addEventListener("click", (e) => {
|
|
712
|
+
const btn = e.target.closest(".starter-btn");
|
|
713
|
+
if (btn) sendMessage(btn.dataset.prompt);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// Version history
|
|
717
|
+
document.getElementById("btn-history")?.addEventListener("click", toggleHistoryPanel);
|
|
718
|
+
document.getElementById("history-panel-close")?.addEventListener("click", () => {
|
|
719
|
+
historyPanelOpen = false;
|
|
720
|
+
document.getElementById("history-panel")?.classList.add("hidden");
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
// Import from GitHub is now on the setup screen (setup.js)
|
|
724
|
+
|
|
725
|
+
// Upload button — triggers the upload panel
|
|
726
|
+
document.getElementById("btn-upload").addEventListener("click", () => {
|
|
727
|
+
if (typeof startUpload === "function") {
|
|
728
|
+
startUpload();
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// Resize handle
|
|
733
|
+
const resizeHandle = document.getElementById("resize-handle");
|
|
734
|
+
const panelLeft = document.getElementById("panel-left");
|
|
735
|
+
|
|
736
|
+
resizeHandle.addEventListener("mousedown", (e) => {
|
|
737
|
+
e.preventDefault();
|
|
738
|
+
resizeHandle.classList.add("dragging");
|
|
739
|
+
|
|
740
|
+
const onMove = (e) => {
|
|
741
|
+
const width = Math.max(300, Math.min(600, e.clientX));
|
|
742
|
+
panelLeft.style.width = width + "px";
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
const onUp = () => {
|
|
746
|
+
resizeHandle.classList.remove("dragging");
|
|
747
|
+
document.removeEventListener("mousemove", onMove);
|
|
748
|
+
document.removeEventListener("mouseup", onUp);
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
document.addEventListener("mousemove", onMove);
|
|
752
|
+
document.addEventListener("mouseup", onUp);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
// Responsive toggle
|
|
756
|
+
document.getElementById("responsive-toggle").addEventListener("click", (e) => {
|
|
757
|
+
const btn = e.target.closest(".responsive-btn");
|
|
758
|
+
if (!btn) return;
|
|
759
|
+
|
|
760
|
+
document.querySelectorAll(".responsive-btn").forEach((b) => b.classList.remove("active"));
|
|
761
|
+
btn.classList.add("active");
|
|
762
|
+
|
|
763
|
+
const chrome = document.getElementById("browser-chrome");
|
|
764
|
+
const width = btn.dataset.width;
|
|
765
|
+
chrome.style.maxWidth = width === "100%" ? "none" : width;
|
|
766
|
+
|
|
767
|
+
// Update browser URL bar with theme name
|
|
768
|
+
const urlEl = document.getElementById("browser-url");
|
|
769
|
+
const themeName = document.getElementById("theme-name")?.textContent || "vibespot.app";
|
|
770
|
+
if (urlEl) urlEl.textContent = themeName + ".vibespot.app";
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
// HubSpot account status pill
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
async function fetchHsAccountStatus() {
|
|
778
|
+
const pill = document.getElementById("status-hs-account");
|
|
779
|
+
if (!pill) return;
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
const res = await fetch("/api/settings/status");
|
|
783
|
+
const data = await res.json();
|
|
784
|
+
const hs = data.environment?.tools?.hubspot;
|
|
785
|
+
|
|
786
|
+
if (hs && hs.authenticated && hs.portalName) {
|
|
787
|
+
pill.innerHTML = `<span class="statusbar__dot statusbar__dot--ok"></span>${hs.portalName}${hs.portalId ? " (" + hs.portalId + ")" : ""}`;
|
|
788
|
+
pill.classList.add("statusbar__pill--visible");
|
|
789
|
+
} else {
|
|
790
|
+
pill.textContent = "";
|
|
791
|
+
pill.classList.remove("statusbar__pill--visible");
|
|
792
|
+
}
|
|
793
|
+
} catch {
|
|
794
|
+
// Silently ignore
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ---------------------------------------------------------------------------
|
|
799
|
+
// Initialize
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
|
|
802
|
+
// WebSocket connection is started by setup.js after a session is created.
|
|
803
|
+
// Do NOT auto-connect here.
|