vibespot 0.7.1 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibespot",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "AI-powered HubSpot CMS landing page builder — vibe coding & React converter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,12 +21,16 @@
21
21
  "dependencies": {
22
22
  "@anthropic-ai/sdk": "^0.39.0",
23
23
  "@clack/prompts": "^0.9.1",
24
+ "busboy": "^1.6.0",
24
25
  "chalk": "^5.4.1",
25
26
  "commander": "^13.1.0",
26
27
  "execa": "^9.5.2",
28
+ "mammoth": "^1.11.0",
29
+ "pdf-parse": "^2.4.5",
27
30
  "ws": "^8.19.0"
28
31
  },
29
32
  "devDependencies": {
33
+ "@types/busboy": "^1.5.4",
30
34
  "@types/node": "^22.0.0",
31
35
  "@types/ws": "^8.18.1",
32
36
  "tsup": "^8.4.0",
package/ui/chat.js CHANGED
@@ -153,25 +153,191 @@ function handleWsMessage(msg) {
153
153
  }
154
154
  }
155
155
 
156
+ // ---------------------------------------------------------------------------
157
+ // File attachments
158
+ // ---------------------------------------------------------------------------
159
+
160
+ const pendingFiles = [];
161
+ const fileChipsEl = document.getElementById("file-chips");
162
+ const fileInputEl = document.getElementById("file-input");
163
+ const attachBtn = document.getElementById("btn-attach-file");
164
+ const dropOverlay = document.getElementById("drop-overlay");
165
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
166
+
167
+ const IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/svg+xml", "image/webp", "image/gif"]);
168
+ const DOC_TYPES = new Set([
169
+ "application/pdf",
170
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
171
+ "text/markdown", "text/plain",
172
+ ]);
173
+ const SUPPORTED_TYPES = new Set([...IMAGE_TYPES, ...DOC_TYPES]);
174
+
175
+ function addPendingFile(file) {
176
+ if (!SUPPORTED_TYPES.has(file.type)) {
177
+ showToast(`Unsupported file type: ${file.name}`);
178
+ return;
179
+ }
180
+ if (file.size > MAX_FILE_SIZE) {
181
+ showToast(`File too large (>10MB): ${file.name}`);
182
+ return;
183
+ }
184
+ pendingFiles.push(file);
185
+ renderFileChips();
186
+ }
187
+
188
+ function removePendingFile(index) {
189
+ pendingFiles.splice(index, 1);
190
+ renderFileChips();
191
+ }
192
+
193
+ function renderFileChips() {
194
+ fileChipsEl.innerHTML = "";
195
+ if (pendingFiles.length === 0) {
196
+ fileChipsEl.classList.remove("visible");
197
+ return;
198
+ }
199
+ fileChipsEl.classList.add("visible");
200
+ pendingFiles.forEach((file, i) => {
201
+ const isImage = IMAGE_TYPES.has(file.type);
202
+ const chip = document.createElement("div");
203
+ chip.className = `file-chip file-chip--${isImage ? "image" : "doc"}`;
204
+ const sizeKB = Math.round(file.size / 1024);
205
+ const sizeStr = sizeKB > 1024 ? `${(sizeKB / 1024).toFixed(1)} MB` : `${sizeKB} KB`;
206
+ chip.innerHTML = `
207
+ <span class="file-chip__icon">${isImage ? "\u{1F5BC}" : "\u{1F4C4}"}</span>
208
+ <span class="file-chip__name">${escapeHtml(file.name)}</span>
209
+ <span class="file-chip__size">${sizeStr}</span>
210
+ <button class="file-chip__remove" title="Remove">&times;</button>
211
+ `;
212
+ chip.querySelector(".file-chip__remove").addEventListener("click", () => removePendingFile(i));
213
+ fileChipsEl.appendChild(chip);
214
+ });
215
+ }
216
+
217
+ function showToast(message) {
218
+ const toast = document.createElement("div");
219
+ toast.className = "toast";
220
+ toast.textContent = message;
221
+ document.body.appendChild(toast);
222
+ setTimeout(() => toast.classList.add("visible"), 10);
223
+ setTimeout(() => {
224
+ toast.classList.remove("visible");
225
+ setTimeout(() => toast.remove(), 300);
226
+ }, 3000);
227
+ }
228
+
229
+ async function uploadFiles(files) {
230
+ const formData = new FormData();
231
+ for (const file of files) {
232
+ formData.append("files", file);
233
+ }
234
+ const resp = await fetch("/api/upload-files", { method: "POST", body: formData });
235
+ if (!resp.ok) {
236
+ const err = await resp.json().catch(() => ({ error: "Upload failed" }));
237
+ throw new Error(err.error || "Upload failed");
238
+ }
239
+ return resp.json();
240
+ }
241
+
242
+ function renderFileChipsInMessage(files) {
243
+ if (!files || files.length === 0) return "";
244
+ return `<div class="chat-msg__files">${files
245
+ .map((f) => {
246
+ const isImage = f.type === "image";
247
+ return `<span class="file-chip file-chip--${isImage ? "image" : "doc"} file-chip--sent">
248
+ <span class="file-chip__icon">${isImage ? "\u{1F5BC}" : "\u{1F4C4}"}</span>
249
+ <span class="file-chip__name">${escapeHtml(f.originalName || f.name)}</span>
250
+ </span>`;
251
+ })
252
+ .join("")}</div>`;
253
+ }
254
+
255
+ // Drag-and-drop
256
+ let dragCounter = 0;
257
+
258
+ inputEl.closest(".chat__input-area").addEventListener("dragenter", (e) => {
259
+ e.preventDefault();
260
+ dragCounter++;
261
+ dropOverlay.classList.remove("hidden");
262
+ });
263
+
264
+ inputEl.closest(".chat__input-area").addEventListener("dragleave", (e) => {
265
+ e.preventDefault();
266
+ dragCounter--;
267
+ if (dragCounter <= 0) {
268
+ dragCounter = 0;
269
+ dropOverlay.classList.add("hidden");
270
+ }
271
+ });
272
+
273
+ inputEl.closest(".chat__input-area").addEventListener("dragover", (e) => {
274
+ e.preventDefault();
275
+ });
276
+
277
+ inputEl.closest(".chat__input-area").addEventListener("drop", (e) => {
278
+ e.preventDefault();
279
+ dragCounter = 0;
280
+ dropOverlay.classList.add("hidden");
281
+ if (e.dataTransfer?.files) {
282
+ for (const file of e.dataTransfer.files) {
283
+ addPendingFile(file);
284
+ }
285
+ }
286
+ });
287
+
288
+ // Paperclip button
289
+ attachBtn.addEventListener("click", () => fileInputEl.click());
290
+ fileInputEl.addEventListener("change", () => {
291
+ for (const file of fileInputEl.files) {
292
+ addPendingFile(file);
293
+ }
294
+ fileInputEl.value = "";
295
+ });
296
+
156
297
  // ---------------------------------------------------------------------------
