From af6fe78f3fc1178b5cb3671f68da61fee010f5db Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 10 May 2026 22:57:51 +0200 Subject: [PATCH] feat(web): live planned-XML preview + reset stale form state on device switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for the Plan-card → Customize-pane preview flow: 1. Live planned-XML preview. The Customize panel's "Planned Config (AfterTouch)" pane previously showed summary.planned_config — server-rendered, only updated on the next showSummary fetch. So editing a URL field in the Plan card had no visible effect on the preview until the user manually refreshed. The new renderPlannedXMLPreview composes the same XML client-side from plan-target-url + the four override inputs, mirroring exactly what migrateViaXML writes (target-derived defaults + applyURLOverrides), and is called from validatePlanURLs which already runs on every keystroke. 2. Per-device form-state isolation on speaker switch. The Plan card inputs preserve manual edits across summary refreshes (force=false) so a user's typed URL doesn't get clobbered by a re-fetch. That semantic is right within one device but wrong across devices: if the user edited a URL on speaker A and then picked speaker B in the dropdown, A's value silently appeared in B's preview. showSummary now compares the previous summary-device-id to the new one and, on change, calls resetPlanCardForDeviceSwitch to clear the four URL inputs, the Soundcork checkbox, the "saved" hint dataset, the URL-validation banner, and both apply-status lines. The downstream fillPlanURLInputs(defaults, force=false) then fills the now-empty inputs with the new device's canonical defaults. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/service/handlers/web/js/script.js | 107 ++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index 1262705..9d72fa3 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -1730,9 +1730,22 @@ async function showSummary(deviceId) { const ip = summary.ip_address || deviceId; const finalDisplay = summary.device_name ? `${summary.device_name} (${ip})` : ip; document.getElementById("summary-device-display").innerText = finalDisplay; + + // Detect a device switch BEFORE we clobber the hidden id input. + // When the user moves between speakers in the dropdown, any + // per-device form state (plan-card URL edits, the soundcork + // checkbox, the "saved" hint) belongs to the previous device + // and would otherwise leak into the new device's preview. + const prevDeviceId = document.getElementById("summary-device-id").value; + const deviceChanged = prevDeviceId && prevDeviceId !== deviceId; + // Keep deviceId hidden for subsequent calls document.getElementById("summary-device-id").value = deviceId; + if (deviceChanged) { + resetPlanCardForDeviceSwitch(); + } + // Update table row if it exists const rowId = "device-row-" + deviceId; const row = document.getElementById(rowId); @@ -2590,6 +2603,11 @@ function validatePlanURLs() { applyBtn.disabled = noPlan || errors.length > 0; } + // Refresh the client-side XML preview so the Customize panel's + // Planned Config pane tracks every keystroke without a backend + // round-trip. + renderPlannedXMLPreview(); + return errors.length === 0; } @@ -2603,6 +2621,46 @@ function resetPlanURLsToDefaults() { fillPlanURLInputs(defaultServiceURLs(targetUrl, {soundcorkMode: soundcork}), {force: true}); } +// resetPlanCardForDeviceSwitch clears every per-device form state on +// the Plan card so the previously-edited values don't leak into the +// new device's preview. Called from showSummary the moment the user +// picks a different speaker in the dropdown. +// +// Doesn't refill defaults itself — that happens further down in +// showSummary via fillPlanURLInputs(defaults), which writes only into +// empty inputs. +function resetPlanCardForDeviceSwitch() { + const inputs = ["plan-marge-url", "plan-stats-url", "plan-sw_update-url", "plan-bmx-url"]; + for (const id of inputs) { + const el = document.getElementById(id); + if (el) { + el.value = ""; + el.style.borderColor = ""; + } + } + + const soundcork = document.getElementById("plan-soundcork-mode"); + if (soundcork) soundcork.checked = false; + + const saved = document.getElementById("plan-target-saved"); + if (saved) { + saved.innerText = ""; + delete saved.dataset.savedValue; + } + + const validationBox = document.getElementById("plan-url-validation"); + if (validationBox) { + validationBox.style.display = "none"; + validationBox.replaceChildren(); + } + + const applyStatus = document.getElementById("plan-apply-status"); + if (applyStatus) applyStatus.innerText = ""; + + const customizeStatus = document.getElementById("customize-apply-status"); + if (customizeStatus) customizeStatus.innerText = ""; +} + // toggleSoundcorkMode reapplies defaults so the /marge suffix appears // or disappears on margeServerUrl. Manual edits are intentionally // reset — the checkbox is a deliberate "give me the canonical @@ -2657,6 +2715,55 @@ function computeSuggestedPlan(summary) { }; } +// renderPlannedXMLPreview rewrites the #planned-config
 from the
+// current Plan card inputs so the preview tracks the user's edits live
+// — without a backend round-trip. The output mirrors what
+// migrateViaXML actually writes: target-derived defaults, with each
+// per-field override (marge_url / stats_url / sw_update_url /
+// bmx_url) substituted in. The three boolean fields are constants
+// matching the Go-side PrivateCfg{} the migration constructs.
+//
+// Pure client-side once GetMigrationSummary has populated everything,
+// so the preview updates on every keystroke and stays in sync with
+// validatePlanURLs's input border / Apply-button state.
+function renderPlannedXMLPreview() {
+    const targetUrl = (document.getElementById("plan-target-url") || {}).value || "";
+    const overrides = readPlanURLOptions();
+    const defaults = defaultServiceURLs(targetUrl);
+
+    const marge    = overrides.marge_url     || defaults.marge;
+    const stats    = overrides.stats_url     || defaults.stats;
+    const swUpdate = overrides.sw_update_url || defaults.sw_update;
+    const bmx      = overrides.bmx_url       || defaults.bmx;
+
+    const xml = [
+        '',
+        '',
+        `  ${escapeForXMLText(marge)}`,
+        `  ${escapeForXMLText(stats)}`,
+        `  ${escapeForXMLText(swUpdate)}`,
+        '  true',
+        '  true',
+        '  false',
+        `  ${escapeForXMLText(bmx)}`,
+        '',
+    ].join("\n");
+
+    const el = document.getElementById("planned-config");
+    if (el) el.innerText = xml;
+}
+
+// escapeForXMLText escapes the characters that have special meaning
+// inside XML text content. URLs typically only need & escaping, but
+// covering < and > too keeps the preview honest if the user pastes
+// something exotic.
+function escapeForXMLText(s) {
+    return String(s)
+        .replace(/&/g, "&")
+        .replace(//g, ">");
+}
+
 // renderPlanCurrentURLs populates the "Current on Device" cells in the
 // Service URLs table from whichever transport answered (telnet
 // getpdo, falling back to the SSH-read XML config).