Files
LEDMatrix/web_interface/templates/v3/partials/overview.html
ChuckBuilds 6a93b2c89e fix(web): resolve remaining valid findings from latest review pass
- custom-feeds.js: fix asset-upload contract mismatch (field name "file" ->
  "files", response read from top-level "uploaded_files" not "data.files") -
  same bug already fixed in this file on a separate branch/PR (#420), which
  this branch never received since they're independent PRs off main
- custom-feeds.js: add aria-label to the two icon-only "remove feed" buttons
- custom-feeds.js: move file-input reset into .finally() so a failed upload
  doesn't leave the input stuck holding the file, blocking retry of the
  same file
- app-shell.js: fix executePluginAction(pluginId, actionId) parameter
  order/count mismatch vs. its callers' (actionId, index, pluginId) -
  currently masked by plugins_manager.js's correct version overwriting this
  one at load time (classic vs. deferred script order), but worth fixing
  outright since it's an isolated, self-contained reassignment (not inside
  the Alpine app() object literal) and removes a latent footgun
- overview.html: align Alpine-state resolution with settings-search.js's
  two-tier getAppData() (also check appEl.__x.$data, not just _x_dataStack)

Verified already addressed by earlier passes (no change needed):
plugin_rotation_order validation, DisplayController log prefixes, togglePlugin
returning its promise for install-flow chaining, installedPlugins setter
always updating state, mobile-nav aria-label, toggleSection aria-expanded
sync, PluginOrderList bounded init retries (both display.html and
durations.html), plugin-order-list.js Array.isArray validation, batched
getImageData in the LED-dot preview renderer, app.py exception narrowing/
logging, form-submission log redaction.

Confirmed dead code, skipped (unreachable - zero template/JS callers,
verified via full-repo grep): dotToNested prototype-pollution hardening,
generateFieldHtml HTML-injection hardening, and the HTML-entity-unescape
block in JSON parsing - all three live only inside app-shell.js's two
legacy savePluginConfig implementations (one Alpine-method, one standalone),
neither of which any template or script calls. The real, live plugin-config
path is server-rendered via GET /partials/plugin-config/<id>.

