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/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, "&amp;")
345
+ .replace(/</g, "&lt;")
346
+ .replace(/>/g, "&gt;")
347
+ .replace(/"/g, "&quot;");
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">&times;</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.