157
298
  // Sending messages
158
299
  // ---------------------------------------------------------------------------
159
300
 
160
- function sendMessage(text) {
161
- if (!text.trim() || isStreaming || !ws || ws.readyState !== WebSocket.OPEN) return;
301
+ async function sendMessage(text) {
302
+ const hasFiles = pendingFiles.length > 0;
303
+ if ((!text.trim() && !hasFiles) || isStreaming || !ws || ws.readyState !== WebSocket.OPEN) return;
162
304
 
163
305
  // Remove welcome screen
164
306
  const welcome = messagesEl.querySelector(".chat__welcome");
165
307
  if (welcome) welcome.remove();
166
308
 
167
- // Show user message
168
- appendUserMessage(text);
309
+ // Upload files first if any
310
+ let uploadedFiles = [];
311
+ const filesToUpload = [...pendingFiles];
312
+ if (hasFiles) {
313
+ pendingFiles.length = 0;
314
+ renderFileChips();
315
+ setStatus("Uploading files...");
316
+ try {
317
+ const result = await uploadFiles(filesToUpload);
318
+ uploadedFiles = result.files || [];
319
+ if (result.errors?.length) {
320
+ result.errors.forEach((e) => showToast(e));
321
+ }
322
+ } catch (err) {
323
+ showToast(err.message);
324
+ setStatus("Upload failed");
325
+ return;
326
+ }
327
+ }
328
+
329
+ // Show user message with file chips
330
+ appendUserMessage(text, null, uploadedFiles);
169
331
 
170
332
  // Start streaming indicator
171
333
  startStreaming();
172
334
 
173
- // Send via WebSocket
174
- ws.send(JSON.stringify({ type: "chat", message: text }));
335
+ // Send via WebSocket with file IDs
336
+ const payload = { type: "chat", message: text || "(files attached)" };
337
+ if (uploadedFiles.length > 0) {
338
+ payload.fileIds = uploadedFiles.map((f) => f.id);
339
+ }
340
+ ws.send(JSON.stringify(payload));
175
341
 
176
342
  // Clear input
177
343
  inputEl.value = "";
@@ -189,10 +355,11 @@ function formatMessageTime(ts) {
189
355
  return `${d.getHours()}:${d.getMinutes().toString().padStart(2, "0")}`;
190
356
  }
191
357
 
192
- function appendUserMessage(text, timestamp) {
358
+ function appendUserMessage(text, timestamp, files) {
193
359
  const time = formatMessageTime(timestamp || Date.now());
194
360
  const div = document.createElement("div");
195
361
  div.className = "chat-msg chat-msg--user";
362
+ const fileChipsHtml = renderFileChipsInMessage(files);
196
363
  div.innerHTML = `
197
364
  <div class="chat-msg__avatar chat-msg__avatar--user">Y</div>
198
365
  <div class="chat-msg__content">
@@ -200,6 +367,7 @@ function appendUserMessage(text, timestamp) {
200
367
  <span class="chat-msg__sender">You</span>
201
368
  <span class="chat-msg__time">${time}</span>
202
369
  </div>
370
+ ${fileChipsHtml}
203
371
  <div class="chat-msg__bubble">${escapeHtml(text)}</div>
204
372
  </div>`;
205
373
  messagesEl.appendChild(div);
package/ui/index.html CHANGED
@@ -70,53 +70,63 @@
70
70
  <!-- Settings link (hidden — now in sidebar footer) -->
71
71
  <div class="setup__settings-link hidden" id="setup-settings-link"></div>
72
72
 
73
- <!-- Main options -->
73
+ <!-- Main action buttons -->
74
74
  <div class="setup__options" id="setup-options">
75
- <!-- Previous sessions (hidden — now shown in sidebar) -->
76
- <div class="setup__section hidden" id="section-recent">
77
- <div class="setup__cards" id="recent-sessions"></div>
78
- </div>
79
-
80
- <!-- Local themes (hidden — now shown in sidebar) -->
81
- <div class="setup__section hidden" id="section-local">
82
- <div class="setup__cards" id="local-themes"></div>
75
+ <div class="setup__actions">
76
+ <button class="setup__action-btn" data-action="new">
77
+ <span class="setup__action-icon">&#43;</span>
78
+ <span>New Theme</span>
79
+ </button>
80
+ <button class="setup__action-btn" data-action="continue">
81
+ <span class="setup__action-icon">&#9654;</span>
82
+ <span>Continue</span>
83
+ </button>
84
+ <button class="setup__action-btn" data-action="download">
85
+ <span class="setup__action-icon">&#8595;</span>
86
+ <span>From HubSpot</span>
87
+ </button>
88
+ <button class="setup__action-btn" data-action="convert">
89
+ <span class="setup__action-icon">&#8634;</span>
90
+ <span>Convert React</span>
91
+ <span class="setup__action-badge">Experimental</span>
92
+ </button>
83
93
  </div>
84
94
 
85
- <!-- Create new -->
86
- <div class="setup__section">
87
- <p class="setup__label">Start a new project</p>
95
+ <!-- Expandable panels (one visible at a time) -->
96
+ <div class="setup__panel hidden" id="panel-new">
88
97
  <div class="setup__row">
89
98
  <input type="text" class="setup__input setup__input--wide" id="new-theme-name" placeholder="my-landing-page" />
90
99
  <button class="btn btn--primary" id="btn-create-theme">Create</button>
91
100
  </div>
92
101
  </div>
93
102
 
94
- <!-- Fetch from HubSpot (only shown if hs is installed) -->
95
- <div class="setup__section hidden" id="section-fetch">
96
- <p class="setup__label">Download from HubSpot</p>
97
- <div class="setup__row">
98
- <input type="text" class="setup__input setup__input--wide" id="fetch-theme-name" placeholder="theme-name-in-hubspot" />
99
- <button class="btn btn--secondary" id="btn-fetch-theme">Fetch</button>
100
- </div>
103
+ <div class="setup__panel hidden" id="panel-continue">
104
+ <div class="setup__pills" id="continue-projects"></div>
105
+ <p class="setup__hint setup__hint--empty hidden" id="continue-empty">No themes yet — create one above!</p>
101
106
  </div>
102
107
 
103
- <!-- Import from GitHub -->
104
- <div class="setup__section">
105
- <p class="setup__label">Import from GitHub</p>
106
- <div class="setup__row">
107
- <input type="text" class="setup__input setup__input--wide" id="import-url" placeholder="https://github.com/user/my-landing-page" />
108
- <button class="btn btn--secondary" id="import-btn">Import</button>
108
+ <div class="setup__panel hidden" id="panel-download">
109
+ <div class="setup__panel-account hidden" id="dl-account">
110
+ <span id="dl-account-name"></span>
111
+ <button class="btn-link" id="dl-account-change">Change</button>
112
+ </div>
113
+ <div id="dl-account-switch" class="hidden" style="margin:0 0 12px"></div>
114
+ <div class="setup__row hidden" id="dl-input-row">
115
+ <input type="text" class="setup__input setup__input--wide" id="dl-theme-name" placeholder="Enter theme folder name on HubSpot" />
116
+ <button class="btn btn--primary" id="btn-fetch-theme">Download</button>
117
+ </div>
118
+ <p class="setup__hint hidden" id="dl-hint">Enter the exact folder name of your theme in the HubSpot Design Manager</p>
119
+ <div class="hidden" id="dl-no-account">
120
+ <p class="setup__hint">Connect a HubSpot account in <a href="#" id="dl-open-settings">Settings</a> to download themes.</p>
109
121
  </div>
110
- <p class="setup__hint">Convert a React/Lovable project to HubSpot</p>
111
122
  </div>
112
123
 
113
- <!-- Open folder -->
114
- <div class="setup__section">
115
- <p class="setup__label">Open from disk</p>
124
+ <div class="setup__panel hidden" id="panel-convert">
116
125
  <div class="setup__row">
117
- <input type="text" class="setup__input setup__input--wide" id="open-theme-path" placeholder="/path/to/my-theme" />
118
- <button class="btn btn--secondary" id="btn-open-theme">Open</button>
126
+ <input type="text" class="setup__input setup__input--wide" id="import-url" placeholder="https://github.com/user/repo or Lovable URL" />
127
+ <button class="btn btn--primary" id="import-btn">Import</button>
119
128
  </div>
129
+ <p class="setup__hint">Convert a React or Lovable project to a HubSpot theme</p>
120
130
  </div>
121
131
  </div>
122
132
 
@@ -361,9 +371,14 @@
361
371
  </div>
362
372
  </div>
363
373
  <div class="chat__input-area">
374
+ <div class="chat__file-chips" id="file-chips"></div>
364
375
  <div class="chat__input-wrapper">
365
376
  <textarea class="chat__input" id="chat-input" placeholder="Describe your landing page..." rows="1"></textarea>
366
377
  <div class="chat__input-actions">
378
+ <button class="chat__input-icon" id="btn-attach-file" title="Attach files (images, PDFs, docs)">
379
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
380
+ </button>
381
+ <input type="file" id="file-input" multiple accept="image/png,image/jpeg,image/svg+xml,image/webp,image/gif,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/markdown,text/plain" style="display:none">
367
382
  <button class="chat__input-icon" id="btn-starter-templates" title="Templates">
368
383
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
369
384
  </button>
@@ -372,6 +387,12 @@
372
387
  </button>
373
388
  </div>
374
389
  </div>
390
+ <div class="chat__drop-overlay hidden" id="drop-overlay">
391
+ <div class="chat__drop-overlay-content">
392
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
393
+ <span>Drop files here</span>
394
+ </div>
395
+ </div>
375
396
  </div>
376
397
  </div>
377
398
  </aside>
@@ -430,6 +451,12 @@
430
451
  <h2 class="settings__title">Settings</h2>
431
452
  <button class="settings__close" id="settings-close">&times;</button>
432
453
  </div>
454
+ <div class="settings__tabs" id="settings-tabs">
455
+ <button class="settings__tab active" data-tab="ai">AI</button>
456
+ <button class="settings__tab" data-tab="hubspot">HubSpot</button>
457
+ <button class="settings__tab" data-tab="github">GitHub</button>
458
+ <button class="settings__tab" data-tab="vibespot">vibeSpot</button>
459
+ </div>
433
460
  <div class="settings__body" id="settings-body">
434
461
  <!-- Populated dynamically by settings.js -->
435
462
  <div class="settings__loading">