Merge commit 'b9416ef803028ec37e0f2f756c051e52d52622d7' into claude/hdpi-scroll-performance-antialiasing-4ae609

# Conflicts:
#	src/vegas_mode/config.py
#	src/vegas_mode/coordinator.py
This commit is contained in:
Chuck
2026-09-24 17:38:08 -04:00
89 changed files with 2881 additions and 5493 deletions
+97 -181
View File
@@ -168,54 +168,109 @@
</script>
<script src="{{ url_for('static', filename='v3/js/htmx-config.js') }}"></script>
<!-- Fallback for loading plugins if HTMX fails -->
<!-- Direct-fetch fallback for tab panels. Used when HTMX never loads, and
by the hx-on::response-error handlers on the Overview, WiFi and
Plugin Manager panels when HTMX's own request fails. -->
<script>
function loadPluginsDirect() {
const content = document.getElementById('plugins-content');
if (content && !content.hasAttribute('data-loaded')) {
content.setAttribute('data-loaded', 'true');
debugLog('Loading plugins directly via fetch (HTMX fallback)...');
(function() {
const PANEL_LABELS = {
'overview-content': 'the Overview',
'wifi-content': 'WiFi setup',
'plugins-content': 'the Plugin Manager'
};
// Fetches `url` into the element `id`, runs the partial's inline
// scripts, and lets HTMX and Alpine process the new markup. The
// scripts run before Alpine sees the content for the same reason
// htmx-config.js runs them on htmx:afterSwap: partials define their
// x-data component functions in those scripts.
window.loadPartialDirect = function(id, url) {
const el = document.getElementById(id);
if (!el || !url || el.hasAttribute('data-loaded') || el.hasAttribute('data-direct-loading')) return;
el.setAttribute('data-direct-loading', 'true');
debugLog('Loading ' + url + ' directly via fetch (HTMX fallback)');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
fetch('/v3/partials/plugins', { signal: controller.signal })
fetch(url, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return r.text();
})
.then(html => {
clearTimeout(timeout);
content.innerHTML = html;
if (typeof htmx !== 'undefined') {
htmx.process(content);
}
// Trigger full initialization chain
if (window.pluginManager) {
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
}
if (window.initPluginsPage) {
el.innerHTML = html;
el.querySelectorAll('script').forEach(oldScript => {
const script = document.createElement('script');
for (const attr of oldScript.attributes) script.setAttribute(attr.name, attr.value);
script.textContent = oldScript.textContent;
oldScript.replaceWith(script);
});
el.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') htmx.process(el);
if (window.Alpine) window.Alpine.initTree(el);
if (id === 'plugins-content' && window.initPluginsPage) {
if (window.pluginManager) {
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
}
window.initPluginsPage();
}
})
.catch(err => {
console.error('Failed to load ' + url + ':', err);
const label = PANEL_LABELS[id] || 'this page';
el.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load ' + label + '. Check that the device is still reachable, then try again.</p><button type="button" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
el.querySelector('button').addEventListener('click', () => window.loadPartialDirect(id, url));
})
.finally(() => {
clearTimeout(timeout);
console.error('Failed to load plugins:', err);
content.removeAttribute('data-loaded');
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Plugin Manager. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadPluginsDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
el.removeAttribute('data-direct-loading');
});
}
}
};
// Fallback if HTMX doesn't load within 5 seconds
var _pluginsFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
// Load plugins tab content directly regardless of active tab,
// so it's ready when the user navigates to it
loadPluginsDirect();
function activeTab() {
const appEl = document.querySelector('[x-data="app()"]');
try {
return (window.Alpine && appEl) ? window.Alpine.$data(appEl).activeTab : null;
} catch (e) {
return null; // Alpine has not initialised the app yet
}
}
}, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
function loadActiveTabDirect() {
const el = document.getElementById((activeTab() || 'overview') + '-content');
if (el) window.loadPartialDirect(el.id, el.getAttribute('hx-get'));
}
// HTMX loaded but never initialised, even from the CDN rescue.
window.addEventListener('htmx-load-failed', function() {
console.warn('HTMX failed to load, loading the active tab directly');
setTimeout(loadActiveTabDirect, 2000);
});
// HTMX still missing after 5 seconds: load the active tab, and the
// Plugin Manager regardless of tab so the plugin tabs work.
const fallbackTimer = setTimeout(function() {
if (typeof htmx !== 'undefined') return;
console.warn('HTMX not loaded after 5 seconds, loading tabs directly');
window.loadPartialDirect('plugins-content', '/v3/partials/plugins');
loadActiveTabDirect();
}, 5000);
// AP mode (setting up WiFi) opens on the WiFi tab and can least
// afford to wait, so it gets an earlier check.
const apTimer = setTimeout(function() {
const host = window.location.hostname;
if (typeof htmx === 'undefined' && host.startsWith('192.168.4.')) {
console.warn('HTMX not loaded, loading WiFi setup directly');
window.loadPartialDirect('wifi-content', '/v3/partials/wifi');
}
}, 3000);
window.addEventListener('htmx:ready', function() {
clearTimeout(fallbackTimer);
clearTimeout(apTimer);
}, { once: true });
})();
</script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script src="{{ url_for('static', filename='v3/js/app-early.js') }}"></script>
@@ -246,45 +301,6 @@
});
</script>
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
<link rel="preload" href="{{ url_for('static', filename='v3/vendor/codemirror/codemirror.min.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/codemirror/codemirror.min.css') }}"></noscript>
<link rel="preload" href="{{ url_for('static', filename='v3/vendor/codemirror/theme/monokai.min.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/codemirror/theme/monokai.min.css') }}"></noscript>
<!-- CodeMirror scripts loaded on demand when JSON editor is opened -->
<script>
// Lazy load CodeMirror when needed
window.loadCodeMirror = function() {
if (window.CodeMirror) return Promise.resolve();
return new Promise((resolve, reject) => {
// Vendored CodeMirror 5.65.2 (note: the previously listed CDN
// mode/json/json.min.js never existed — it 404'd on every
// editor open; JSON highlighting comes from the javascript
// mode, so it's simply dropped here)
const scripts = [
'/static/v3/vendor/codemirror/codemirror.min.js',
'/static/v3/vendor/codemirror/mode/javascript/javascript.min.js',
'/static/v3/vendor/codemirror/addon/edit/closebrackets.min.js',
'/static/v3/vendor/codemirror/addon/edit/matchbrackets.min.js'
];
let loaded = 0;
scripts.forEach((src, index) => {
const script = document.createElement('script');
script.src = src;
script.defer = true;
script.onload = () => {
loaded++;
if (loaded === scripts.length) resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
});
};
</script>
<!-- Font Awesome icons (vendored 6.0.0 — css/all.min.css references
../webfonts/ relatively, so both live under v3/vendor/fontawesome/) -->
<link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}">
@@ -548,7 +564,7 @@
<!-- Second row - Plugin tabs (populated dynamically) -->
<div id="plugin-tabs-row" class="border-b border-gray-200">
<nav aria-label="Plugins" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPluginsDirect()); }"
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPartialDirect('plugins-content', '/v3/partials/plugins')); }"
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-plug" aria-hidden="true"></i>Plugin Manager
@@ -592,7 +608,7 @@
<div id="tab-content" class="space-y-6" tabindex="-1">
<!-- Overview tab -->
<div x-show="activeTab === 'overview'" x-transition>
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -601,65 +617,6 @@
</div>
</div>
</div>
<script>
// Fallback: Load overview content directly if HTMX fails
function loadOverviewDirect() {
const overviewContent = document.getElementById('overview-content');
if (overviewContent && !overviewContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/overview')
.then(response => {
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response.text();
})
.then(html => {
overviewContent.innerHTML = html;
overviewContent.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') {
htmx.process(overviewContent);
}
// Re-initialize Alpine.js for the new content
if (window.Alpine) {
window.Alpine.initTree(overviewContent);
}
})
.catch(err => {
console.error('Failed to load overview content:', err);
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Overview. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadOverviewDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
});
}
}
// Listen for HTMX load failure
window.addEventListener('htmx-load-failed', function() {
console.warn('HTMX failed to load, setting up direct content loading fallbacks');
// Try to load content directly after a delay
setTimeout(() => {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement.__x) {
const activeTab = appElement.__x.$data.activeTab || 'overview';
if (activeTab === 'overview') {
loadOverviewDirect();
}
}
}, 2000);
});
// Also try direct load if HTMX doesn't load within 5 seconds
var _overviewFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement.__x) {
const activeTab = appElement.__x.$data.activeTab || 'overview';
if (activeTab === 'overview') {
loadOverviewDirect();
}
}
}
}, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
</script>
<!-- General tab -->
<div x-show="activeTab === 'general'" x-transition>
@@ -683,7 +640,7 @@
hx-get="/v3/partials/wifi"
hx-trigger="loadtab"
hx-swap="innerHTML"
hx-on::htmx:response-error="loadWifiDirect()">
hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -696,42 +653,6 @@
</div>
</div>
</div>
<script>
// Fallback: Load WiFi content directly if HTMX fails
function loadWifiDirect() {
const wifiContent = document.getElementById('wifi-content');
if (wifiContent && !wifiContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/wifi')
.then(response => {
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response.text();
})
.then(html => {
wifiContent.innerHTML = html;
wifiContent.setAttribute('data-loaded', 'true');
// Re-initialize Alpine.js for the new content
if (window.Alpine) {
window.Alpine.initTree(wifiContent);
}
})
.catch(err => {
console.error('Failed to load WiFi content:', err);
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load WiFi setup. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadWifiDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
});
}
}
// Also try direct load if HTMX doesn't load within 3 seconds (AP mode detection)
setTimeout(() => {
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
if (isAPMode && typeof htmx === 'undefined') {
console.warn('HTMX not loaded, using direct fetch for WiFi content');
loadWifiDirect();
}
}, 3000);
</script>
<!-- Schedule tab -->
<div x-show="activeTab === 'schedule'" x-transition>
@@ -806,7 +727,7 @@
<!-- Plugins tab -->
<div x-show="activeTab === 'plugins'" x-transition>
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML"
hx-on::response-error="loadPluginsDirect()">
hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -986,11 +907,10 @@
their own URLs for plugin-loader.js and debugging. -->
<script src="{{ widgets_bundle_url() }}" defer></script>
<!-- plugins_manager.js: loaded LAST on purpose — it defines the live
window.* implementations for several plugin actions (installPlugin,
uninstallPlugin, executePluginAction, ...) and intentionally wins
over same-named definitions in js/app.js and js/app-shell.js. Do not
remove or reorder without resolving that overlap first. -->
<!-- plugins_manager.js: loaded last. It uses PluginAPI, ListFilter,
LEDDialog, the widgets and app-shell.js's updatePlugin at load time
or when a card is clicked, so it must follow all of them. No other
script defines the same globals. -->
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
@@ -1147,16 +1067,12 @@
'services to run the new code');
}
}
if (typeof showNotification === 'function') {
showNotification(data.message || 'Update complete', data.status || 'success');
}
showNotification(data.message || 'Update complete', data.status || 'success');
})
.catch(function() {
btn.innerHTML = originalHTML;
btn.disabled = false;
if (typeof showNotification === 'function') {
showNotification('Update failed — check your connection', 'error');
}
showNotification('Update failed — check your connection', 'error');
});
};
@@ -121,11 +121,7 @@
let inspectedFile = null;
function notify(message, kind) {
if (typeof showNotification === 'function') {
showNotification(message, kind || 'info');
} else {
console.log('[backup]', kind || 'info', message);
}
showNotification(message, kind || 'info');
}
function formatSize(bytes) {
@@ -136,11 +132,7 @@
return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
}
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
function escapeHtml(value) { return window.LEDEscape.html(value); }
async function loadPreview() {
const el = document.getElementById('export-preview');
+10 -26
View File
@@ -55,13 +55,12 @@
</div>
<script>
// Initialize cache management when partial loads
// Scoped: this script runs at global scope after every HTMX swap, and the Logs
// partial has helpers with the same names (showError, escapeHtml). Only
// deleteCacheFile, which the row buttons call from onclick, is exported.
(function() {
loadCacheFiles();
// Event listeners
document.getElementById('refresh-cache-btn').addEventListener('click', loadCacheFiles);
})();
loadCacheFiles();
document.getElementById('refresh-cache-btn').addEventListener('click', loadCacheFiles);
function loadCacheFiles() {
const tbody = document.getElementById('cache-files-tbody');
@@ -190,21 +189,15 @@ function deleteCacheFile(key) {
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
if (typeof showNotification !== 'undefined') {
showNotification(data.message || 'Cache file deleted successfully', 'success');
}
showNotification(data.message || 'Cache file deleted successfully', 'success');
// Reload cache files list
loadCacheFiles();
} else {
if (typeof showNotification !== 'undefined') {
showNotification(data.message || 'Failed to delete cache file', 'error');
}
showNotification(data.message || 'Failed to delete cache file', 'error');
}
})
.catch(error => {
if (typeof showNotification !== 'undefined') {
showNotification('Error deleting cache file: ' + error.message, 'error');
}
showNotification('Error deleting cache file: ' + error.message, 'error');
});
}
@@ -220,17 +213,8 @@ function showError(message) {
errorMessage.textContent = message;
}
// Utility function to escape HTML
// Quotes too: the result is interpolated into quoted attribute values, and the
// textContent/innerHTML round-trip only escapes &, < and >.
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
// Make deleteCacheFile available globally for onclick handlers
window.deleteCacheFile = deleteCacheFile;
})();
</script>
@@ -35,7 +35,7 @@
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="showDisplaySaveResult(event.detail.xhr)"
hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Display settings saved', 'Display settings were not saved. Check your connection and try again.')"
class="space-y-6"
novalidate
onsubmit="fixInvalidNumberInputs(this); return true;">
@@ -863,62 +863,12 @@ document.getElementById('brightness').addEventListener('input', function() {
document.getElementById('brightness-display').textContent = this.value;
});
// Fix invalid number inputs function (if not already defined globally)
if (typeof window.fixInvalidNumberInputs !== 'function') {
window.fixInvalidNumberInputs = function(form) {
if (!form) return;
const allInputs = form.querySelectorAll('input[type="number"]');
allInputs.forEach(input => {
const min = parseFloat(input.getAttribute('min'));
const max = parseFloat(input.getAttribute('max'));
const value = parseFloat(input.value);
if (!isNaN(value)) {
if (!isNaN(min) && value < min) {
input.value = min;
} else if (!isNaN(max) && value > max) {
input.value = max;
}
}
});
};
}
// Report the outcome of a display-settings save. XMLHttpRequest has no
// `responseJSON` (that's a jQuery property) — read `responseText` and the real
// status code, otherwise a failed save reports success.
window.showDisplaySaveResult = function(xhr) {
// Only 2xx counts as saved. A network failure reports status 0, which any
// `>= 400` test would wave through as success.
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
let message = httpSuccess
? 'Display settings saved'
: 'Display settings were not saved. Check your connection and try again.';
let status = httpSuccess ? 'success' : 'error';
try {
const data = JSON.parse(xhr.responseText);
if (data.message) message = data.message;
// A body can refine a successful verdict but never overturn a failed one.
if (httpSuccess && data.status) status = data.status;
} catch {
// Non-JSON body — fall back to the status-code verdict above.
}
showNotification(message, status);
};
// Vegas Scroll Mode Settings
(function() {
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = String(text || '');
return div.innerHTML;
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
// Escape for use in HTML attributes
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(text) { return window.LEDEscape.attr(text); }
// Toggle settings visibility
const vegasEnabledCheckbox = document.getElementById('vegas_scroll_enabled');
@@ -11,7 +11,7 @@
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="showNotification(event.detail.xhr.responseJSON?.message || 'Durations saved', event.detail.xhr.responseJSON?.status || 'success')"
hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Durations saved', 'Durations were not saved. Check your connection and try again.')"
class="space-y-6"
novalidate
onsubmit="fixInvalidNumberInputs(this); return true;">
+1 -23
View File
@@ -164,21 +164,6 @@ function initializeFontsTab() {
// Reset retry counter on successful init
initRetryCount = 0;
// showNotification is implemented by the notification widget
// (widgets/notification.js). If it hasn't loaded, queue the message; the
// widget shows queued messages as soon as it registers.
if (typeof window.showNotification !== 'function') {
window.showNotification = function(message, type = 'info') {
const registry = window.LEDMatrixWidgets;
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
if (widget && typeof widget.show === 'function') {
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
}
(window.__pendingNotifications = window.__pendingNotifications || []).push([message, type]);
console.log(`[${String((type && type.type) || type).toUpperCase()}]`, message);
};
}
debugLog('Initializing font management...');
initializeFontManagement();
@@ -349,14 +334,7 @@ async function loadFontData() {
// Show error states
availableContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
// Only show notification if showNotification is available
if (typeof showNotification === 'function') {
showNotification('Error loading font configuration', 'error');
} else if (typeof window.showNotification === 'function') {
window.showNotification('Error loading font configuration', 'error');
} else {
console.error('Error loading font configuration:', error.message);
}
showNotification('Error loading font configuration', 'error');
}
}
@@ -11,24 +11,7 @@
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="
var xhr = event.detail.xhr;
var isSuccess = xhr.status >= 200 && xhr.status < 300;
var message = '';
var status = 'success';
try {
var data = JSON.parse(xhr.responseText);
message = data.message || '';
status = data.status || status;
} catch (e) {}
if (isSuccess) {
message = message || 'Settings saved';
} else {
message = message || 'Failed to save settings';
status = 'error';
}
showNotification(message, status);
"
hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Settings saved', 'Failed to save settings')"
class="space-y-6">
{# Marks this post as the whole form, so an unchecked box saves as
false. Without it the save endpoint treats absent keys as unchanged
+14 -30
View File
@@ -126,8 +126,10 @@
</div>
<script>
// Global variables - use window properties to avoid redeclaration errors with HTMX reloads
// Initialize only if not already defined
// Scoped: this script runs at global scope after every HTMX swap, and the
// Cache partial has helpers with the same names (showError, escapeHtml). State
// that must survive a reload of this partial lives on window._logs* instead.
(function() {
if (typeof window._logsEventSource === 'undefined') {
window._logsEventSource = null;
}
@@ -624,9 +626,7 @@ function toggleRealtime() {
function refreshLogs() {
loadLogs();
if (typeof showNotification !== 'undefined') {
showNotification('Logs refreshed', 'success');
}
showNotification('Logs refreshed', 'success');
}
function clearLogs() {
@@ -637,16 +637,12 @@ function clearLogs() {
}
showEmptyState();
updateLogStats();
if (typeof showNotification !== 'undefined') {
showNotification('Logs cleared', 'info');
}
showNotification('Logs cleared', 'info');
}
function downloadLogs() {
if (window._filteredLogs.length === 0) {
if (typeof showNotification !== 'undefined') {
showNotification('No logs to download', 'warning');
}
showNotification('No logs to download', 'warning');
return;
}
@@ -662,9 +658,7 @@ function downloadLogs() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (typeof showNotification !== 'undefined') {
showNotification('Logs downloaded', 'success');
}
showNotification('Logs downloaded', 'success');
}
// Track if user is near bottom for smart auto-scroll
@@ -776,16 +770,7 @@ function showError(message) {
}
}
// Utility function to escape HTML. Quotes too: the result is interpolated into
// quoted attribute values (option value=, title=), and the
// textContent/innerHTML round-trip only escapes &, < and >.
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function refreshCurrentPluginStatus() {
fetch('/api/v3/display/current-status')
@@ -920,15 +905,13 @@ function clearPluginErrors() {
})
.then(response => response.json())
.then(data => {
if (typeof showNotification !== 'undefined') {
showNotification(data.status === 'success' ? 'Plugin errors cleared'
: (data.message || 'Could not clear plugin errors'),
data.status === 'success' ? 'success' : 'error');
}
showNotification(data.status === 'success' ? 'Plugin errors cleared'
: (data.message || 'Could not clear plugin errors'),
data.status === 'success' ? 'success' : 'error');
refreshPluginErrors();
})
.catch(() => {
if (typeof showNotification !== 'undefined') showNotification('Could not clear plugin errors', 'error');
showNotification('Could not clear plugin errors', 'error');
});
}
@@ -946,4 +929,5 @@ window.addEventListener('beforeunload', function() {
window._pluginErrorsPollTimer = null;
}
});
})();
</script>
@@ -260,18 +260,10 @@
return 'bg-gray-100 text-gray-800';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function showError(message) {
if (typeof showNotification === 'function') {
showNotification(message, 'error');
} else {
alert(message);
}
showNotification(message, 'error');
}
// Populate plugin filter
@@ -216,11 +216,7 @@
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));
var data = window.getApp();
if (data) {
data.activeTab = btn.dataset.tab;
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
@@ -443,7 +439,7 @@
<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); }"
hx-on:htmx:after-request="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
@@ -452,7 +448,7 @@
<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); }"
hx-on:htmx:after-request="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
@@ -462,7 +458,7 @@
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); }"
hx-on:htmx:after-request="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
@@ -472,7 +468,7 @@
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); }"
hx-on:htmx:after-request="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
@@ -482,7 +478,7 @@
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); }"
hx-on:htmx:after-request="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
@@ -491,7 +487,7 @@
<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); }"
hx-on:htmx:after-request="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
@@ -500,7 +496,7 @@
<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); }"
hx-on:htmx:after-request="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
@@ -423,11 +423,10 @@
{% if img.get('size') %}{{ (img.get('size') / 1024)|round }} KB{% endif %}
{% if img.get('uploaded_at') %} • {{ img.get('uploaded_at') }}{% endif %}
</p>
{% if has_schedule %}
<p class="text-xs text-blue-600 mt-1">
<i class="fas fa-clock mr-1"></i>Scheduled
{# file-upload.js rewrites .image-schedule-summary in place when the schedule is edited #}
<p class="text-xs text-blue-600 mt-1 image-schedule-summary">
<i class="fas fa-clock mr-1"></i>{{ 'Scheduled' if has_schedule else 'Always shown' }}
</p>
{% endif %}
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
@@ -540,18 +540,6 @@
background-color: #e5e7eb;
}
/* CodeMirror editor styles */
.CodeMirror {
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 14px;
height: auto;
min-height: 400px;
}
.CodeMirror.cm-error {
border-color: #ef4444;
}
/* Plugin config view styles */
.plugin-config-view {
@@ -148,7 +148,7 @@
fieldId: 'schedule'
});
console.log('[Schedule] Schedule picker widget initialized');
debugLog('[Schedule] Schedule picker widget initialized');
}
// Handle form submission response
@@ -165,28 +165,7 @@
const type = response.status || 'info';
// Use global notification function if available
if (typeof window.showNotification === 'function') {
window.showNotification(message, type);
} else {
// Fallback notification
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
const notification = document.createElement('div');
notification.className = 'fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ' + (colors[type] || colors.info) + ' text-white';
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(function() {
notification.style.transition = 'opacity 0.5s';
notification.style.opacity = '0';
setTimeout(function() { notification.remove(); }, 500);
}, 3000);
}
window.showNotification(message, type);
};
// Initialize when DOM is ready or if already loaded
@@ -265,7 +244,7 @@
fieldId: 'dim_schedule'
});
console.log('[DimSchedule] Dim schedule picker widget initialized');
debugLog('[DimSchedule] Dim schedule picker widget initialized');
}
// Handle dim schedule form submission response
@@ -282,28 +261,7 @@
const type = response.status || 'info';
// Use global notification function if available
if (typeof window.showNotification === 'function') {
window.showNotification(message, type);
} else {
// Fallback notification
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
const notification = document.createElement('div');
notification.className = 'fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ' + (colors[type] || colors.info) + ' text-white';
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(function() {
notification.style.transition = 'opacity 0.5s';
notification.style.opacity = '0';
setTimeout(function() { notification.remove(); }, 500);
}, 3000);
}
window.showNotification(message, type);
};
// Initialize when DOM is ready
@@ -344,21 +344,15 @@ function forceRenderStarlarkApp(appId) {
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
if (typeof showNotification === 'function') {
showNotification('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)', 'success');
} else {
alert('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)');
}
showNotification('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)', 'success');
} else {
var msg = 'Render failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
}
})
.catch(function(err) {
var msg = 'Render failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
});
}
@@ -371,22 +365,20 @@ function toggleStarlarkApp(appId, enabled) {
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins();
// A failed reload has already been reported by loadInstalledPlugins.
window.pluginManager.loadInstalledPlugins(true).catch(function() {});
var container = document.getElementById('plugin-config-starlark:' + appId);
if (container && window.htmx) {
htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'});
}
} else {
var msg = 'Toggle failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
}
})
.catch(function(err) {
var msg = 'Toggle failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
});
}
@@ -436,8 +428,7 @@ function saveStarlarkConfig(appId) {
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
if (typeof showNotification === 'function') showNotification('Configuration saved!', 'success');
else alert('Configuration saved!');
showNotification('Configuration saved!', 'success');
// Reload partial to reflect updated status
var container = document.getElementById('plugin-config-starlark:' + appId);
if (container && window.htmx) {
@@ -445,14 +436,12 @@ function saveStarlarkConfig(appId) {
}
} else {
var msg = 'Save failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
}
})
.catch(function(err) {
var msg = 'Save failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
});
}
@@ -435,9 +435,7 @@
el.innerHTML = html;
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escHtml(s) { return window.LEDEscape.html(s); }
// ── main action dispatcher ────────────────────────────────────────────────
@@ -910,11 +908,7 @@
};
// ── plugin health panel ──────────────────────────────────────────────────
function phEscape(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function phEscape(s) { return window.LEDEscape.html(s); }
function phFmtSecs(v) {
if (typeof v !== 'number' || !isFinite(v)) return '—';
return v.toFixed(3) + 's';