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/setup.js ADDED
@@ -0,0 +1,830 @@
1
+ /**
2
+ * Setup screen — onboarding flow in the browser.
3
+ * Handles theme creation, fetching, opening, and session resume.
4
+ */
5
+
6
+ const setupScreen = document.getElementById("setup-screen");
7
+ const appScreen = document.getElementById("app-screen");
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Load setup info on page load
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const ENGINE_DISPLAY_NAMES = {
14
+ "claude-code": "Claude Code",
15
+ "anthropic-api": "Anthropic API",
16
+ "openai-api": "OpenAI API",
17
+ "gemini-cli": "Gemini CLI",
18
+ "gemini-api": "Gemini API",
19
+ "codex-cli": "Codex CLI",
20
+ };
21
+
22
+ async function initSetup() {
23
+ try {
24
+ const res = await fetch("/api/setup");
25
+ const info = await res.json();
26
+
27
+ // Populate sidebar with all projects (sessions + local themes)
28
+ populateSidebar(info);
29
+
30
+ // Auto-select engine if available but not yet chosen
31
+ if (info.availableEngines && info.availableEngines.length > 0 && !info.activeEngine) {
32
+ const engine = info.availableEngines[0];
33
+ await fetch("/api/settings/engine", {
34
+ method: "POST",
35
+ headers: { "Content-Type": "application/json" },
36
+ body: JSON.stringify({ engine }),
37
+ });
38
+ info.activeEngine = engine;
39
+ info.aiAvailable = true;
40
+ // Update statusbar
41
+ const statusEngine = document.getElementById("status-engine");
42
+ if (statusEngine) statusEngine.textContent = ENGINE_DISPLAY_NAMES[engine] || engine;
43
+ }
44
+
45
+ // Show environment alerts
46
+ const alerts = document.getElementById("setup-alerts");
47
+ alerts.innerHTML = "";
48
+ if (!info.aiAvailable) {
49
+ alerts.innerHTML += `
50
+ <div class="setup__alert setup__alert--warn">
51
+ <div>No AI engine configured. Paste an API key to get started:</div>
52
+ <div class="setup__alert-key-row">
53
+ <input type="password" class="setup__alert-key-input" id="alert-api-key" placeholder="sk-ant-api03-..." />
54
+ <button class="btn btn--primary btn--sm" id="alert-api-save">Save</button>
55
+ </div>
56
+ <div class="setup__alert-alt">or <a href="#" id="alert-setup-link">open settings</a> for more options</div>
57
+ </div>`;
58
+ setTimeout(() => {
59
+ const link = document.getElementById("alert-setup-link");
60
+ if (link) link.addEventListener("click", (e) => { e.preventDefault(); openSettings(); });
61
+ const saveBtn = document.getElementById("alert-api-save");
62
+ const keyInput = document.getElementById("alert-api-key");
63
+ if (saveBtn && keyInput) {
64
+ const doSave = () => saveAlertApiKey(keyInput.value.trim());
65
+ saveBtn.addEventListener("click", doSave);
66
+ keyInput.addEventListener("keydown", (e) => { if (e.key === "Enter") doSave(); });
67
+ }
68
+ }, 0);
69
+ }
70
+
71
+ // Check if we should show the walkthrough (fresh environment)
72
+ // Add ?walkthrough to URL to force-show it for testing
73
+ if (new URLSearchParams(location.search).has("walkthrough") ||
74
+ (!info.aiAvailable && info.sessions.length === 0 && info.localThemes.length === 0)) {
75
+ showWalkthrough();
76
+ return;
77
+ }
78
+
79
+ // Show fetch section if hs is installed
80
+ if (info.hsInstalled) {
81
+ document.getElementById("section-fetch").classList.remove("hidden");
82
+ }
83
+
84
+ } catch (err) {
85
+ showError("Could not connect to server. Is vibeSpot running?");
86
+ }
87
+ }
88
+
89
+ async function saveAlertApiKey(key) {
90
+ if (!key) return;
91
+ // Detect provider from key prefix
92
+ let provider;
93
+ if (key.startsWith("sk-ant-")) provider = "anthropic";
94
+ else if (key.startsWith("sk-")) provider = "openai";
95
+ else if (key.startsWith("AIza")) provider = "gemini";
96
+ else provider = "anthropic"; // default guess
97
+
98
+ try {
99
+ const res = await fetch("/api/settings/apikey", {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify({ provider, apiKey: key }),
103
+ });
104
+ const data = await res.json();
105
+ if (data.error) { await vibeAlert(data.error, "Error"); return; }
106
+
107
+ // Update statusbar if engine was auto-selected
108
+ if (data.autoSelectedEngine) {
109
+ const statusEngine = document.getElementById("status-engine");
110
+ if (statusEngine) statusEngine.textContent = ENGINE_DISPLAY_NAMES[data.autoSelectedEngine] || data.autoSelectedEngine;
111
+ }
112
+
113
+ // Re-init to refresh everything
114
+ initSetup();
115
+ } catch (err) {
116
+ await vibeAlert("Failed to save API key: " + err.message, "Error");
117
+ }
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Sidebar — project list (like Lovable)
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function populateSidebar(info) {
125
+ const list = document.getElementById("sidebar-project-list");
126
+ const countEl = document.getElementById("sidebar-project-count");
127
+ list.innerHTML = "";
128
+
129
+ // Build a combined, deduplicated list of projects
130
+ const projects = [];
131
+ const seen = new Set();
132
+
133
+ // Add sessions first (most recent)
134
+ for (const s of info.sessions || []) {
135
+ if (!seen.has(s.themeName)) {
136
+ seen.add(s.themeName);
137
+ projects.push({
138
+ name: s.themeName,
139
+ type: "session",
140
+ sessionId: s.id,
141
+ updatedAt: s.updatedAt,
142
+ });
143
+ }
144
+ }
145
+
146
+ // Add local themes that aren't already sessions
147
+ for (const name of info.localThemes || []) {
148
+ if (!seen.has(name)) {
149
+ seen.add(name);
150
+ projects.push({ name, type: "local", sessionId: null, updatedAt: null });
151
+ }
152
+ }
153
+
154
+ countEl.textContent = projects.length;
155
+
156
+ if (projects.length === 0) {
157
+ list.innerHTML = `<div class="setup-sidebar__empty">No projects yet.<br>Create one to get started.</div>`;
158
+ return;
159
+ }
160
+
161
+ for (const p of projects) {
162
+ const item = document.createElement("div");
163
+ item.className = "setup-sidebar__item";
164
+ const initial = p.name.charAt(0).toUpperCase();
165
+ const meta = p.updatedAt ? timeAgo(p.updatedAt) : "on disk";
166
+
167
+ const openBtn = document.createElement("button");
168
+ openBtn.className = "setup-sidebar__item-open";
169
+ openBtn.innerHTML = `
170
+ <div class="setup-sidebar__item-icon">${esc(initial)}</div>
171
+ <div class="setup-sidebar__item-info">
172
+ <span class="setup-sidebar__item-name">${esc(p.name)}</span>
173
+ <span class="setup-sidebar__item-meta">${meta}</span>
174
+ </div>
175
+ `;
176
+ openBtn.addEventListener("click", () => {
177
+ if (p.sessionId) {
178
+ resumeSession(p.sessionId);
179
+ } else {
180
+ openTheme(p.name);
181
+ }
182
+ });
183
+ item.appendChild(openBtn);
184
+
185
+ const delBtn = document.createElement("button");
186
+ delBtn.className = "setup-sidebar__item-delete";
187
+ delBtn.innerHTML = "&times;";
188
+ delBtn.title = "Delete project";
189
+ delBtn.addEventListener("click", (e) => {
190
+ e.stopPropagation();
191
+ confirmDeleteProject(p);
192
+ });
193
+ item.appendChild(delBtn);
194
+
195
+ list.appendChild(item);
196
+ }
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Delete project confirmation
201
+ // ---------------------------------------------------------------------------
202
+
203
+ function confirmDeleteProject(project) {
204
+ // Build a custom confirm dialog
205
+ const overlay = document.createElement("div");
206
+ overlay.className = "confirm-overlay";
207
+ overlay.innerHTML = `
208
+ <div class="confirm-dialog">
209
+ <div class="confirm-dialog__title">Delete "${esc(project.name)}"?</div>
210
+ <label class="confirm-dialog__check">
211
+ <input type="checkbox" id="confirm-delete-files" checked />
212
+ <span>Also delete local files</span>
213
+ </label>
214
+ <p class="confirm-dialog__warn">Deleting local files cannot be undone.</p>
215
+ <div class="confirm-dialog__actions">
216
+ <button class="btn btn--secondary" id="confirm-cancel">Cancel</button>
217
+ <button class="btn btn--danger" id="confirm-delete">Delete</button>
218
+ </div>
219
+ </div>
220
+ `;
221
+ document.body.appendChild(overlay);
222
+
223
+ document.getElementById("confirm-cancel").addEventListener("click", () => overlay.remove());
224
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
225
+
226
+ document.getElementById("confirm-delete").addEventListener("click", async () => {
227
+ const deleteFiles = document.getElementById("confirm-delete-files").checked;
228
+ overlay.remove();
229
+
230
+ try {
231
+ if (project.sessionId) {
232
+ await fetch("/api/themes", {
233
+ method: "DELETE",
234
+ headers: { "Content-Type": "application/json" },
235
+ body: JSON.stringify({ sessionId: project.sessionId, deleteFiles }),
236
+ });
237
+ } else if (deleteFiles) {
238
+ // Local-only theme (no session) — delete via dedicated endpoint
239
+ await fetch("/api/themes/delete-local", {
240
+ method: "POST",
241
+ headers: { "Content-Type": "application/json" },
242
+ body: JSON.stringify({ themeName: project.name }),
243
+ });
244
+ }
245
+ // Refresh the sidebar
246
+ initSetup();
247
+ } catch {
248
+ showError("Failed to delete project.");
249
+ }
250
+ });
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Guided walkthrough (first-run experience)
255
+ // ---------------------------------------------------------------------------
256
+
257
+ async function showWalkthrough() {
258
+ const walkthrough = document.getElementById("walkthrough");
259
+ const options = document.getElementById("setup-options");
260
+
261
+ walkthrough.classList.remove("hidden");
262
+ options.classList.add("hidden");
263
+
264
+ // Fetch full environment status for CLI tool details
265
+ let envData;
266
+ try {
267
+ const res = await fetch("/api/settings/status");
268
+ envData = await res.json();
269
+ } catch {
270
+ showError("Could not load environment status.");
271
+ walkthrough.classList.add("hidden");
272
+ options.classList.remove("hidden");
273
+ return;
274
+ }
275
+
276
+ const env = envData.environment;
277
+ const progress = document.getElementById("walkthrough-progress");
278
+ const content = document.getElementById("walkthrough-content");
279
+ progress.innerHTML = "";
280
+
281
+ content.innerHTML = `
282
+ <div class="walkthrough__step-title">Set up your AI engine</div>
283
+ <div class="walkthrough__step-desc">vibeSpot needs an AI engine to build HubSpot pages. The fastest way is to paste an API key.</div>
284
+
285
+ <div class="walkthrough__card walkthrough__card--highlight">
286
+ <div class="walkthrough__card-title">Paste an API key <span class="walkthrough__badge">Easiest</span></div>
287
+ <div class="walkthrough__key-row">
288
+ <label>Anthropic</label>
289
+ <input type="password" class="walkthrough__key-input" id="wt-key-anthropic" placeholder="sk-ant-api03-..." />
290
+ <button class="btn btn--primary btn--sm" data-provider="anthropic">Save</button>
291
+ <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener" class="walkthrough__key-link">Get key</a>
292
+ </div>
293
+ <div class="walkthrough__key-row">
294
+ <label>OpenAI</label>
295
+ <input type="password" class="walkthrough__key-input" id="wt-key-openai" placeholder="sk-..." />
296
+ <button class="btn btn--primary btn--sm" data-provider="openai">Save</button>
297
+ <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener" class="walkthrough__key-link">Get key</a>
298
+ </div>
299
+ <div class="walkthrough__key-row">
300
+ <label>Google AI</label>
301
+ <input type="password" class="walkthrough__key-input" id="wt-key-gemini" placeholder="AIza..." />
302
+ <button class="btn btn--primary btn--sm" data-provider="gemini">Save</button>
303
+ <a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener" class="walkthrough__key-link">Get key</a>
304
+ </div>
305
+ </div>
306
+
307
+ <div class="walkthrough__card">
308
+ <div class="walkthrough__card-title">Or use a CLI tool</div>
309
+ <div class="walkthrough__tool-list">
310
+ ${cliToolRow("Claude Code", "claude-code", env.tools.claudeCode)}
311
+ ${cliToolRow("Gemini CLI", "gemini-cli", env.tools.geminiCli)}
312
+ ${cliToolRow("Codex CLI", "codex-cli", env.tools.codexCli)}
313
+ </div>
314
+ </div>
315
+
316
+ <div class="walkthrough__actions">
317
+ <button class="btn btn--secondary" id="wt-skip">Skip for now</button>
318
+ </div>
319
+ `;
320
+
321
+ // API key save handlers
322
+ content.querySelectorAll(".walkthrough__card--highlight button[data-provider]").forEach((btn) => {
323
+ const provider = btn.dataset.provider;
324
+ const input = document.getElementById("wt-key-" + provider);
325
+ const doSave = async () => {
326
+ const key = input.value.trim();
327
+ if (!key) return;
328
+ btn.disabled = true;
329
+ btn.textContent = "...";
330
+ try {
331
+ const res = await fetch("/api/settings/apikey", {
332
+ method: "POST",
333
+ headers: { "Content-Type": "application/json" },
334
+ body: JSON.stringify({ provider, apiKey: key }),
335
+ });
336
+ const data = await res.json();
337
+ if (data.error) { await vibeAlert(data.error, "Error"); btn.disabled = false; btn.textContent = "Save"; return; }
338
+ if (data.autoSelectedEngine) {
339
+ const statusEngine = document.getElementById("status-engine");
340
+ if (statusEngine) statusEngine.textContent = ENGINE_DISPLAY_NAMES[data.autoSelectedEngine] || data.autoSelectedEngine;
341
+ }
342
+ clearWalkthroughParam();
343
+ walkthrough.classList.add("hidden");
344
+ options.classList.remove("hidden");
345
+ initSetup();
346
+ } catch (err) {
347
+ await vibeAlert("Failed to save: " + err.message, "Error");
348
+ btn.disabled = false;
349
+ btn.textContent = "Save";
350
+ }
351
+ };
352
+ btn.addEventListener("click", doSave);
353
+ input.addEventListener("keydown", (e) => { if (e.key === "Enter") doSave(); });
354
+ });
355
+
356
+ // CLI tool action handlers
357
+ content.querySelectorAll("[data-cli-action]").forEach((btn) => {
358
+ btn.addEventListener("click", () => handleCliAction(btn.dataset.cliEngine, btn.dataset.cliAction, btn));
359
+ });
360
+
361
+ // Skip
362
+ document.getElementById("wt-skip").addEventListener("click", () => {
363
+ clearWalkthroughParam();
364
+ walkthrough.classList.add("hidden");
365
+ options.classList.remove("hidden");
366
+ });
367
+ }
368
+
369
+ /** Strip ?walkthrough from URL so re-init doesn't re-show it */
370
+ function clearWalkthroughParam() {
371
+ const url = new URL(location.href);
372
+ if (url.searchParams.has("walkthrough")) {
373
+ url.searchParams.delete("walkthrough");
374
+ history.replaceState(null, "", url.pathname + url.search + url.hash);
375
+ }
376
+ }
377
+
378
+ function cliToolRow(name, engineId, toolInfo) {
379
+ let statusHtml, actionHtml;
380
+ if (toolInfo.found && toolInfo.authenticated) {
381
+ statusHtml = `<span class="walkthrough__tool-status walkthrough__tool-status--ok">Ready</span>`;
382
+ actionHtml = `<button class="btn btn--sm btn--primary" data-cli-action="select" data-cli-engine="${engineId}">Use</button>`;
383
+ } else if (toolInfo.found && !toolInfo.authenticated) {
384
+ statusHtml = `<span class="walkthrough__tool-status walkthrough__tool-status--missing">Not signed in</span>`;
385
+ actionHtml = `<button class="btn btn--sm btn--secondary" data-cli-action="auth" data-cli-engine="${engineId}">Sign in</button>`;
386
+ } else {
387
+ statusHtml = `<span class="walkthrough__tool-status walkthrough__tool-status--missing">Not installed</span>`;
388
+ actionHtml = `<button class="btn btn--sm btn--secondary" data-cli-action="install" data-cli-engine="${engineId}">Install</button>`;
389
+ }
390
+ return `<div class="walkthrough__tool-item">
391
+ <span class="settings__dot settings__dot--${toolInfo.found && toolInfo.authenticated ? "success" : toolInfo.found ? "warn" : "muted"}"></span>
392
+ <span class="walkthrough__tool-name">${esc(name)}</span>
393
+ ${statusHtml}
394
+ ${actionHtml}
395
+ </div>`;
396
+ }
397
+
398
+ async function handleCliAction(engineId, action, btn) {
399
+ const toolMap = { "claude-code": "claude", "gemini-cli": "gemini", "codex-cli": "codex" };
400
+ const tool = toolMap[engineId];
401
+ if (!tool) return;
402
+
403
+ if (action === "select") {
404
+ // Already installed + authed, just select
405
+ await fetch("/api/settings/engine", {
406
+ method: "POST",
407
+ headers: { "Content-Type": "application/json" },
408
+ body: JSON.stringify({ engine: engineId }),
409
+ });
410
+ const statusEngine = document.getElementById("status-engine");
411
+ if (statusEngine) statusEngine.textContent = ENGINE_DISPLAY_NAMES[engineId] || engineId;
412
+ clearWalkthroughParam();
413
+ document.getElementById("walkthrough").classList.add("hidden");
414
+ document.getElementById("setup-options").classList.remove("hidden");
415
+ initSetup();
416
+ return;
417
+ }
418
+
419
+ const endpoint = action === "install" ? "/api/settings/install" : "/api/settings/cli-auth";
420
+ btn.disabled = true;
421
+ const origText = btn.textContent;
422
+ btn.innerHTML = '<span class="upload-spinner"></span>';
423
+
424
+ try {
425
+ const res = await fetch(endpoint, {
426
+ method: "POST",
427
+ headers: { "Content-Type": "application/json" },
428
+ body: JSON.stringify({ tool }),
429
+ });
430
+ const data = await res.json();
431
+ if (data.jobId) {
432
+ // Poll until complete
433
+ await pollJob(data.jobId);
434
+ }
435
+ // Refresh walkthrough to show updated status
436
+ showWalkthrough();
437
+ } catch {
438
+ btn.disabled = false;
439
+ btn.textContent = origText;
440
+ }
441
+ }
442
+
443
+ async function pollJob(jobId) {
444
+ for (let i = 0; i < 60; i++) {
445
+ await new Promise((r) => setTimeout(r, 2000));
446
+ try {
447
+ const res = await fetch("/api/settings/job/" + jobId);
448
+ const data = await res.json();
449
+ if (data.status === "completed" || data.status === "failed") return;
450
+ } catch { return; }
451
+ }
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // Actions
456
+ // ---------------------------------------------------------------------------
457
+
458
+ async function createTheme() {
459
+ const name = document.getElementById("new-theme-name").value.trim();
460
+ if (!name) {
461
+ showError("Please enter a name for your theme.");
462
+ return;
463
+ }
464
+
465
+ showLoading("Creating theme...");
466
+
467
+ try {
468
+ const res = await fetch("/api/setup/create", {
469
+ method: "POST",
470
+ headers: { "Content-Type": "application/json" },
471
+ body: JSON.stringify({ name }),
472
+ });
473
+ const data = await res.json();
474
+
475
+ if (data.error) {
476
+ showError(data.error);
477
+ return;
478
+ }
479
+
480
+ showApp(data.themeName);
481
+ } catch (err) {
482
+ showError("Failed to create theme: " + err.message);
483
+ }
484
+ }
485
+
486
+ async function fetchTheme() {
487
+ const name = document.getElementById("fetch-theme-name").value.trim();
488
+ if (!name) {
489
+ showError("Please enter the theme name from your HubSpot account.");
490
+ return;
491
+ }
492
+
493
+ showLoading("Fetching theme from HubSpot...");
494
+
495
+ try {
496
+ const res = await fetch("/api/setup/fetch", {
497
+ method: "POST",
498
+ headers: { "Content-Type": "application/json" },
499
+ body: JSON.stringify({ name }),
500
+ });
501
+ const data = await res.json();
502
+
503
+ if (data.error) {
504
+ showError(data.error);
505
+ return;
506
+ }
507
+
508
+ showApp(data.themeName);
509
+ } catch (err) {
510
+ showError("Failed to fetch theme: " + err.message);
511
+ }
512
+ }
513
+
514
+ async function openTheme(pathOrName) {
515
+ showLoading("Opening theme...");
516
+
517
+ try {
518
+ const res = await fetch("/api/setup/open", {
519
+ method: "POST",
520
+ headers: { "Content-Type": "application/json" },
521
+ body: JSON.stringify({ path: pathOrName }),
522
+ });
523
+ const data = await res.json();
524
+
525
+ if (data.error) {
526
+ showError(data.error);
527
+ return;
528
+ }
529
+
530
+ showApp(data.themeName);
531
+ } catch (err) {
532
+ showError("Failed to open theme: " + err.message);
533
+ }
534
+ }
535
+
536
+ async function resumeSession(sessionId) {
537
+ showLoading("Resuming session...");
538
+
539
+ try {
540
+ const res = await fetch("/api/setup/resume", {
541
+ method: "POST",
542
+ headers: { "Content-Type": "application/json" },
543
+ body: JSON.stringify({ sessionId }),
544
+ });
545
+ const data = await res.json();
546
+
547
+ if (data.error) {
548
+ showError(data.error);
549
+ return;
550
+ }
551
+
552
+ showApp(data.themeName);
553
+ } catch (err) {
554
+ showError("Failed to resume session: " + err.message);
555
+ }
556
+ }
557
+
558
+ // API key management moved to settings panel (settings.js)
559
+
560
+ // ---------------------------------------------------------------------------
561
+ // UI transitions
562
+ // ---------------------------------------------------------------------------
563
+
564
+ let currentAppTheme = "";
565
+
566
+ function showApp(themeName) {
567
+ // Route through dashboard instead of going directly to chat
568
+ if (typeof showDashboard === "function") {
569
+ currentAppTheme = themeName;
570
+ showDashboard(themeName);
571
+ } else {
572
+ // Fallback if dashboard.js not loaded
573
+ showAppDirect(themeName);
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Direct app view — shows chat screen without dashboard.
579
+ * Used as fallback or when navigating from dashboard to a specific template.
580
+ */
581
+ function showAppDirect(themeName) {
582
+ setupScreen.classList.add("hidden");
583
+ document.getElementById("setup-topbar").classList.add("hidden");
584
+ if (typeof hideDashboard === "function") hideDashboard();
585
+ appScreen.classList.remove("hidden");
586
+ document.getElementById("theme-name").textContent = themeName;
587
+
588
+ const urlEl = document.getElementById("browser-url");
589
+ if (urlEl) urlEl.textContent = themeName + ".vibespot.app";
590
+
591
+ currentAppTheme = themeName;
592
+ const target = "#/app/" + encodeURIComponent(themeName);
593
+ if (location.hash !== target) {
594
+ history.pushState(null, "", target);
595
+ }
596
+
597
+ if (typeof connectWebSocket === "function") {
598
+ connectWebSocket();
599
+ }
600
+ if (typeof refreshPreview === "function") {
601
+ refreshPreview();
602
+ }
603
+ }
604
+
605
+ function showSetup() {
606
+ appScreen.classList.add("hidden");
607
+ if (typeof hideDashboard === "function") hideDashboard();
608
+ setupScreen.classList.remove("hidden");
609
+ document.getElementById("setup-topbar").classList.remove("hidden");
610
+ currentAppTheme = "";
611
+
612
+ hideLoading();
613
+
614
+ if (location.hash && location.hash !== "#/") {
615
+ history.pushState(null, "", "#/");
616
+ }
617
+
618
+ initSetup();
619
+ }
620
+
621
+ // Logo click → go back to dashboard (from chat) or setup (from dashboard)
622
+ document.querySelectorAll(".topbar__brand").forEach((el) => {
623
+ el.style.cursor = "pointer";
624
+ el.addEventListener("click", () => {
625
+ const dashEl = document.getElementById("dashboard-screen");
626
+ // From chat → go to dashboard
627
+ if (!appScreen.classList.contains("hidden") && currentAppTheme) {
628
+ if (typeof showDashboard === "function") {
629
+ appScreen.classList.add("hidden");
630
+ showDashboard(currentAppTheme);
631
+ return;
632
+ }
633
+ }
634
+ // From dashboard → go to setup
635
+ if (dashEl && !dashEl.classList.contains("hidden")) {
636
+ showSetup();
637
+ return;
638
+ }
639
+ // Fallback
640
+ if (!appScreen.classList.contains("hidden")) {
641
+ showSetup();
642
+ }
643
+ });
644
+ });
645
+
646
+ function showLoading(text) {
647
+ hideError();
648
+ document.getElementById("setup-options").classList.add("hidden");
649
+ document.getElementById("setup-loading").classList.remove("hidden");
650
+ document.getElementById("setup-loading-text").textContent = text;
651
+ }
652
+
653
+ function hideLoading() {
654
+ document.getElementById("setup-options").classList.remove("hidden");
655
+ document.getElementById("setup-loading").classList.add("hidden");
656
+ }
657
+
658
+ function showError(message) {
659
+ hideLoading();
660
+ const el = document.getElementById("setup-error");
661
+ el.textContent = message;
662
+ el.classList.remove("hidden");
663
+ setTimeout(() => el.classList.add("hidden"), 8000);
664
+ }
665
+
666
+ function hideError() {
667
+ document.getElementById("setup-error").classList.add("hidden");
668
+ }
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // Event listeners
672
+ // ---------------------------------------------------------------------------
673
+
674
+ document.getElementById("btn-create-theme").addEventListener("click", createTheme);
675
+ document.getElementById("new-theme-name").addEventListener("keydown", (e) => {
676
+ if (e.key === "Enter") { e.preventDefault(); createTheme(); }
677
+ });
678
+
679
+ document.getElementById("btn-fetch-theme").addEventListener("click", fetchTheme);
680
+ document.getElementById("fetch-theme-name").addEventListener("keydown", (e) => {
681
+ if (e.key === "Enter") { e.preventDefault(); fetchTheme(); }
682
+ });
683
+
684
+ document.getElementById("btn-open-theme").addEventListener("click", () => {
685
+ openTheme(document.getElementById("open-theme-path").value.trim());
686
+ });
687
+ document.getElementById("open-theme-path").addEventListener("keydown", (e) => {
688
+ if (e.key === "Enter") { e.preventDefault(); document.getElementById("btn-open-theme").click(); }
689
+ });
690
+
691
+ // Import from GitHub (on setup screen)
692
+ document.getElementById("import-btn").addEventListener("click", async () => {
693
+ const urlInput = document.getElementById("import-url");
694
+ const url = urlInput.value.trim();
695
+ if (!url) return;
696
+
697
+ // Extract repo name to use as theme name
698
+ const repoMatch = url.match(/github\.com\/[\w.-]+\/([\w.-]+)/);
699
+ const themeName = repoMatch ? repoMatch[1].replace(/\.git$/, "") : "imported-project";
700
+
701
+ showLoading(`Importing ${themeName}...`);
702
+ urlInput.disabled = true;
703
+ document.getElementById("import-btn").disabled = true;
704
+
705
+ try {
706
+ // First create the theme
707
+ const setupRes = await fetch("/api/setup/create", {
708
+ method: "POST",
709
+ headers: { "Content-Type": "application/json" },
710
+ body: JSON.stringify({ themeName }),
711
+ });
712
+ const setupData = await setupRes.json();
713
+ if (setupData.error) {
714
+ showError(`Failed to create theme: ${setupData.error}`);
715
+ urlInput.disabled = false;
716
+ document.getElementById("import-btn").disabled = false;
717
+ return;
718
+ }
719
+
720
+ // Now import
721
+ const importRes = await fetch("/api/import", {
722
+ method: "POST",
723
+ headers: { "Content-Type": "application/json" },
724
+ body: JSON.stringify({ url }),
725
+ });
726
+ const importData = await importRes.json();
727
+ if (importData.error) {
728
+ showError(`Import failed: ${importData.error}`);
729
+ urlInput.disabled = false;
730
+ document.getElementById("import-btn").disabled = false;
731
+ return;
732
+ }
733
+
734
+ // Show the app and send conversion prompt
735
+ showApp(themeName);
736
+ if (typeof sendMessage === "function" && importData.conversionPrompt) {
737
+ sendMessage(importData.conversionPrompt);
738
+ }
739
+ } catch (err) {
740
+ showError(`Import failed: ${err.message}`);
741
+ urlInput.disabled = false;
742
+ document.getElementById("import-btn").disabled = false;
743
+ }
744
+ });
745
+ document.getElementById("import-url").addEventListener("keydown", (e) => {
746
+ if (e.key === "Enter") { e.preventDefault(); document.getElementById("import-btn").click(); }
747
+ });
748
+
749
+ // API key is now handled in the settings panel (settings.js)
750
+
751
+ // ---------------------------------------------------------------------------
752
+ // Helpers
753
+ // ---------------------------------------------------------------------------
754
+
755
+ function esc(str) {
756
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
757
+ }
758
+
759
+ function timeAgo(timestamp) {
760
+ const diff = Date.now() - timestamp;
761
+ const mins = Math.floor(diff / 60000);
762
+ if (mins < 1) return "just now";
763
+ if (mins < 60) return mins + "m ago";
764
+ const hours = Math.floor(mins / 60);
765
+ if (hours < 24) return hours + "h ago";
766
+ const days = Math.floor(hours / 24);
767
+ if (days < 7) return days + "d ago";
768
+ return new Date(timestamp).toLocaleDateString();
769
+ }
770
+
771
+ // ---------------------------------------------------------------------------
772
+ // Hash router — enables bookmarks and browser back/forward
773
+ // ---------------------------------------------------------------------------
774
+
775
+ function handleRoute() {
776
+ const hash = location.hash || "#/";
777
+
778
+ // #/app/{themeName}/{templateId} → open specific template in chat
779
+ const appTemplateMatch = hash.match(/^#\/app\/([^/]+)\/(.+)$/);
780
+ if (appTemplateMatch) {
781
+ const themeName = decodeURIComponent(appTemplateMatch[1]);
782
+ const templateId = decodeURIComponent(appTemplateMatch[2]);
783
+ // Already showing this — nothing to do
784
+ if (currentAppTheme === themeName && !appScreen.classList.contains("hidden")) return;
785
+ // Open theme then activate template
786
+ openTheme(themeName).then(() => {
787
+ if (typeof showChat === "function") {
788
+ showChat(themeName, templateId);
789
+ }
790
+ });
791
+ return;
792
+ }
793
+
794
+ // #/app/{themeName} → open theme (goes to dashboard or direct)
795
+ const appMatch = hash.match(/^#\/app\/([^/]+)$/);
796
+ if (appMatch) {
797
+ const themeName = decodeURIComponent(appMatch[1]);
798
+ if (currentAppTheme === themeName && !appScreen.classList.contains("hidden")) return;
799
+ openTheme(themeName);
800
+ return;
801
+ }
802
+
803
+ // #/dashboard/{themeName} → show dashboard for theme
804
+ const dashMatch = hash.match(/^#\/dashboard\/(.+)$/);
805
+ if (dashMatch) {
806
+ const themeName = decodeURIComponent(dashMatch[1]);
807
+ const dashEl = document.getElementById("dashboard-screen");
808
+ if (currentDashboardTheme === themeName && dashEl && !dashEl.classList.contains("hidden")) return;
809
+ openTheme(themeName);
810
+ return;
811
+ }
812
+
813
+ // Default: show setup
814
+ const dashEl = document.getElementById("dashboard-screen");
815
+ if (!appScreen.classList.contains("hidden") || (dashEl && !dashEl.classList.contains("hidden"))) {
816
+ showSetup();
817
+ }
818
+ }
819
+
820
+ window.addEventListener("popstate", handleRoute);
821
+
822
+ // ---------------------------------------------------------------------------
823
+ // Initialize — check URL hash first, fall back to setup screen
824
+ // ---------------------------------------------------------------------------
825
+
826
+ if (location.hash && (location.hash.startsWith("#/app/") || location.hash.startsWith("#/dashboard/"))) {
827
+ handleRoute();
828
+ } else {
829
+ initSetup();
830
+ }