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.
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Dashboard screen — project overview with templates, module library, and brand assets.
3
+ * Sits between setup (project list) and chat (template editing).
4
+ */
5
+
6
+ const dashboardScreen = document.getElementById("dashboard-screen");
7
+
8
+ // Page type labels for display
9
+ const PAGE_TYPE_LABELS = {
10
+ landing_page: "LP",
11
+ blog_post: "Blog",
12
+ website_page: "Web",
13
+ module_only: "Mod",
14
+ };
15
+
16
+ const PAGE_TYPE_FULL_LABELS = {
17
+ landing_page: "Landing Page",
18
+ blog_post: "Blog Post",
19
+ website_page: "Website Page",
20
+ module_only: "Module Only",
21
+ };
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Show / hide dashboard
25
+ // ---------------------------------------------------------------------------
26
+
27
+ let currentDashboardTheme = "";
28
+
29
+ async function showDashboard(themeName) {
30
+ currentDashboardTheme = themeName;
31
+
32
+ // Hide other screens
33
+ setupScreen.classList.add("hidden");
34
+ document.getElementById("setup-topbar").classList.add("hidden");
35
+ appScreen.classList.add("hidden");
36
+ dashboardScreen.classList.remove("hidden");
37
+
38
+ document.getElementById("dashboard-theme-name").textContent = themeName;
39
+
40
+ // Update URL
41
+ const target = "#/dashboard/" + encodeURIComponent(themeName);
42
+ if (location.hash !== target) {
43
+ history.pushState(null, "", target);
44
+ }
45
+
46
+ // Load dashboard data
47
+ await refreshDashboard();
48
+ }
49
+
50
+ function hideDashboard() {
51
+ dashboardScreen.classList.add("hidden");
52
+ currentDashboardTheme = "";
53
+ closeModulePreview();
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Load dashboard data from server
58
+ // ---------------------------------------------------------------------------
59
+
60
+ async function refreshDashboard() {
61
+ try {
62
+ const res = await fetch("/api/dashboard");
63
+ const data = await res.json();
64
+ if (data.error) {
65
+ console.warn("Dashboard load error:", data.error);
66
+ return;
67
+ }
68
+ renderTemplateList(data.templates || []);
69
+ renderModuleLibrary(data.moduleLibrary || []);
70
+ renderBrandAssets(data.brandAssets || {});
71
+ } catch (err) {
72
+ console.error("Failed to load dashboard:", err);
73
+ }
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Template list
78
+ // ---------------------------------------------------------------------------
79
+
80
+ function renderTemplateList(templates) {
81
+ const list = document.getElementById("dashboard-template-list");
82
+ const countEl = document.getElementById("dashboard-template-count");
83
+ countEl.textContent = templates.length;
84
+
85
+ if (templates.length === 0) {
86
+ list.innerHTML = `<p class="dashboard__empty-state">No templates yet. Choose a page type above to get started.</p>`;
87
+ return;
88
+ }
89
+
90
+ list.innerHTML = "";
91
+ for (const tpl of templates) {
92
+ const item = document.createElement("div");
93
+ item.className = "dashboard__template-item";
94
+ item.innerHTML = `
95
+ <span class="dashboard__template-badge dashboard__template-badge--${tpl.pageType}">${esc(PAGE_TYPE_LABELS[tpl.pageType] || "?")}</span>
96
+ <span class="dashboard__template-label">${esc(tpl.label)}</span>
97
+ <span class="dashboard__template-meta">${tpl.moduleCount} module${tpl.moduleCount !== 1 ? "s" : ""}</span>
98
+ <button class="btn btn--sm btn--primary dashboard__template-open" data-id="${esc(tpl.id)}">Open</button>
99
+ <button class="dashboard__template-delete" data-id="${esc(tpl.id)}" title="Delete template">&times;</button>
100
+ `;
101
+ list.appendChild(item);
102
+ }
103
+
104
+ // Attach click handlers
105
+ list.querySelectorAll(".dashboard__template-open").forEach((btn) => {
106
+ btn.addEventListener("click", () => openTemplate(btn.dataset.id));
107
+ });
108
+ list.querySelectorAll(".dashboard__template-delete").forEach((btn) => {
109
+ btn.addEventListener("click", () => confirmDeleteTemplate(btn.dataset.id));
110
+ });
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Module library
115
+ // ---------------------------------------------------------------------------
116
+
117
+ let activePreviewModule = "";
118
+
119
+ function renderModuleLibrary(modules) {
120
+ const container = document.getElementById("dashboard-module-library");
121
+
122
+ if (modules.length === 0) {
123
+ container.innerHTML = `<p class="dashboard__empty-state">Modules will appear here as you build pages.</p>`;
124
+ closeModulePreview();
125
+ return;
126
+ }
127
+
128
+ container.innerHTML = "";
129
+ for (const mod of modules) {
130
+ const chip = document.createElement("span");
131
+ chip.className = "dashboard__module-chip";
132
+ if (mod.moduleName === activePreviewModule) chip.classList.add("dashboard__module-chip--active");
133
+ chip.textContent = mod.moduleName;
134
+ chip.title = `Used in: ${mod.usedIn.join(", ")}`;
135
+ chip.dataset.module = mod.moduleName;
136
+ chip.dataset.usedIn = mod.usedIn.join(", ");
137
+ chip.addEventListener("click", () => toggleModulePreview(mod.moduleName, mod.usedIn));
138
+ container.appendChild(chip);
139
+ }
140
+ }
141
+
142
+ function toggleModulePreview(moduleName, usedIn) {
143
+ if (activePreviewModule === moduleName) {
144
+ closeModulePreview();
145
+ return;
146
+ }
147
+ showModulePreview(moduleName, usedIn);
148
+ }
149
+
150
+ async function showModulePreview(moduleName, usedIn) {
151
+ const previewEl = document.getElementById("dashboard-module-preview");
152
+ const nameEl = document.getElementById("dashboard-preview-name");
153
+ const usedEl = document.getElementById("dashboard-preview-used");
154
+ const frame = document.getElementById("dashboard-preview-frame");
155
+
156
+ activePreviewModule = moduleName;
157
+
158
+ // Update active chip styling
159
+ document.querySelectorAll(".dashboard__module-chip").forEach((c) => {
160
+ c.classList.toggle("dashboard__module-chip--active", c.dataset.module === moduleName);
161
+ });
162
+
163
+ nameEl.textContent = moduleName;
164
+ usedEl.textContent = usedIn ? `Used in: ${usedIn.join(", ")}` : "";
165
+ previewEl.classList.remove("hidden");
166
+
167
+ // Load preview into iframe
168
+ try {
169
+ const res = await fetch(`/module-preview?module=${encodeURIComponent(moduleName)}`);
170
+ const html = await res.text();
171
+ frame.srcdoc = html;
172
+ } catch {
173
+ frame.srcdoc = "<p style='padding:2rem;color:#888;font-family:sans-serif'>Preview unavailable</p>";
174
+ }
175
+ }
176
+
177
+ function closeModulePreview() {
178
+ activePreviewModule = "";
179
+ const previewEl = document.getElementById("dashboard-module-preview");
180
+ previewEl.classList.add("hidden");
181
+ document.querySelectorAll(".dashboard__module-chip").forEach((c) => {
182
+ c.classList.remove("dashboard__module-chip--active");
183
+ });
184
+ }
185
+
186
+ // Close button for module preview
187
+ document.getElementById("dashboard-preview-close").addEventListener("click", closeModulePreview);
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Brand assets
191
+ // ---------------------------------------------------------------------------
192
+
193
+ function renderBrandAssets(assets) {
194
+ const sgIcon = document.getElementById("brand-icon-styleguide");
195
+ const bvIcon = document.getElementById("brand-icon-brandvoice");
196
+
197
+ if (assets.hasStyleguide) {
198
+ sgIcon.textContent = "\u2713";
199
+ sgIcon.classList.add("brand-asset-upload__icon--done");
200
+ } else {
201
+ sgIcon.textContent = "+";
202
+ sgIcon.classList.remove("brand-asset-upload__icon--done");
203
+ }
204
+
205
+ if (assets.hasBrandvoice) {
206
+ bvIcon.textContent = "\u2713";
207
+ bvIcon.classList.add("brand-asset-upload__icon--done");
208
+ } else {
209
+ bvIcon.textContent = "+";
210
+ bvIcon.classList.remove("brand-asset-upload__icon--done");
211
+ }
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Actions
216
+ // ---------------------------------------------------------------------------
217
+
218
+ async function createTemplateFromPageType(pageType) {
219
+ const defaultLabels = {
220
+ landing_page: "Landing Page",
221
+ blog_post: "Blog Post",
222
+ website_page: "Website Page",
223
+ module_only: "Module",
224
+ };
225
+
226
+ const label = await vibePrompt("Template name", defaultLabels[pageType] || "New Template");
227
+ if (!label) return;
228
+
229
+ try {
230
+ const res = await fetch("/api/templates", {
231
+ method: "POST",
232
+ headers: { "Content-Type": "application/json" },
233
+ body: JSON.stringify({ pageType, label }),
234
+ });
235
+ const data = await res.json();
236
+ if (data.error) {
237
+ await vibeAlert(data.error, "Error");
238
+ return;
239
+ }
240
+
241
+ // Open the newly created template in chat
242
+ openTemplate(data.template.id);
243
+ } catch (err) {
244
+ await vibeAlert("Failed to create template: " + err.message, "Error");
245
+ }
246
+ }
247
+
248
+ async function openTemplate(templateId) {
249
+ try {
250
+ // Activate the template on the server
251
+ const res = await fetch("/api/templates/activate", {
252
+ method: "POST",
253
+ headers: { "Content-Type": "application/json" },
254
+ body: JSON.stringify({ templateId }),
255
+ });
256
+ const data = await res.json();
257
+ if (data.error) {
258
+ await vibeAlert(data.error, "Error");
259
+ return;
260
+ }
261
+
262
+ // Transition to chat screen
263
+ showChat(currentDashboardTheme, templateId);
264
+ } catch (err) {
265
+ await vibeAlert("Failed to open template: " + err.message, "Error");
266
+ }
267
+ }
268
+
269
+ async function confirmDeleteTemplate(templateId) {
270
+ const ok = await vibeConfirm("Delete this template?", "This cannot be undone.", { confirmLabel: "Delete" });
271
+ if (!ok) return;
272
+
273
+ try {
274
+ await fetch("/api/templates", {
275
+ method: "DELETE",
276
+ headers: { "Content-Type": "application/json" },
277
+ body: JSON.stringify({ templateId }),
278
+ });
279
+ await refreshDashboard();
280
+ } catch (err) {
281
+ await vibeAlert("Failed to delete: " + err.message, "Error");
282
+ }
283
+ }
284
+
285
+ async function uploadBrandAsset(type) {
286
+ const uploadEl = document.getElementById(`brand-upload-${type}`);
287
+ const fileInput = uploadEl.querySelector("input[type=file]");
288
+
289
+ // Trigger file picker
290
+ fileInput.click();
291
+ }
292
+
293
+ async function handleBrandFileSelected(type, file) {
294
+ const content = await file.text();
295
+
296
+ try {
297
+ const res = await fetch("/api/brand-assets", {
298
+ method: "POST",
299
+ headers: { "Content-Type": "application/json" },
300
+ body: JSON.stringify({ type, content }),
301
+ });
302
+ const data = await res.json();
303
+ if (data.error) {
304
+ await vibeAlert(data.error, "Error");
305
+ return;
306
+ }
307
+ refreshDashboard();
308
+ } catch (err) {
309
+ await vibeAlert("Failed to upload: " + err.message, "Error");
310
+ }
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Chat screen transition
315
+ // ---------------------------------------------------------------------------
316
+
317
+ function showChat(themeName, templateId) {
318
+ hideDashboard();
319
+
320
+ // Show app screen
321
+ appScreen.classList.remove("hidden");
322
+ document.getElementById("theme-name").textContent = themeName;
323
+
324
+ // Update browser chrome URL bar
325
+ const urlEl = document.getElementById("browser-url");
326
+ if (urlEl) urlEl.textContent = themeName + ".vibespot.app";
327
+
328
+ // Update URL
329
+ const target = `#/app/${encodeURIComponent(themeName)}/${encodeURIComponent(templateId)}`;
330
+ if (location.hash !== target) {
331
+ history.pushState(null, "", target);
332
+ }
333
+
334
+ // Connect WebSocket (defined in chat.js)
335
+ if (typeof connectWebSocket === "function") {
336
+ connectWebSocket();
337
+ }
338
+
339
+ // Load initial preview
340
+ if (typeof refreshPreview === "function") {
341
+ refreshPreview();
342
+ }
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Event listeners
347
+ // ---------------------------------------------------------------------------
348
+
349
+ // Page type cards
350
+ document.querySelectorAll(".page-type-card").forEach((card) => {
351
+ card.addEventListener("click", () => {
352
+ createTemplateFromPageType(card.dataset.type);
353
+ });
354
+ });
355
+
356
+ // Back button → setup
357
+ document.getElementById("dashboard-back").addEventListener("click", () => {
358
+ hideDashboard();
359
+ if (typeof showSetup === "function") showSetup();
360
+ });
361
+
362
+ // Settings button
363
+ document.getElementById("dashboard-settings-btn").addEventListener("click", () => {
364
+ if (typeof openSettings === "function") openSettings();
365
+ });
366
+
367
+ // Deploy button
368
+ document.getElementById("dashboard-deploy-btn").addEventListener("click", () => {
369
+ if (typeof startUpload === "function") {
370
+ // Need to show app screen temporarily for upload
371
+ appScreen.classList.remove("hidden");
372
+ dashboardScreen.classList.add("hidden");
373
+ startUpload();
374
+ }
375
+ });
376
+
377
+ // Brand asset file inputs
378
+ document.getElementById("brand-upload-styleguide").querySelector("input").addEventListener("change", (e) => {
379
+ if (e.target.files[0]) handleBrandFileSelected("styleguide", e.target.files[0]);
380
+ });
381
+ document.getElementById("brand-upload-brandvoice").querySelector("input").addEventListener("change", (e) => {
382
+ if (e.target.files[0]) handleBrandFileSelected("brandvoice", e.target.files[0]);
383
+ });
package/ui/dialog.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Shared modal dialog utilities — styled replacements for native alert/confirm/prompt.
3
+ * Uses the existing .confirm-overlay / .confirm-dialog CSS pattern.
4
+ */
5
+
6
+ // HTML-escape helper (standalone so dialog.js has no load-order dependency)
7
+ if (typeof esc === "undefined") {
8
+ // eslint-disable-next-line no-var
9
+ var esc = function (str) {
10
+ const el = document.createElement("span");
11
+ el.textContent = String(str);
12
+ return el.innerHTML;
13
+ };
14
+ }
15
+
16
+ /**
17
+ * Show a styled alert dialog (replaces window.alert).
18
+ * @param {string} message — the message to display
19
+ * @param {string} [title] — optional dialog title
20
+ * @returns {Promise<void>}
21
+ */
22
+ function vibeAlert(message, title) {
23
+ return new Promise((resolve) => {
24
+ const overlay = document.createElement("div");
25
+ overlay.className = "confirm-overlay";
26
+ overlay.innerHTML = `
27
+ <div class="confirm-dialog">
28
+ ${title ? `<div class="confirm-dialog__title">${esc(title)}</div>` : ""}
29
+ <p class="confirm-dialog__detail">${esc(message)}</p>
30
+ <div class="confirm-dialog__actions">
31
+ <button class="btn btn--primary" data-action="ok">OK</button>
32
+ </div>
33
+ </div>
34
+ `;
35
+ document.body.appendChild(overlay);
36
+
37
+ const close = () => { overlay.remove(); resolve(); };
38
+ overlay.querySelector('[data-action="ok"]').addEventListener("click", close);
39
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Show a styled confirm dialog (replaces window.confirm).
45
+ * @param {string} title — dialog title / question
46
+ * @param {string} [detail] — optional detail text
47
+ * @param {object} [opts] — options: { confirmLabel, confirmClass }
48
+ * @returns {Promise<boolean>}
49
+ */
50
+ function vibeConfirm(title, detail, opts) {
51
+ const label = (opts && opts.confirmLabel) || "Confirm";
52
+ const btnClass = (opts && opts.confirmClass) || "btn--danger";
53
+ return new Promise((resolve) => {
54
+ const overlay = document.createElement("div");
55
+ overlay.className = "confirm-overlay";
56
+ overlay.innerHTML = `
57
+ <div class="confirm-dialog">
58
+ <div class="confirm-dialog__title">${esc(title)}</div>
59
+ ${detail ? `<p class="confirm-dialog__warn">${esc(detail)}</p>` : ""}
60
+ <div class="confirm-dialog__actions">
61
+ <button class="btn btn--secondary" data-action="cancel">Cancel</button>
62
+ <button class="btn ${btnClass}" data-action="confirm">${esc(label)}</button>
63
+ </div>
64
+ </div>
65
+ `;
66
+ document.body.appendChild(overlay);
67
+
68
+ const close = (val) => { overlay.remove(); resolve(val); };
69
+ overlay.querySelector('[data-action="cancel"]').addEventListener("click", () => close(false));
70
+ overlay.querySelector('[data-action="confirm"]').addEventListener("click", () => close(true));
71
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(false); });
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Show a styled prompt dialog with an input field (replaces window.prompt).
77
+ * @param {string} title — dialog title
78
+ * @param {string} [defaultValue] — pre-filled input value
79
+ * @param {string} [placeholder] — input placeholder
80
+ * @returns {Promise<string|null>} — input value or null if cancelled
81
+ */
82
+ function vibePrompt(title, defaultValue, placeholder) {
83
+ return new Promise((resolve) => {
84
+ const overlay = document.createElement("div");
85
+ overlay.className = "confirm-overlay";
86
+ overlay.innerHTML = `
87
+ <div class="confirm-dialog">
88
+ <div class="confirm-dialog__title">${esc(title)}</div>
89
+ <input
90
+ type="text"
91
+ class="confirm-dialog__input"
92
+ value="${esc(defaultValue || "")}"
93
+ placeholder="${esc(placeholder || "")}"
94
+ data-role="input"
95
+ />
96
+ <div class="confirm-dialog__actions">
97
+ <button class="btn btn--secondary" data-action="cancel">Cancel</button>
98
+ <button class="btn btn--primary" data-action="ok">OK</button>
99
+ </div>
100
+ </div>
101
+ `;
102
+ document.body.appendChild(overlay);
103
+
104
+ const input = overlay.querySelector('[data-role="input"]');
105
+ setTimeout(() => { input.focus(); input.select(); }, 50);
106
+
107
+ const close = (val) => { overlay.remove(); resolve(val); };
108
+
109
+ overlay.querySelector('[data-action="cancel"]').addEventListener("click", () => close(null));
110
+ overlay.querySelector('[data-action="ok"]').addEventListener("click", () => close(input.value));
111
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(null); });
112
+ input.addEventListener("keydown", (e) => {
113
+ if (e.key === "Enter") close(input.value);
114
+ if (e.key === "Escape") close(null);
115
+ });
116
+ });
117
+ }