Explicitly NOT reverted: the htmx:afterSwap script-execution listener. An
earlier finding batch asked to remove it as "duplicate" htmx behavior; that
was tried and reverted this session after live testing on hardware proved
it broke every partial whose Alpine x-data depends on an inline <script>
in the same partial (confirmed: WiFi tab hard-failed with "wifiSetup is not
defined"). Removing it again would reintroduce that regression.
2026-07-16 20:31:49 -04:00

527 lines
27 KiB
HTML

<!-- Reconciliation warning banner: shown when startup reconciliation found stale plugin config entries -->
<div id="reconciliation-banner" class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-4 flex items-start" style="display:none !important" role="alert">
<div class="flex-shrink-0 mr-3 mt-0.5">
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-800">Plugin Config Warning</p>
<p class="text-sm text-yellow-700 mt-1" id="reconciliation-banner-text"></p>
</div>
<button type="button" onclick="window.dismissReconciliationBanner()" class="ml-4 flex-shrink-0 text-yellow-500 hover:text-yellow-700" aria-label="Dismiss">
<i class="fas fa-times"></i>
</button>
</div>
<script>
(function () {
var DISMISS_KEY = 'ledmatrix-recon-dismissed';
var _recon_timer = null;
function checkReconciliation() {
fetch('/api/v3/plugins/reconciliation-status')
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data || {};
if (!d.done) {
// Reconciliation still running — poll again shortly
_recon_timer = setTimeout(checkReconciliation, 2000);
return;
}
_recon_timer = null;
if (!d.unresolved || d.unresolved.length === 0) return;
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
var ids = d.unresolved.map(function (i) { return i.plugin_id; }).join(', ');
document.getElementById('reconciliation-banner-text').textContent =
'Stale plugin config entries found: ' + ids +
'. Remove them from config.json or reinstall via the Plugin Store.';
var banner = document.getElementById('reconciliation-banner');
banner.dataset.dismissKey = key;
banner.style.setProperty('display', 'flex', 'important');
})
.catch(function () {});
}
checkReconciliation();
window.dismissReconciliationBanner = function () {
var banner = document.getElementById('reconciliation-banner');
banner.style.setProperty('display', 'none', 'important');
if (_recon_timer !== null) {
clearTimeout(_recon_timer);
_recon_timer = null;
}
// Persist dismissal immediately so the banner won't reappear on reload
// even if the background sync fetch below fails.
var key = banner.dataset.dismissKey;
if (key) {
try { sessionStorage.setItem(DISMISS_KEY, key); } catch (e) {}
}
// Background sync only — do not rely on this for DISMISS_KEY or hiding.
fetch('/api/v3/plugins/reconciliation-status').catch(function () {});
};
}());
</script>
<!-- Getting Started checklist: non-gating, dismissible (localStorage), items
auto-check from existing config/endpoints — no new persisted state.
Known heuristic limits (acceptable, disclosed): values left at legitimate
defaults (e.g. a user actually in Tampa) read as "not done". -->
{% set _hw = main_config.display.hardware if main_config and main_config.display else {} %}
{% set _hw_done = (_hw.rows or 0) > 0 and (_hw.cols or 0) > 0 and (_hw.chain_length or 0) > 0 %}
{% set _loc = main_config.location if main_config and main_config.location else {} %}
{% set _loc_done = (main_config.timezone and main_config.timezone != 'America/New_York')
or (_loc.city and _loc.city != 'Tampa') %}
<div id="getting-started-card" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4" style="display:none" role="region" aria-label="Getting started checklist">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm font-semibold text-blue-900"><i class="fas fa-rocket mr-1"></i>Getting Started</p>
<p class="text-xs text-blue-700 mt-0.5 mb-2">A few steps to get your display up and running. Click a step to jump there, or click its checkbox to mark it done yourself. The card hides once everything is checked.</p>
<ul class="space-y-1 text-sm" id="getting-started-items">
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display">
<i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _loc_done else '0' }}" data-tab="general">
<i class="far fa-square mr-2"></i>Set your timezone and location (General tab)</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="installed" data-tab="plugins">
<i class="far fa-square mr-2"></i>Install a plugin from the Plugin Store</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="enabled" data-tab="plugins">
<i class="far fa-square mr-2"></i>Enable a plugin</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="configured" data-tab="plugins">
<i class="far fa-square mr-2"></i>Configure it (each plugin gets its own tab)</button></li>
</ul>
</div>
<button type="button" onclick="window.dismissGettingStarted()" class="ml-4 flex-shrink-0 text-blue-400 hover:text-blue-600" aria-label="Dismiss getting started checklist">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<script>
(function () {
var KEY = 'ledmatrix-getting-started-dismissed';
var MANUAL_KEY = 'ledmatrix-getting-started-manual';
var card = document.getElementById('getting-started-card');
if (!card) return;
try { if (localStorage.getItem(KEY) === '1') return; } catch (e) {}
card.style.display = 'block';
// Manual per-item overrides: the auto-detection is heuristic (a value
// saved AT its default — e.g. a user genuinely in the default timezone —
// reads as "not done"), so clicking an item's checkbox marks it done by
// hand, persisted per browser.
var manual = {};
try { manual = JSON.parse(localStorage.getItem(MANUAL_KEY) || '{}') || {}; } catch (e) {}
function saveManual() {
try { localStorage.setItem(MANUAL_KEY, JSON.stringify(manual)); } catch (e) {}
}
function setDone(btn, done) {
btn.dataset.done = done ? '1' : '0';
var icon = btn.querySelector('i');
if (icon) { icon.className = done ? 'fas fa-check-square mr-2 text-green-600' : 'far fa-square mr-2'; }
btn.classList.toggle('text-gray-500', done);
btn.classList.toggle('line-through', done);
}
// Once every step is done (auto-detected or manually checked), the card
// has served its purpose — hide it without requiring an explicit dismiss.
function maybeAutoHide() {
var items = card.querySelectorAll('.gs-item');
for (var i = 0; i < items.length; i++) {
if (items[i].dataset.done !== '1') return;
}
card.style.display = 'none';
}
function markDone(btn) {
if (!btn) return;
setDone(btn, true);
maybeAutoHide();
}
// Apply server-derived + manual states, wire deep links (same app-data
// access pattern as settings-search.js). Clicking the checkbox icon
// toggles manual done; clicking the text deep-links to the tab.
Array.prototype.forEach.call(card.querySelectorAll('.gs-item'), function (btn, idx) {
if (manual[idx] === 1) btn.dataset.done = '1';
if (btn.dataset.done === '1') setDone(btn, true);
btn.addEventListener('click', function (ev) {
var icon = btn.querySelector('i');
if (icon && ev.target === icon) {
var nowDone = btn.dataset.done !== '1';
setDone(btn, nowDone);
manual[idx] = nowDone ? 1 : 0;
saveManual();
if (nowDone) maybeAutoHide();
return;
}
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
// Same two-tier resolution as settings-search.js's getAppData():
// _x_dataStack on current Alpine, __x.$data as an older-API fallback.
var data = appEl && ((appEl._x_dataStack && appEl._x_dataStack[0]) ||
(appEl.__x && appEl.__x.$data));
if (data) {
data.activeTab = btn.dataset.tab;
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
}
});
});
maybeAutoHide();
// Plugin-derived states from the existing installed-plugins endpoint.
fetch('/api/v3/plugins/installed')
.then(function (r) { return r.json(); })
.then(function (resp) {
var plugins = (resp.data && resp.data.plugins) || [];
if (plugins.length > 0) markDone(card.querySelector('[data-check="installed"]'));
var enabled = plugins.filter(function (p) { return p.enabled; });
if (enabled.length > 0) {
markDone(card.querySelector('[data-check="enabled"]'));
// "Configured" heuristic: the first enabled plugin has at least
// one saved value that differs from its schema defaults.
var pid = enabled[0].id;
Promise.all([
fetch('/api/v3/plugins/config?plugin_id=' + encodeURIComponent(pid)).then(function (r) { return r.json(); }),
fetch('/api/v3/plugins/schema?plugin_id=' + encodeURIComponent(pid)).then(function (r) { return r.json(); })
]).then(function (res) {
// GET /plugins/config returns the config dict directly in .data
var cfg = res[0].data || {};
var props = (res[1].data && res[1].data.schema && res[1].data.schema.properties) || {};
for (var k in cfg) {
if (k === 'enabled' || !(k in props)) continue;
if (props[k] && 'default' in props[k] &&
JSON.stringify(cfg[k]) !== JSON.stringify(props[k]['default'])) {
markDone(card.querySelector('[data-check="configured"]'));
return;
}
}
}).catch(function () {});
}
})
.catch(function () {});
window.dismissGettingStarted = function () {
card.style.display = 'none';
try { localStorage.setItem(KEY, '1'); } catch (e) {}
};
}());
</script>
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
<p class="mt-1 text-sm text-gray-600">Monitor system status and manage your LED matrix display.</p>
</div>
<!-- System Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-6 mb-8">
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-microchip text-blue-600 text-xl"></i>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">CPU Usage</dt>
<dd class="text-lg font-medium text-gray-900" id="cpu-usage">--%</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-memory text-green-600 text-xl"></i>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Memory Usage</dt>
<dd class="text-lg font-medium text-gray-900" id="memory-usage">--%</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-thermometer-half text-red-600 text-xl"></i>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">CPU Temperature</dt>
<dd class="text-lg font-medium text-gray-900" id="cpu-temp">--°C</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-desktop text-purple-600 text-xl"></i>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Display Status</dt>
<dd class="text-lg font-medium text-gray-900" id="display-status">Unknown</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Version Info -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-code-branch text-blue-600 text-xl mr-3"></i>
<div>
<dt class="text-sm font-medium text-gray-600">LEDMatrix Version</dt>
<dd class="text-lg font-semibold text-gray-900" id="ledmatrix-version">Loading...</dd>
</div>
</div>
<button onclick="checkForUpdates()"
class="inline-flex items-center px-3 py-2 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50">
<i class="fas fa-sync-alt mr-2"></i>
Check Updates
</button>
</div>
</div>
<!-- Quick Actions -->
<div class="border-b border-gray-200 pb-4 mb-6">
<h3 class="text-md font-medium text-gray-900 mb-4">Quick Actions</h3>
<div class="flex flex-wrap gap-3" hx-ext="json-enc">
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-play mr-2"></i>
Start Display
</button>
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i>
Stop Display
</button>
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "git_pull"}'
hx-confirm="This will stash any local changes and update the code. Continue?"
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i>
Update Code
</button>
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-power-off mr-2"></i>
Reboot System
</button>
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "shutdown_system"}'
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
<i class="fas fa-power-off mr-2"></i>
Shutdown System
</button>
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_display_service"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i>
Restart Display Service
</button>
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_web_service"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i>
Restart Web Service
</button>
</div>
</div>
<!-- Display Preview -->
<div>
<h3 class="text-md font-medium text-gray-900 mb-4">
<i class="fas fa-desktop"></i> Live Display Preview
</h3>
<!-- overflow-x-auto: on narrow screens a wide preview scrolls at its
true pixel-perfect size instead of being squeezed (fractional
downscaling of pixel art reads as blur) -->
<div class="bg-gray-900 rounded-lg p-6 border border-gray-700 overflow-x-auto" style="position: relative;">
<div id="previewStage" class="preview-stage" style="display:none; position:relative; display:inline-block;">
<div id="previewMeta" style="position:absolute; top:-28px; left:0; color:#ddd; font-size:12px; opacity:0.85;"></div>
<img id="displayImage" style="image-rendering: pixelated; display: block;" alt="LED Matrix Display">
<canvas id="ledCanvas" style="position:absolute; top:0; left:0; pointer-events:none; display:none; z-index: 10;"></canvas>
<canvas id="gridOverlay" style="position:absolute; top:0; left:0; pointer-events:none; z-index: 20;"></canvas>
</div>
<div id="displayPlaceholder" class="text-center text-gray-400 py-8">
<i class="fas fa-spinner fa-spin text-4xl mb-3"></i>
<p>Connecting to display...</p>
</div>
</div>
<!-- Display Controls -->
<div class="mt-4 flex flex-wrap items-center gap-3">
<button onclick="takeScreenshot()" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-camera mr-2"></i> Screenshot
</button>
<label class="inline-flex items-center gap-2 bg-gray-100 px-3 py-2 rounded-md text-sm">
<span>Scale:</span>
<input type="range" id="scaleRange" min="2" max="16" value="8" class="w-20">
<span id="scaleValue" class="font-medium">8x</span>
</label>
<label class="inline-flex items-center gap-2 bg-gray-100 px-3 py-2 rounded-md text-sm cursor-pointer">
<input type="checkbox" id="toggleGrid" class="rounded">
<span>Show pixel grid</span>
</label>
<label class="inline-flex items-center gap-2 bg-gray-100 px-3 py-2 rounded-md text-sm cursor-pointer">
<input type="checkbox" id="toggleLedDots" checked class="rounded">
<span>LED dot mode</span>
</label>
<label class="inline-flex items-center gap-2 bg-gray-100 px-3 py-2 rounded-md text-sm">
<span>Dot fill:</span>
<input type="range" id="dotFillRange" min="40" max="95" value="75" class="w-16">
<span id="dotFillValue" class="font-medium">75%</span>
</label>
</div>
</div>
</div>
<!-- Stats are updated via the global updateSystemStats function in base.html -->
<script>
// Load LEDMatrix version
(function() {
fetch('/api/v3/system/version')
.then(response => response.json())
.then(data => {
const versionEl = document.getElementById('ledmatrix-version');
if (versionEl && data.status === 'success') {
versionEl.textContent = data.data.version;
}
})
.catch(error => {
const versionEl = document.getElementById('ledmatrix-version');
if (versionEl) versionEl.textContent = 'Unknown';
});
})();
// Check for updates function
window.checkForUpdates = function() {
const btn = event.target.closest('button');
if (btn) {
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Checking...';
fetch('/api/v3/system/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'git_pull' })
})
.then(response => response.json())
.then(data => {
btn.innerHTML = originalContent;
btn.disabled = false;
if (data.status === 'success') {
showNotification('Update successful: ' + (data.stdout || 'Code updated'), 'success');
// Reload version after a short delay
setTimeout(() => {
fetch('/api/v3/system/version')
.then(response => response.json())
.then(data => {
const versionEl = document.getElementById('ledmatrix-version');
if (versionEl && data.status === 'success') {
versionEl.textContent = data.data.version;
}
});
}, 1000);
} else {
showNotification('Update failed: ' + (data.stderr || data.message || 'Unknown error'), 'error');
}
})
.catch(error => {
btn.innerHTML = originalContent;
btn.disabled = false;
showNotification('Error checking for updates: ' + error.message, 'error');
});
}
};
// Setup display preview controls (runs when this partial loads)
(function() {
const scaleRange = document.getElementById('scaleRange');
const scaleValue = document.getElementById('scaleValue');
const dotFillRange = document.getElementById('dotFillRange');
const dotFillValue = document.getElementById('dotFillValue');
const toggleGrid = document.getElementById('toggleGrid');
const toggleLedDots = document.getElementById('toggleLedDots');
if (scaleRange && scaleValue) {
scaleRange.addEventListener('input', function() {
scaleValue.textContent = this.value + 'x';
// Re-render the preview with new scale
const img = document.getElementById('displayImage');
if (img && img.src) {
const data = {
image: img.src.replace('data:image/png;base64,', ''),
width: img.naturalWidth,
height: img.naturalHeight
};
updateDisplayPreview(data);
}
});
}
if (dotFillRange && dotFillValue) {
dotFillRange.addEventListener('input', function() {
dotFillValue.textContent = this.value + '%';
renderLedDots();
});
}
if (toggleGrid) {
toggleGrid.addEventListener('change', function() {
const canvas = document.getElementById('gridOverlay');
const img = document.getElementById('displayImage');
if (canvas && img && img.src) {
const scale = parseInt(scaleRange?.value || '8');
drawGrid(canvas, img.naturalWidth, img.naturalHeight, scale);
}
});
}
if (toggleLedDots) {
toggleLedDots.addEventListener('change', function() {
renderLedDots();
});
}
})();
</script>