From 660bdc17cf664d7d78ff4f2c4f76dc8e33ddbe52 Mon Sep 17 00:00:00 2001 From: Jason Hoku Date: Tue, 3 Nov 2026 23:58:33 -0800 Subject: [PATCH] feat(dashboard): standalone page mode, own live websocket, open-standalone button html_generator gains a standalone flag (USCG_STANDALONE) and escapes " --- html_generator.py | 10 +- resources/logic_events.js | 3 +- resources/logic_standalone.js | 170 ++++++++++++++++++++++++++++++++++ resources/logic_ui.js | 75 +++++++++++---- resources/logic_utils.js | 24 +++++ resources/template.html | 18 +++- tests/test_standalone_html.py | 79 ++++++++++++++++ 7 files changed, 354 insertions(+), 25 deletions(-) create mode 100644 resources/logic_standalone.js create mode 100644 tests/test_standalone_html.py diff --git a/html_generator.py b/html_generator.py index e8f9ccc..bae1d4f 100644 --- a/html_generator.py +++ b/html_generator.py @@ -1,13 +1,17 @@ import os import json -def get_html_template(title, manifest_data, node_id): +def get_html_template(title, manifest_data, node_id, standalone=False): # 1. Normalize Data if isinstance(manifest_data, list): manifest_data = {"items": manifest_data, "meta": {"model": "", "positive": "", "negative": ""}} # Indent JSON for readability json_str = json.dumps(manifest_data, indent=2) + # The manifest is inlined inside a " would + # end it early (page break / script injection). "<\/" is an equivalent JSON escape, so + # JSON.parse (web/dashboard.js's incremental refresh) still reads the original text. + json_str = json_str.replace(" { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); e.stopPropagation(); - window.parent.postMessage({ type: 'forward_ctrl_s' }, '*'); + // Standalone page: nothing to forward to (edits already auto-save), just keep the browser's Save dialog away. + if (!USCG_STANDALONE) window.parent.postMessage({ type: 'forward_ctrl_s' }, '*'); } }); diff --git a/resources/logic_standalone.js b/resources/logic_standalone.js new file mode 100644 index 0000000..373dc1f --- /dev/null +++ b/resources/logic_standalone.js @@ -0,0 +1,170 @@ +/** + * STANDALONE MODE (dashboard opened as a top-level page: /config_tester/dashboard) + * Everything that talks to the ComfyUI tab (postMessage bridge in web/dashboard.js) is replaced here: + * - live updates: own ComfyUI websocket, re-dispatched as the SAME message shapes the bridge sends, + * so logic_events.js renders them with no duplicated logic + * - "GENERATE NEW" in the lightbox queues through POST /config_tester/run + * - the node iframe's header button opens this page via openStandalone() + * Loaded LAST (after logic_init.js). All behaviour is inside `if (USCG_STANDALONE)`; the node iframe is untouched. + */ + +// Header button (node iframe only): open this dashboard as its own page. +function openStandalone() { + var sess = (document.getElementById('session-input') || {}).value || ''; + var base = document.baseURI; + if (!base || base.indexOf('http') !== 0) { + try { base = window.parent.location.href; } catch (e) { base = ''; } + } + var url; + try { + url = new URL('/config_tester/dashboard' + (sess ? '?session=' + encodeURIComponent(sess) : ''), base).href; + } catch (e) { + alert('Could not work out the dashboard URL: ' + e.message); + return; + } + // No `noopener` feature: it makes window.open() always return null, hiding blocked popups. + var w = window.open(url, '_blank'); + if (w) { + try { w.opener = null; } catch (e) { /* cross-origin guard */ } + } else { + prompt('Open this URL in your browser', url); + } +} + +// Lightbox "GENERATE NEW" without a ComfyUI graph: queue via the run API into the session being viewed. +async function uscgRunViaApi(btn, configOutput) { + var sess = window._uscgLoadedSession || (document.getElementById('session-input') || {}).value || ''; + var original = btn ? btn.innerText : ''; + var flash = function (text, ms) { + if (!btn) return; + btn.innerText = text; + setTimeout(function () { btn.innerText = original || 'GENERATE NEW'; }, ms); + }; + try { + var body = { configs_json: configOutput, force: true }; + if (sess) body.session_name = sess; + var r = await fetch('/config_tester/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + var j = null; + try { j = await r.json(); } catch (e) { /* non-JSON error body */ } + if (!r.ok) { + var msg = j && j.error ? (typeof j.error === 'string' ? j.error : (j.error.message || JSON.stringify(j.error))) : ('HTTP ' + r.status); + console.error('[Revise] /config_tester/run failed:', j); + flash('ERROR: ' + String(msg).slice(0, 60), 3000); + return; + } + console.log('[Revise] queued via /config_tester/run:', j.prompt_id); + flash('QUEUED!', 1000); + } catch (e) { + console.error('[Revise] run API error:', e); + flash('ERROR: ' + (e && e.message ? e.message : e), 3000); + } +} + +if (USCG_STANDALONE) { + (function () { + document.body.classList.add('uscg-standalone'); + + var openBtn = document.getElementById('open-standalone-btn'); + if (openBtn) openBtn.style.display = 'none'; + + var pack = document.getElementById('pack-workflow-checkbox'); + if (pack) { + pack.checked = false; + pack.disabled = true; + pack.title = 'Needs the ComfyUI tab (open the dashboard from the node to pack the workflow)'; + } + + // The session this page currently shows ('' on the landing page). + var titleEl = document.getElementById('session-input'); + window._uscgLoadedSession = (titleEl && titleEl.value) ? titleEl.value : ''; + if (window._uscgLoadedSession) document.title = 'USCG - ' + window._uscgLoadedSession; + + function knownIds() { + var s = new Set(); + var items = (typeof fullManifest !== 'undefined' && fullManifest && fullManifest.items) || []; + for (var i = 0; i < items.length; i++) s.add(items[i].id); + return s; + } + + function dispatch(type, data) { + window.postMessage({ type: type, data: data }, location.origin); + } + + function onEvent(name, data) { + if (!data) return; + var isUpdate = (name === 'ultimate_grid.update' || name === 'ultimate_grid.update_data'); + var isProgress = (name === 'ultimate_grid.progress'); + if (!isUpdate && !isProgress) return; + + // Landing page: follow a newly started session (mirrors the node bridge's "no session loaded" case). + if (!window._uscgLoadedSession) { + if (name === 'ultimate_grid.update' && data.session_name) { + loadSessionFromPicker(data.session_name); + } + return; + } + if (data.session_name !== window._uscgLoadedSession) return; + + if (isProgress) { dispatch('progress_update', data); return; } + + if (data.new_items && data.new_items.length) { + // processNewData does not dedupe: a resume reload plus a late event would double the cards. + var have = knownIds(); + var fresh = data.new_items.filter(function (it) { return !have.has(it.id); }); + if (!fresh.length) return; + var copy = {}; + for (var k in data) copy[k] = data[k]; + copy.new_items = fresh; + dispatch('update_data', copy); + } else if (data.manifest) { + dispatch('update_data', data); + } + } + + // ---- websocket with backoff + resync on resume ------------------------------------- + var ws = null, backoff = 1000, dropped = false, resyncTimer = null; + var clientId = 'uscg-dash-' + Math.random().toString(16).slice(2, 10) + Date.now().toString(16); + + function resync() { + if (!window._uscgLoadedSession) return; + if (resyncTimer) return; // debounce: a reconnect and a visibility change must not both reload + resyncTimer = setTimeout(function () { + resyncTimer = null; + if (navigator.onLine === false) return; + loadSession(); + }, 2000); + } + + function connect() { + var url = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + '/ws?clientId=' + clientId; + try { ws = new WebSocket(url); } catch (e) { schedule(); return; } + ws.onopen = function () { + backoff = 1000; + if (dropped) { dropped = false; resync(); } + }; + ws.onmessage = function (ev) { + if (typeof ev.data !== 'string') return; // binary preview frames + var msg; + try { msg = JSON.parse(ev.data); } catch (e) { return; } + if (msg && msg.type) onEvent(msg.type, msg.data); + }; + ws.onclose = function () { dropped = true; schedule(); }; + ws.onerror = function () { try { ws.close(); } catch (e) { /* already closed */ } }; + } + + function schedule() { + setTimeout(connect, backoff); + backoff = Math.min(backoff * 2, 30000); + } + + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') resync(); + }); + + connect(); + })(); +} diff --git a/resources/logic_ui.js b/resources/logic_ui.js index 31f922c..986bcdb 100644 --- a/resources/logic_ui.js +++ b/resources/logic_ui.js @@ -1493,25 +1493,7 @@ function openM(id) { e.code >= 'Digit1' && e.code <= 'Digit9') { e.preventDefault(); e.stopPropagation(); - const item = activeData ? activeData.find(x => x.id === currentId) : null; - if (item) { - item.favorited = !item.favorited; - if (typeof markItemChanged === 'function') markItemChanged(item); - if (typeof scheduleJSONUpdate === 'function') scheduleJSONUpdate(); - // Visual feedback on the modal image - const mImg = document.getElementById('m-img'); - if (mImg) { - const color = item.favorited ? '#00cc44' : '#cc4444'; - mImg.style.transition = 'box-shadow 0.3s'; - mImg.style.boxShadow = `0 0 30px ${color}`; - setTimeout(() => { mImg.style.boxShadow = ''; }, 400); - } - // Update the navigation counter tooltip if displayed - const counterEl = document.getElementById('modal-nav-counter'); - if (counterEl) { - counterEl.title = item.favorited ? 'Favorited' : 'Not favorited'; - } - } + toggleModalFavorite(); } break; } @@ -1522,6 +1504,61 @@ function openM(id) { _updateModalCounter(); } +// Favorite the item shown in the lightbox (1-9 keys and the phone action bar). +function toggleModalFavorite() { + const item = activeData ? activeData.find(x => x.id === window.currentModalId) : null; + if (!item) return; + item.favorited = !item.favorited; + if (typeof markItemChanged === 'function') markItemChanged(item); + if (typeof scheduleJSONUpdate === 'function') scheduleJSONUpdate(); + // Keep the rendered card's star in sync when the card is in the DOM + const card = (typeof nodeMap !== 'undefined' && nodeMap) ? nodeMap.get(item.id) : null; + const favBtn = card ? card.querySelector('.favorite-btn') : null; + if (favBtn) { + favBtn.classList.toggle('favorited', item.favorited); + favBtn.innerText = item.favorited ? '★' : '☆'; + } + // Visual feedback on the modal image + const mImg = document.getElementById('m-img'); + if (mImg) { + const color = item.favorited ? '#00cc44' : '#cc4444'; + mImg.style.transition = 'box-shadow 0.3s'; + mImg.style.boxShadow = `0 0 30px ${color}`; + setTimeout(() => { mImg.style.boxShadow = ''; }, 400); + } + // Update the navigation counter tooltip if displayed + const counterEl = document.getElementById('modal-nav-counter'); + if (counterEl) { + counterEl.title = item.favorited ? 'Favorited' : 'Not favorited'; + } + const favActionBtn = document.getElementById('modal-fav-btn'); + if (favActionBtn) favActionBtn.innerText = item.favorited ? '★ Favorited' : '☆ Favorite'; +} + +// Lightbox prev/next by delta through the filtered/sorted set (phone action bar). +function modalNav(delta) { + if (!processedData) return; + const idx = processedData.findIndex(x => x.id === window.currentModalId); + const target = processedData[idx + delta]; + if (idx !== -1 && target) openM(target.id); +} + +// Reject the lightbox item, then move on to the neighbour (or close when it was the last one). +function rejectModalItem() { + const item = activeData ? activeData.find(x => x.id === window.currentModalId) : null; + if (!item) return; + item.rejected = true; + if (typeof markItemChanged === 'function') markItemChanged(item); + const idx = processedData ? processedData.findIndex(x => x.id === item.id) : -1; + const next = idx !== -1 ? (processedData[idx + 1] || processedData[idx - 1]) : null; + // Re-run the pipeline after the neighbour is chosen: it drops the rejected item from the grid. + setTimeout(() => { + if (typeof updateDataPipeline === 'function') updateDataPipeline(); + if (typeof scheduleJSONUpdate === 'function') scheduleJSONUpdate(); + }, 100); + if (next && next.id !== item.id) openM(next.id); else closeM(); +} + // Update the modal nav counter display function _updateModalCounter() { const counterEl = document.getElementById('modal-nav-counter'); diff --git a/resources/logic_utils.js b/resources/logic_utils.js index ef736ed..b98cf0e 100644 --- a/resources/logic_utils.js +++ b/resources/logic_utils.js @@ -5,6 +5,14 @@ */ function toggleFullscreen() { + if (USCG_STANDALONE) { + // Top-level page: use the browser Fullscreen API (iOS Safari has no element fullscreen; harmless no-op there). + try { + if (document.fullscreenElement) document.exitFullscreen(); + else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen(); + } catch (e) { /* unsupported */ } + return; + } // Send current session name so fullscreen reload uses the right session var currentSession = document.getElementById('session-input')?.value || ''; window.parent.postMessage({ type: 'toggle_fullscreen', node_id: TARGET_NODE_ID, session_name: currentSession }, '*'); @@ -152,6 +160,13 @@ async function loadSession() { if (landing) landing.style.display = 'none'; if (viewport) viewport.style.display = 'block'; + // Standalone page: keep the URL on the session being shown so F5 / reload-after-delete stay on it. + if (USCG_STANDALONE) { + window._uscgLoadedSession = sess; + try { history.replaceState(null, '', '?session=' + encodeURIComponent(sess)); } catch (e) { /* ignore */ } + document.title = 'USCG - ' + sess; + } + console.log('[Load] ✅ Session loaded successfully'); @@ -287,6 +302,7 @@ async function fetchAndShowSessions() { cardsContainer.innerHTML = ''; sessions.forEach(s => { const card = document.createElement('div'); + card.className = 'session-card'; card.style.cssText = 'width: 180px; background: #2a2a2a; border: 1px solid #444; border-radius: 8px; overflow: hidden; cursor: pointer; transition: border-color 0.2s, transform 0.15s;'; card.onmouseenter = () => { card.style.borderColor = '#0af'; card.style.transform = 'translateY(-2px)'; }; card.onmouseleave = () => { card.style.borderColor = '#444'; card.style.transform = 'none'; }; @@ -783,6 +799,14 @@ async function triggerGen(btn) { console.log("[Revise] Sending config to UltimateSamplerGrid:", config); + // No ComfyUI graph to drive (standalone page, or no parent app): queue through the run API instead. + let hasParentApp = false; + try { hasParentApp = !!(window.parent && window.parent.app); } catch (e) { /* cross-origin parent */ } + if (USCG_STANDALONE || !hasParentApp) { + await uscgRunViaApi(btn, configOutput); + return; + } + try { const app = window.parent.app; const graph = app.graph; diff --git a/resources/template.html b/resources/template.html index d87af90..408f846 100644 --- a/resources/template.html +++ b/resources/template.html @@ -2,6 +2,7 @@ + USCG - __TITLE__