vibespot 1.7.7 → 1.8.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/README.md +11 -0
- package/assets/whats-new.json +27 -0
- package/dist/index.js +482 -648
- package/dist/index.js.map +1 -1
- package/package.json +7 -4
- package/ui/chat.js +21 -8
- package/ui/dashboard.js +53 -12
- package/ui/docs/index.html +10 -1
- package/ui/email-preview.js +1 -5
- package/ui/escape-html.js +14 -0
- package/ui/field-editor.js +6 -12
- package/ui/field-save.js +82 -0
- package/ui/index.html +6 -3
- package/ui/inline-edit.js +116 -570
- package/ui/plan.js +19 -13
- package/ui/preview-agent.js +1050 -0
- package/ui/preview.js +248 -265
- package/ui/section-controls.js +16 -622
- package/ui/setup.js +73 -20
- package/ui/styles.css +177 -0
- package/ui/upload-panel.js +7 -8
- package/ui/whats-new.js +249 -0
- package/assets/readme/00-hero-banner.png +0 -0
- package/assets/readme/00-hero-banner.svg +0 -59
- package/assets/readme/01-vibe-coding-hero.png +0 -0
- package/assets/readme/02-checkpoints.png +0 -0
- package/assets/readme/02-plan-mode.png +0 -0
- package/assets/readme/03-figma-import.png +0 -0
- package/assets/readme/04-multi-page-sites.png +0 -0
- package/assets/readme/05-inline-wysiwyg.png +0 -0
- package/assets/readme/06-hubspot-upload.png +0 -0
- package/ui/docs/screenshots/brand-kit-preview.png +0 -0
- package/ui/docs/screenshots/checkpoint-card.png +0 -0
- package/ui/docs/screenshots/content-type-dropdown.png +0 -0
- package/ui/docs/screenshots/editor-full-layout.png +0 -0
- package/ui/docs/screenshots/inline-wysiwyg-editing.png +0 -0
- package/ui/docs/screenshots/module-overview-slideout.png +0 -0
- package/ui/docs/screenshots/multi-page-tree.png +0 -0
- package/ui/docs/screenshots/onboarding-walkthrough.png +0 -0
- package/ui/docs/screenshots/split-pane-view.png +0 -0
- package/ui/docs/screenshots/visual-controls-toolbar.png +0 -0
- package/ui/docs/screenshots/workspace-tabs.png +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibespot",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "AI-powered HubSpot CMS landing page builder
|
|
3
|
+
"version": "1.8.0",
|
|
4
|
+
"description": "AI-powered HubSpot CMS landing page builder — vibe coding & React converter",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"vibespot": "bin/vibespot.mjs"
|
|
@@ -11,12 +11,15 @@
|
|
|
11
11
|
"dist",
|
|
12
12
|
"bin",
|
|
13
13
|
"assets",
|
|
14
|
+
"!assets/readme/**",
|
|
14
15
|
"ui",
|
|
16
|
+
"!ui/docs/screenshots/**",
|
|
15
17
|
"starters"
|
|
16
18
|
],
|
|
17
19
|
"scripts": {
|
|
18
20
|
"dev": "tsx src/index.ts",
|
|
19
|
-
"build": "tsup",
|
|
21
|
+
"build": "npm run whatsnew:gen && tsup",
|
|
22
|
+
"whatsnew:gen": "tsx scripts/gen-whats-new.ts",
|
|
20
23
|
"typecheck": "tsc --noEmit",
|
|
21
24
|
"test": "vitest run",
|
|
22
25
|
"test:watch": "vitest",
|
|
@@ -27,7 +30,6 @@
|
|
|
27
30
|
"prompts:seed": "tsx scripts/sync-prompts.ts --from-local",
|
|
28
31
|
"prompts:push": "tsx scripts/sync-prompts.ts --push",
|
|
29
32
|
"prepublishOnly": "npm run build",
|
|
30
|
-
"contact-monitor": "tsx scripts/contact-monitor.ts",
|
|
31
33
|
"docker:publish": "scripts/docker-publish.sh",
|
|
32
34
|
"docker:publish:multi": "scripts/docker-publish.sh --multi-arch",
|
|
33
35
|
"docker:smoke": "scripts/docker-publish.sh --smoke --dry-run"
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
"@types/busboy": "^1.5.4",
|
|
56
58
|
"@types/node": "^25.6.0",
|
|
57
59
|
"@types/ws": "^8.18.1",
|
|
60
|
+
"jsdom": "^29.1.1",
|
|
58
61
|
"tsup": "^8.4.0",
|
|
59
62
|
"tsx": "^4.19.0",
|
|
60
63
|
"typescript": "^6.0.3",
|
package/ui/chat.js
CHANGED
|
@@ -12,6 +12,10 @@ let isStreaming = false;
|
|
|
12
12
|
// which a new message barges in to cancel-and-replan (VIB-1880). False when
|
|
13
13
|
// parked at a checkpoint gate or running single-call mode.
|
|
14
14
|
let agenticRunning = false;
|
|
15
|
+
// True while a checkpoint gate card is parked awaiting the user's decision.
|
|
16
|
+
// finishStreaming() clears isStreaming at the gate, so this flag is what
|
|
17
|
+
// actually locks the composer (VIB-1898).
|
|
18
|
+
let checkpointGateActive = false;
|
|
15
19
|
// Softened barge-in (VIB-1876): a message sent during an active build is QUEUED
|
|
16
20
|
// by default and dispatched when the build finishes. The queued chip carries an
|
|
17
21
|
// "Interrupt now" button that triggers the old cancel-and-replan barge-in.
|
|
@@ -750,6 +754,9 @@ function handleWsMessage(msg) {
|
|
|
750
754
|
resetPipelineState();
|
|
751
755
|
appendAssistantError(msg.message);
|
|
752
756
|
setStatus("Error");
|
|
757
|
+
// The run this message was queued behind is over — dispatch it rather
|
|
758
|
+
// than stranding the chip forever (VIB-1898).
|
|
759
|
+
flushQueuedMessage();
|
|
753
760
|
break;
|
|
754
761
|
|
|
755
762
|
case "pong":
|
|
@@ -1785,6 +1792,10 @@ async function sendMessage(text, opts = {}) {
|
|
|
1785
1792
|
const hasFiles = pendingFiles.length > 0;
|
|
1786
1793
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
1787
1794
|
if (!text.trim() && !hasFiles) return;
|
|
1795
|
+
// A parked checkpoint gate must be resolved through its card. Only the send
|
|
1796
|
+
// buttons were locked before — Enter still reached here (the streaming flags
|
|
1797
|
+
// are reset at the gate) and started a second run mid-park (VIB-1898).
|
|
1798
|
+
if (checkpointGateActive) return;
|
|
1788
1799
|
// Block sends while busy — EXCEPT during an active agentic build. There, a new
|
|
1789
1800
|
// message is QUEUED by default and runs when the build finishes (VIB-1876);
|
|
1790
1801
|
// an explicit Interrupt (opts.interrupt) triggers the cancel-and-replan
|
|
@@ -2078,6 +2089,9 @@ function interruptWithQueued() {
|
|
|
2078
2089
|
// Dispatch the queued message once the current build has finished.
|
|
2079
2090
|
function flushQueuedMessage() {
|
|
2080
2091
|
if (!queuedMessage) return;
|
|
2092
|
+
// A parked gate blocks sendMessage — keep the message queued; the gate's
|
|
2093
|
+
// resolution path flushes again (VIB-1898).
|
|
2094
|
+
if (checkpointGateActive) return;
|
|
2081
2095
|
const { text, opts } = queuedMessage;
|
|
2082
2096
|
clearQueuedMessage();
|
|
2083
2097
|
// Defer so the just-finished run's UI settles before the next one starts.
|
|
@@ -2122,6 +2136,7 @@ function supersedeCurrentRun() {
|
|
|
2122
2136
|
}
|
|
2123
2137
|
}
|
|
2124
2138
|
removeCheckpointCard();
|
|
2139
|
+
checkpointGateActive = false;
|
|
2125
2140
|
isStreaming = false;
|
|
2126
2141
|
agenticRunning = false;
|
|
2127
2142
|
streamingMsgEl = null;
|
|
@@ -2257,13 +2272,7 @@ function renderMarkdown(text) {
|
|
|
2257
2272
|
// Helpers
|
|
2258
2273
|
// ---------------------------------------------------------------------------
|
|
2259
2274
|
|
|
2260
|
-
|
|
2261
|
-
return str
|
|
2262
|
-
.replace(/&/g, "&")
|
|
2263
|
-
.replace(/</g, "<")
|
|
2264
|
-
.replace(/>/g, ">")
|
|
2265
|
-
.replace(/"/g, """);
|
|
2266
|
-
}
|
|
2275
|
+
// escapeHtml comes from the shared ui/escape-html.js (VIB-1902).
|
|
2267
2276
|
|
|
2268
2277
|
function scrollToBottom() {
|
|
2269
2278
|
if (scrollScheduled) return;
|
|
@@ -2540,13 +2549,16 @@ function handleCheckpointRequested(msg) {
|
|
|
2540
2549
|
messagesEl.appendChild(checkpointCardEl);
|
|
2541
2550
|
scrollToBottom();
|
|
2542
2551
|
|
|
2543
|
-
// Hold the
|
|
2552
|
+
// Hold the composer until the gate is resolved — buttons AND the Enter path
|
|
2553
|
+
// through sendMessage (VIB-1898).
|
|
2554
|
+
checkpointGateActive = true;
|
|
2544
2555
|
setSendDisabled(true);
|
|
2545
2556
|
setStatus("Awaiting your call");
|
|
2546
2557
|
}
|
|
2547
2558
|
|
|
2548
2559
|
function handleCheckpointCancelled() {
|
|
2549
2560
|
removeCheckpointCard();
|
|
2561
|
+
checkpointGateActive = false;
|
|
2550
2562
|
setSendDisabled(false);
|
|
2551
2563
|
appendSystemMessage("Cancelled — nothing was built.");
|
|
2552
2564
|
flushQueuedMessage();
|
|
@@ -2554,6 +2566,7 @@ function handleCheckpointCancelled() {
|
|
|
2554
2566
|
|
|
2555
2567
|
function resolveCheckpoint(action, note, extra) {
|
|
2556
2568
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
2569
|
+
checkpointGateActive = false;
|
|
2557
2570
|
// Leave the decision in the transcript (card → one-line record), not an empty
|
|
2558
2571
|
// bubble (VIB-1876).
|
|
2559
2572
|
finalizeCheckpointCard(action, note, extra);
|
package/ui/dashboard.js
CHANGED
|
@@ -78,9 +78,16 @@ function hideDashboard() {
|
|
|
78
78
|
// ---------------------------------------------------------------------------
|
|
79
79
|
|
|
80
80
|
async function refreshDashboard() {
|
|
81
|
+
// Guard against a stale in-flight response rendering after the user
|
|
82
|
+
// switched to another theme (or left the dashboard) mid-fetch.
|
|
83
|
+
const themeAtStart = currentDashboardTheme;
|
|
84
|
+
const sessionAtStart = currentDashboardSessionId;
|
|
81
85
|
try {
|
|
82
86
|
const res = await fetch("/api/dashboard");
|
|
83
87
|
const data = await res.json();
|
|
88
|
+
if (currentDashboardTheme !== themeAtStart || currentDashboardSessionId !== sessionAtStart) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
84
91
|
if (data.error) {
|
|
85
92
|
console.warn("Dashboard load error:", data.error);
|
|
86
93
|
return;
|
|
@@ -673,7 +680,9 @@ document.getElementById("dashboard-brand-assets")?.addEventListener("change", (e
|
|
|
673
680
|
if (e.target.type !== "file") return;
|
|
674
681
|
const card = e.target.closest(".brand-asset-card");
|
|
675
682
|
if (!card || !e.target.files[0]) return;
|
|
676
|
-
|
|
683
|
+
const file = e.target.files[0];
|
|
684
|
+
e.target.value = ""; // allow re-selecting the same file after an error
|
|
685
|
+
handleBrandFileSelected(card.dataset.asset, file);
|
|
677
686
|
});
|
|
678
687
|
|
|
679
688
|
// ---------------------------------------------------------------------------
|
|
@@ -785,27 +794,43 @@ function updateBrandPreview() {
|
|
|
785
794
|
const logoImg = document.getElementById("brand-preview-logo");
|
|
786
795
|
const logoPlaceholder = document.getElementById("brand-preview-logo-placeholder");
|
|
787
796
|
if (logoImg && logoPlaceholder) {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
797
|
+
const safeLogoUrl = logoUrl ? safeImageUrl(logoUrl) : null;
|
|
798
|
+
if (safeLogoUrl) {
|
|
799
|
+
// Attach handlers before setting src so a fast (cached) result can't
|
|
800
|
+
// race past them and wedge the preview in a stale state.
|
|
792
801
|
logoImg.onerror = () => {
|
|
793
802
|
logoImg.hidden = true;
|
|
803
|
+
logoImg.removeAttribute("src");
|
|
794
804
|
logoPlaceholder.hidden = false;
|
|
795
805
|
logoPlaceholder.textContent = "Bad URL";
|
|
796
806
|
};
|
|
797
807
|
logoImg.onload = () => {
|
|
808
|
+
logoImg.hidden = false;
|
|
809
|
+
logoPlaceholder.hidden = true;
|
|
798
810
|
logoPlaceholder.textContent = "No logo";
|
|
799
811
|
};
|
|
812
|
+
logoImg.src = safeLogoUrl;
|
|
813
|
+
logoImg.hidden = false;
|
|
814
|
+
logoPlaceholder.hidden = true;
|
|
800
815
|
} else {
|
|
801
816
|
logoImg.hidden = true;
|
|
802
817
|
logoImg.removeAttribute("src");
|
|
803
818
|
logoPlaceholder.hidden = false;
|
|
804
|
-
logoPlaceholder.textContent = "No logo";
|
|
819
|
+
logoPlaceholder.textContent = logoUrl ? "Bad URL" : "No logo";
|
|
805
820
|
}
|
|
806
821
|
}
|
|
807
822
|
}
|
|
808
823
|
|
|
824
|
+
// Only http(s) URLs (or paths that resolve to them) may reach img.src —
|
|
825
|
+
// blocks javascript:, data:, file: etc. from the free-text logo field.
|
|
826
|
+
function safeImageUrl(url) {
|
|
827
|
+
try {
|
|
828
|
+
const parsed = new URL(url, window.location.href);
|
|
829
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:") return parsed.href;
|
|
830
|
+
} catch { /* unparseable */ }
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
|
|
809
834
|
for (const id of [
|
|
810
835
|
"bk-hex-primary", "bk-hex-secondary", "bk-hex-accent",
|
|
811
836
|
"bk-color-primary", "bk-color-secondary", "bk-color-accent",
|
|
@@ -1026,15 +1051,31 @@ async function uploadBrandAsset(type) {
|
|
|
1026
1051
|
fileInput.click();
|
|
1027
1052
|
}
|
|
1028
1053
|
|
|
1054
|
+
// Brand assets are prose fed into AI prompts — 1 MB of markdown is already
|
|
1055
|
+
// far beyond useful; anything bigger is a mis-picked file.
|
|
1056
|
+
const MAX_BRAND_ASSET_BYTES = 1024 * 1024;
|
|
1057
|
+
|
|
1029
1058
|
async function handleBrandFileSelected(type, file) {
|
|
1030
|
-
|
|
1059
|
+
if (file.size > MAX_BRAND_ASSET_BYTES) {
|
|
1060
|
+
await vibeAlert(
|
|
1061
|
+
`File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Brand assets are text files up to 1 MB.`,
|
|
1062
|
+
"Error",
|
|
1063
|
+
);
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
if (!/\.(md|markdown|txt)$/i.test(file.name || "")) {
|
|
1067
|
+
await vibeAlert("Only .md and .txt files are supported.", "Error");
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1031
1070
|
|
|
1032
1071
|
try {
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1072
|
+
// Multipart keeps the file out of a JSON string (no UTF-16/binary
|
|
1073
|
+
// mangling, no full-file JSON.stringify); the server enforces the same
|
|
1074
|
+
// size cap and rejects non-text content.
|
|
1075
|
+
const form = new FormData();
|
|
1076
|
+
form.append("type", type);
|
|
1077
|
+
form.append("file", file, file.name);
|
|
1078
|
+
const res = await fetch("/api/brand-assets", { method: "POST", body: form });
|
|
1038
1079
|
const data = await res.json();
|
|
1039
1080
|
if (data.error) {
|
|
1040
1081
|
await vibeAlert(data.error, "Error");
|
package/ui/docs/index.html
CHANGED
|
@@ -372,9 +372,14 @@ vibespot</code></pre>
|
|
|
372
372
|
<tr><td><code>langfusePublicKey</code></td><td>string</td><td>Langfuse public key (<code>pk-lf-</code>)</td></tr>
|
|
373
373
|
<tr><td><code>langfuseSecretKey</code></td><td>string</td><td>Langfuse secret key (<code>sk-lf-</code>)</td></tr>
|
|
374
374
|
<tr><td><code>langfuseBaseUrl</code></td><td>string</td><td>Langfuse ingestion endpoint (default: <code>https://cloud.langfuse.com</code>)</td></tr>
|
|
375
|
+
<tr><td><code>lastSeenVersion</code></td><td>string</td><td>The version whose release notes you last dismissed in the <a href="#whats-new">What's new</a> dialog. Set automatically on dismiss; the dialog reappears when you upgrade to a newer version.</td></tr>
|
|
375
376
|
</tbody>
|
|
376
377
|
</table>
|
|
377
378
|
|
|
379
|
+
<h3 id="whats-new">What's new dialog</h3>
|
|
380
|
+
<p>The first time you open the builder after upgrading, a dialog shows the highlights of the new version with a link to the full changelog. Dismiss it — with the <strong>×</strong>, <strong>Got it</strong>, a click outside, or <kbd>Esc</kbd> — and it stays hidden until the next release. One card per version, never a repeat nag.</p>
|
|
381
|
+
<p>The highlights are pulled straight from <a href="https://github.com/borismichel/vibespot/blob/main/CHANGELOG.md"><code>CHANGELOG.md</code></a> at build time, so every release shows its own notes. Your dismissal is remembered per machine via <code>lastSeenVersion</code> in <code>~/.vibespot/config.json</code>.</p>
|
|
382
|
+
|
|
378
383
|
<h3 id="env-vars">Environment Variables</h3>
|
|
379
384
|
<p>API keys and server settings can be set as environment variables. The config file takes precedence — environment variables are used as fallbacks when no key is set in the config. In Docker deployments, env vars are the primary configuration method (see <a href="#docker-deployment">Docker Deployment</a>).</p>
|
|
380
385
|
<table>
|
|
@@ -394,6 +399,10 @@ vibespot</code></pre>
|
|
|
394
399
|
<tr><td><code>VIBESPOT_AGENTIC_MODE</code></td><td>Set <code>true</code> to enable the multi-stage agentic pipeline</td></tr>
|
|
395
400
|
<tr><td><code>VIBESPOT_PORT</code></td><td>Override the HTTP server port (default: <code>4200</code>)</td></tr>
|
|
396
401
|
<tr><td><code>VIBESPOT_NO_OPEN</code></td><td>Set <code>1</code> to suppress auto-opening the browser on startup</td></tr>
|
|
402
|
+
<tr><td><code>VIBESPOT_HOST</code></td><td>Bind address (default: <code>127.0.0.1</code>, loopback only). Set <code>0.0.0.0</code> to reach vibeSpot from another device — a non-loopback bind requires the access token printed at boot (see "Access from another device" in the README)</td></tr>
|
|
403
|
+
<tr><td><code>VIBESPOT_AUTH_TOKEN</code></td><td>Pin the shared-secret access token used on non-loopback binds (otherwise a fresh one is generated at boot and printed in the startup URL)</td></tr>
|
|
404
|
+
<tr><td><code>VIBESPOT_DISABLE_AUTH</code></td><td>Set <code>1</code> to disable the auth gate. Only for deployments behind their own authenticating proxy; on a non-loopback bind this additionally requires <code>VIBESPOT_TRUST_PROXY=1</code></td></tr>
|
|
405
|
+
<tr><td><code>VIBESPOT_TRUST_PROXY</code></td><td>Set <code>1</code> to acknowledge that an authenticating reverse proxy is the sole ingress, allowing <code>VIBESPOT_DISABLE_AUTH=1</code> on a non-loopback bind</td></tr>
|
|
397
406
|
<tr><td><code>LANGFUSE_ENABLED</code></td><td>Set <code>true</code> to send <a href="#langfuse">Langfuse traces</a>. Required — keys alone don't enable it.</td></tr>
|
|
398
407
|
<tr><td><code>LANGFUSE_PUBLIC_KEY</code></td><td>Langfuse public key (<code>pk-lf-</code>)</td></tr>
|
|
399
408
|
<tr><td><code>LANGFUSE_SECRET_KEY</code></td><td>Langfuse secret key (<code>sk-lf-</code>)</td></tr>
|
|
@@ -988,7 +997,7 @@ testimonial carousel, and a minimal footer with social links.</code></pre>
|
|
|
988
997
|
<p>Each card also shows the <strong>estimated cost of the build it gates</strong>, computed from spend-so-far via the per-page <a href="#observability">cost tracker</a> — so you know what Approve will cost before you click it. (CLI engines report no usage, so the estimate is omitted for them.)</p>
|
|
989
998
|
<div class="doc-callout doc-callout--tip">
|
|
990
999
|
<div class="doc-callout__label">🔌 Crash-safe by design</div>
|
|
991
|
-
<p>A parked checkpoint isn't a paused, open promise — the pipeline <em>returns</em> at the gate and your resolution re-enters it via a resume token.
|
|
1000
|
+
<p>A parked checkpoint isn't a paused, open promise — the pipeline <em>returns</em> at the gate and your resolution re-enters it via a resume token. The resume state is persisted with the session, so an open gate even survives a server restart: reopen the theme and the parked card is still there — approve, steer, or skip it and the run continues where it left off.</p>
|
|
992
1001
|
</div>
|
|
993
1002
|
|
|
994
1003
|
<h3 id="checkpoints-oneshot">One-Shot Escape Hatch</h3>
|
package/ui/email-preview.js
CHANGED
|
@@ -38,11 +38,7 @@
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
var div = document.createElement("div");
|
|
43
|
-
div.textContent = str;
|
|
44
|
-
return div.innerHTML;
|
|
45
|
-
}
|
|
41
|
+
// escapeHtml comes from the shared ui/escape-html.js (VIB-1902).
|
|
46
42
|
|
|
47
43
|
function openEmailPreview() {
|
|
48
44
|
overlay.classList.remove("hidden");
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTML escaper (VIB-1902). Loaded before every script that
|
|
3
|
+
* interpolates untrusted strings into innerHTML — chat.js, plan.js and
|
|
4
|
+
* email-preview.js previously each carried their own copy (the
|
|
5
|
+
* email-preview one didn't escape quotes, so it was unsafe in attribute
|
|
6
|
+
* context). One definition, attribute-safe, used everywhere.
|
|
7
|
+
*/
|
|
8
|
+
function escapeHtml(str) {
|
|
9
|
+
return String(str)
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, """);
|
|
14
|
+
}
|
package/ui/field-editor.js
CHANGED
|
@@ -39,7 +39,9 @@ async function openFieldEditor(moduleName) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function closeFieldEditor() {
|
|
42
|
-
|
|
42
|
+
// Fire (not drop) any debounced edit still pending — closing the editor
|
|
43
|
+
// right after typing must not lose the change (VIB-1898).
|
|
44
|
+
flushPendingFieldSaves();
|
|
43
45
|
currentEditModule = null;
|
|
44
46
|
if (typeof showModuleListView === "function") {
|
|
45
47
|
showModuleListView();
|
|
@@ -281,18 +283,10 @@ function createFieldInput(field, moduleName, fullPath) {
|
|
|
281
283
|
// Update field and refresh preview
|
|
282
284
|
// ---------------------------------------------------------------------------
|
|
283
285
|
|
|
284
|
-
let updateTimer = null;
|
|
285
|
-
|
|
286
286
|
function updateField(moduleName, fieldPath, value) {
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
fetch("/api/field", {
|
|
291
|
-
method: "POST",
|
|
292
|
-
headers: { "Content-Type": "application/json" },
|
|
293
|
-
body: JSON.stringify({ moduleName, fieldPath, value }),
|
|
294
|
-
}).then(() => refreshPreview());
|
|
295
|
-
}, 300);
|
|
287
|
+
// Debounced per field via the shared save module (field-save.js) — editing
|
|
288
|
+
// a second field no longer cancels the first field's save (VIB-1898).
|
|
289
|
+
saveFieldDebounced(moduleName, fieldPath, value);
|
|
296
290
|
}
|
|
297
291
|
|
|
298
292
|
// ---------------------------------------------------------------------------
|
package/ui/field-save.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified /api/field save path (VIB-1898).
|
|
3
|
+
*
|
|
4
|
+
* All three field-editing surfaces persist through this module — the field
|
|
5
|
+
* editor sidebar (field-editor.js), inline interact-mode edits
|
|
6
|
+
* (inline-edit.js) and the section-controls commits (section-controls.js).
|
|
7
|
+
* The debounce is keyed PER FIELD, so editing a second field never cancels
|
|
8
|
+
* the first field's pending save, and saves to the same field are chained so
|
|
9
|
+
* an earlier POST can't land after a later one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** key -> { timer, moduleName, fieldPath, value, opts } */
|
|
13
|
+
const fieldSavePending = new Map();
|
|
14
|
+
/** key -> tail of that field's in-flight POST chain */
|
|
15
|
+
const fieldSaveChains = new Map();
|
|
16
|
+
const FIELD_SAVE_DEBOUNCE_MS = 300;
|
|
17
|
+
|
|
18
|
+
function fieldSaveKey(moduleName, fieldPath) {
|
|
19
|
+
return `${moduleName}\u0000${fieldPath}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Save one field value now.
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} [opts]
|
|
26
|
+
* @param {boolean} [opts.refresh=true] Refresh the preview after the POST.
|
|
27
|
+
* @returns {Promise<void>} resolves after the POST (and refresh) settle.
|
|
28
|
+
*/
|
|
29
|
+
function saveField(moduleName, fieldPath, value, opts = {}) {
|
|
30
|
+
const key = fieldSaveKey(moduleName, fieldPath);
|
|
31
|
+
// An immediate save supersedes any older debounced value for the field.
|
|
32
|
+
const pending = fieldSavePending.get(key);
|
|
33
|
+
if (pending) {
|
|
34
|
+
clearTimeout(pending.timer);
|
|
35
|
+
fieldSavePending.delete(key);
|
|
36
|
+
}
|
|
37
|
+
const tail = fieldSaveChains.get(key) || Promise.resolve();
|
|
38
|
+
const post = tail
|
|
39
|
+
.then(() =>
|
|
40
|
+
fetch("/api/field", {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
body: JSON.stringify({ moduleName, fieldPath, value }),
|
|
44
|
+
})
|
|
45
|
+
)
|
|
46
|
+
.then(() => {
|
|
47
|
+
if (opts.refresh !== false) refreshPreview();
|
|
48
|
+
})
|
|
49
|
+
.catch(() => {
|
|
50
|
+
/* field save failed — preview keeps its optimistic state */
|
|
51
|
+
});
|
|
52
|
+
fieldSaveChains.set(key, post);
|
|
53
|
+
post.then(() => {
|
|
54
|
+
if (fieldSaveChains.get(key) === post) fieldSaveChains.delete(key);
|
|
55
|
+
});
|
|
56
|
+
return post;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Debounced save, keyed per field — pending edits to OTHER fields are
|
|
61
|
+
* untouched. Same opts as saveField, plus opts.debounceMs.
|
|
62
|
+
*/
|
|
63
|
+
function saveFieldDebounced(moduleName, fieldPath, value, opts = {}) {
|
|
64
|
+
const key = fieldSaveKey(moduleName, fieldPath);
|
|
65
|
+
const prev = fieldSavePending.get(key);
|
|
66
|
+
if (prev) clearTimeout(prev.timer);
|
|
67
|
+
const entry = { moduleName, fieldPath, value, opts };
|
|
68
|
+
entry.timer = setTimeout(() => {
|
|
69
|
+
fieldSavePending.delete(key);
|
|
70
|
+
saveField(moduleName, fieldPath, value, opts);
|
|
71
|
+
}, opts.debounceMs ?? FIELD_SAVE_DEBOUNCE_MS);
|
|
72
|
+
fieldSavePending.set(key, entry);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Fire every pending debounced save immediately (e.g. the editor closes). */
|
|
76
|
+
function flushPendingFieldSaves() {
|
|
77
|
+
for (const [key, entry] of Array.from(fieldSavePending)) {
|
|
78
|
+
clearTimeout(entry.timer);
|
|
79
|
+
fieldSavePending.delete(key);
|
|
80
|
+
saveField(entry.moduleName, entry.fieldPath, entry.value, entry.opts);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/ui/index.html
CHANGED
|
@@ -889,7 +889,7 @@
|
|
|
889
889
|
<button class="btn btn--danger btn--sm dashboard__module-preview-delete" id="dashboard-preview-delete" type="button">Delete module</button>
|
|
890
890
|
<button class="dashboard__module-preview-close" id="dashboard-preview-close" type="button" aria-label="Close preview">×</button>
|
|
891
891
|
</div>
|
|
892
|
-
<iframe class="dashboard__module-preview-frame" id="dashboard-preview-frame" sandbox="allow-scripts
|
|
892
|
+
<iframe class="dashboard__module-preview-frame" id="dashboard-preview-frame" sandbox="allow-scripts" title="Module preview"></iframe>
|
|
893
893
|
</div>
|
|
894
894
|
</section>
|
|
895
895
|
|
|
@@ -952,7 +952,7 @@
|
|
|
952
952
|
</div>
|
|
953
953
|
<div class="email-preview-panel__notes" id="email-preview-notes"></div>
|
|
954
954
|
<div class="email-preview-panel__body">
|
|
955
|
-
<iframe class="email-preview-panel__frame" id="email-preview-frame" sandbox="allow-scripts
|
|
955
|
+
<iframe class="email-preview-panel__frame" id="email-preview-frame" sandbox="allow-scripts" title="Email client preview"></iframe>
|
|
956
956
|
</div>
|
|
957
957
|
</div>
|
|
958
958
|
</div>
|
|
@@ -997,8 +997,9 @@
|
|
|
997
997
|
<script src="/vendor/marked.umd.js"></script>
|
|
998
998
|
<script src="/vendor/codemirror-bundle.global.js"></script>
|
|
999
999
|
<script src="/icons.js"></script>
|
|
1000
|
+
<script src="/escape-html.js"></script>
|
|
1000
1001
|
<script src="/dialog.js"></script>
|
|
1001
|
-
<script src="/setup.js?v=
|
|
1002
|
+
<script src="/setup.js?v=112"></script>
|
|
1002
1003
|
<script src="/dashboard.js"></script>
|
|
1003
1004
|
<script src="/settings.js"></script>
|
|
1004
1005
|
<script src="/upload-panel.js"></script>
|
|
@@ -1006,10 +1007,12 @@
|
|
|
1006
1007
|
<script src="/chat.js?v=109"></script>
|
|
1007
1008
|
<script src="/plan.js"></script>
|
|
1008
1009
|
<script src="/preview.js"></script>
|
|
1010
|
+
<script src="/field-save.js"></script>
|
|
1009
1011
|
<script src="/inline-edit.js"></script>
|
|
1010
1012
|
<script src="/field-editor.js"></script>
|
|
1011
1013
|
<script src="/code-editor.js"></script>
|
|
1012
1014
|
<script src="/email-preview.js"></script>
|
|
1013
1015
|
<script src="/section-controls.js"></script>
|
|
1016
|
+
<script src="/whats-new.js"></script>
|
|
1014
1017
|
</body>
|
|
1015
1018
|
</html>
|