mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
- 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.
527 lines
27 KiB
HTML
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>
|
|
|