From 2cd47ea8f48d280747fd98e2ce279dc795ff7885 Mon Sep 17 00:00:00 2001 From: Florian Bachmann Date: Wed, 1 Apr 2026 10:55:55 +0200 Subject: [PATCH 1/5] Add live preview widget to plugin settings pages New /preview endpoint generates images in-memory and returns them as base64 without writing temp files or updating the display. PreviewManager.js handles rendering, form listening, and client-side required-field validation before hitting the server. Graceful fallback when Chromium is unavailable. Enabled for: Clock, Countdown, Comic, To-Do List, WPOTD, Year Progress. --- src/blueprints/plugin.py | 35 ++++- src/plugins/clock/settings.html | 10 +- src/plugins/comic/settings.html | 4 + src/plugins/countdown/settings.html | 8 +- src/plugins/todo_list/settings.html | 22 +++- src/plugins/wpotd/settings.html | 6 +- src/plugins/year_progress/settings.html | 7 + src/static/scripts/preview_manager.js | 163 ++++++++++++++++++++++++ src/templates/plugin.html | 1 + 9 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 src/plugins/year_progress/settings.html create mode 100644 src/static/scripts/preview_manager.js diff --git a/src/blueprints/plugin.py b/src/blueprints/plugin.py index b7a80d860..eadd902cc 100644 --- a/src/blueprints/plugin.py +++ b/src/blueprints/plugin.py @@ -2,9 +2,11 @@ from plugins.plugin_registry import get_plugin_instance from utils.app_utils import resolve_path, handle_request_files, parse_form from refresh_task import ManualRefresh, PlaylistRefresh +import base64 import json -import os import logging +import os +from io import BytesIO logger = logging.getLogger(__name__) plugin_bp = Blueprint("plugin", __name__) @@ -256,3 +258,34 @@ def update_now(): return jsonify({"error": f"An error occurred: {str(e)}"}), 500 return jsonify({"success": True, "message": "Display updated"}), 200 + + +@plugin_bp.route('/preview', methods=['POST']) +def preview(): + """Generate a preview image without updating the display""" + device_config = current_app.config['DEVICE_CONFIG'] + + try: + plugin_settings = parse_form(request.form) + plugin_settings.update(handle_request_files(request.files)) + plugin_id = plugin_settings.pop("plugin_id") + + plugin_config = device_config.get_plugin(plugin_id) + if not plugin_config: + return jsonify({"error": f"Plugin '{plugin_id}' not found"}), 404 + + plugin = get_plugin_instance(plugin_config) + image = plugin.generate_image(plugin_settings, device_config) + + buffer = BytesIO() + image.save(buffer, format='PNG') + image_b64 = base64.b64encode(buffer.getvalue()).decode() + + return jsonify({ + "success": True, + "image": f"data:image/png;base64,{image_b64}" + }), 200 + + except Exception as e: + logger.exception(f"Error in preview: {str(e)}") + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 diff --git a/src/plugins/clock/settings.html b/src/plugins/clock/settings.html index f9600252a..48bf4f39a 100644 --- a/src/plugins/clock/settings.html +++ b/src/plugins/clock/settings.html @@ -1,3 +1,5 @@ +
+
@@ -46,13 +48,15 @@ document.getElementById('selected-clock-face').value = selectedFaceName; document.querySelector("[name=primaryColor]").value = element.dataset.primaryColor; document.querySelector("[name=secondaryColor]").value = element.dataset.secondaryColor; + + PreviewManager.refresh(); } // Default selection for the first clock face or based on plugin_settings document.addEventListener('DOMContentLoaded', () => { const clockFaceContainer = document.getElementById('clock-face-selection'); const selectedClockFaceInput = document.getElementById('selected-clock-face'); - + // Check if pluginSettings has a selectedClockFace value const selectedClockFaceFromSettings = pluginSettings?.selectedClockFace; @@ -75,5 +79,7 @@ if (pluginSettings.secondaryColor) { document.querySelector("[name=secondaryColor]").value = pluginSettings.secondaryColor; } + + PreviewManager.init('clock'); }); - \ No newline at end of file + diff --git a/src/plugins/comic/settings.html b/src/plugins/comic/settings.html index 31a73530b..40d040e7c 100644 --- a/src/plugins/comic/settings.html +++ b/src/plugins/comic/settings.html @@ -1,3 +1,5 @@ +
+
- - - + + + @@ -90,6 +92,7 @@ list.querySelector(".delete-button").onclick = () => { list.remove(); listCount--; + PreviewManager.refresh(); }; container.appendChild(list); @@ -116,10 +119,17 @@ // Handle Add button addBtn.onclick = () => { - if (listCount < maxLists) createList(); + if (listCount < maxLists) { + createList(); + PreviewManager.refresh(); + } }; document.getElementById('fontSize').value = fontSize; document.getElementById('listStyle').value = listStyle; + + PreviewManager.init('todo_list', { + required: { 'list[]': 'add a list first' } + }); }); - \ No newline at end of file + diff --git a/src/plugins/wpotd/settings.html b/src/plugins/wpotd/settings.html index c7d81d64c..87959ca06 100644 --- a/src/plugins/wpotd/settings.html +++ b/src/plugins/wpotd/settings.html @@ -1,3 +1,5 @@ +
+ If the date field is blank it will default to today's date. This is useful when adding to a playlist.
@@ -46,5 +48,7 @@ $date.value = ""; $toggleShrink.checked = true; $toggleShrink.value = "true";} + + PreviewManager.init('wpotd'); }); - \ No newline at end of file + diff --git a/src/plugins/year_progress/settings.html b/src/plugins/year_progress/settings.html new file mode 100644 index 000000000..7a51dd2cf --- /dev/null +++ b/src/plugins/year_progress/settings.html @@ -0,0 +1,7 @@ +
+ + diff --git a/src/static/scripts/preview_manager.js b/src/static/scripts/preview_manager.js new file mode 100644 index 000000000..fa0c20f74 --- /dev/null +++ b/src/static/scripts/preview_manager.js @@ -0,0 +1,163 @@ +/** + * Live Preview Manager for InkyPi Plugin Settings + * + * Generates a live preview image when plugin settings change, + * without pushing to the physical display. + * + * Usage in a plugin's settings.html: + * + * + *
+ * + * + * + * The widget auto-renders into #preview-widget and listens to all + * form inputs for changes. Call PreviewManager.refresh() after + * dynamically adding form elements (e.g. new list items). + */ +const PreviewManager = (() => { + let timeout = null; + let pluginId = null; + let requiredFields = {}; + + function render(containerId) { + const container = document.getElementById(containerId); + if (!container) return; + + container.innerHTML = ` +
+ +
+
+

Current

+
+ Current Display +
+
+
+

Preview

+
+ Preview with new settings +
+
+
+
`; + } + + function checkRequired() { + const form = document.querySelector('form'); + if (!form) return null; + for (const [field, label] of Object.entries(requiredFields)) { + if (field.endsWith('[]')) { + const elements = form.querySelectorAll(`[name="${field}"]`); + const hasValue = Array.from(elements).some(el => el.value.trim()); + if (!hasValue) return label; + } else { + const el = form.querySelector(`[name="${field}"]`); + if (!el || !el.value.trim()) return label; + } + } + return null; + } + + function generate() { + clearTimeout(timeout); + timeout = setTimeout(async () => { + const statusEl = document.getElementById('preview-status'); + const previewImg = document.getElementById('preview-image'); + if (!statusEl || !previewImg) return; + + const missingLabel = checkRequired(); + if (missingLabel) { + previewImg.style.display = 'none'; + statusEl.textContent = `(${missingLabel})`; + return; + } + + statusEl.textContent = '(loading...)'; + + const form = document.querySelector('form'); + if (!form) return; + + const formData = new FormData(form); + formData.append('plugin_id', pluginId); + + try { + const response = await fetch('/preview', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + if (result.success) { + previewImg.src = result.image; + previewImg.style.display = 'block'; + statusEl.textContent = ''; + } else { + previewImg.style.display = 'none'; + const err = result.error || ''; + if (err.includes('Chromium') || err.includes('NoneType')) { + statusEl.textContent = '(needs Chromium — works on Pi)'; + } else if (err.includes('required')) { + // Missing required field — show friendly hint + const field = err.match(/(\w+) is required/i); + statusEl.textContent = field ? `(no ${field[1].toLowerCase()} set yet)` : '(fill in required fields)'; + } else { + // Show the actual error (strip "An error occurred: " prefix) + const msg = err.replace(/^An error occurred:\s*/i, ''); + statusEl.textContent = msg ? `(${msg})` : '(preview unavailable)'; + } + console.error('Preview error:', err); + } + } catch (err) { + previewImg.style.display = 'none'; + statusEl.textContent = '(preview unavailable)'; + console.error('Preview error:', err); + } + }, 500); + } + + function listenToForm() { + document.querySelectorAll('form select, form input, form textarea').forEach(el => { + el.addEventListener('change', generate); + el.addEventListener('input', generate); + }); + } + + return { + /** + * Initialize preview for a plugin. + * @param {string} id - The plugin_id (e.g. 'clock', 'countdown') + * @param {string} [containerId='preview-widget'] - ID of the container element + */ + init(id, options = {}) { + pluginId = id; + requiredFields = options.required || {}; + render(options.containerId || 'preview-widget'); + listenToForm(); + generate(); + }, + + /** Trigger a preview refresh (call after dynamically adding form elements) */ + refresh() { + listenToForm(); + generate(); + }, + + /** Refresh the "current" image (call after a successful Update Now) */ + refreshCurrent() { + const img = document.getElementById('preview-current'); + if (img) img.src = '/static/images/current_image.png?' + Date.now(); + } + }; +})(); diff --git a/src/templates/plugin.html b/src/templates/plugin.html index 9285f0920..01dbcf34b 100644 --- a/src/templates/plugin.html +++ b/src/templates/plugin.html @@ -17,6 +17,7 @@ + From 8c1d2207af0c0d2f1fb902dd9de03b71f3e6b74c Mon Sep 17 00:00:00 2001 From: Florian Bachmann Date: Wed, 1 Apr 2026 11:34:11 +0200 Subject: [PATCH 2/5] fix: update JSDoc to match options object parameter --- src/static/scripts/preview_manager.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/static/scripts/preview_manager.js b/src/static/scripts/preview_manager.js index fa0c20f74..e8369f7f8 100644 --- a/src/static/scripts/preview_manager.js +++ b/src/static/scripts/preview_manager.js @@ -138,7 +138,9 @@ const PreviewManager = (() => { /** * Initialize preview for a plugin. * @param {string} id - The plugin_id (e.g. 'clock', 'countdown') - * @param {string} [containerId='preview-widget'] - ID of the container element + * @param {Object} [options] - Configuration options + * @param {Object} [options.required] - Map of field names to hint messages for client-side validation + * @param {string} [options.containerId='preview-widget'] - ID of the container element */ init(id, options = {}) { pluginId = id; From 829b786289ba0b21a5ecd814b526202a0f5539df Mon Sep 17 00:00:00 2001 From: Florian Bachmann Date: Wed, 1 Apr 2026 11:34:42 +0200 Subject: [PATCH 3/5] fix: prevent duplicate event listeners on refresh() --- src/static/scripts/preview_manager.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/static/scripts/preview_manager.js b/src/static/scripts/preview_manager.js index e8369f7f8..f7c79d231 100644 --- a/src/static/scripts/preview_manager.js +++ b/src/static/scripts/preview_manager.js @@ -129,8 +129,10 @@ const PreviewManager = (() => { function listenToForm() { document.querySelectorAll('form select, form input, form textarea').forEach(el => { + if (el.dataset.previewBound) return; el.addEventListener('change', generate); el.addEventListener('input', generate); + el.dataset.previewBound = '1'; }); } From 36e99f19fce22eebbe57ef89e5ffcbfcb4019ad0 Mon Sep 17 00:00:00 2001 From: Florian Bachmann Date: Wed, 1 Apr 2026 11:35:06 +0200 Subject: [PATCH 4/5] fix: return 400 when plugin_id is missing from preview request --- src/blueprints/plugin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/blueprints/plugin.py b/src/blueprints/plugin.py index eadd902cc..1d2d77c09 100644 --- a/src/blueprints/plugin.py +++ b/src/blueprints/plugin.py @@ -268,7 +268,9 @@ def preview(): try: plugin_settings = parse_form(request.form) plugin_settings.update(handle_request_files(request.files)) - plugin_id = plugin_settings.pop("plugin_id") + plugin_id = plugin_settings.pop("plugin_id", None) + if not plugin_id: + return jsonify({"error": "plugin_id is required"}), 400 plugin_config = device_config.get_plugin(plugin_id) if not plugin_config: From 544a0cff61f6d98cac821a2b91622ee41d2b5d56 Mon Sep 17 00:00:00 2001 From: Florian Bachmann Date: Wed, 1 Apr 2026 11:38:05 +0200 Subject: [PATCH 5/5] fix: handle None image from plugins missing Chromium --- src/blueprints/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/blueprints/plugin.py b/src/blueprints/plugin.py index 1d2d77c09..ead6132e0 100644 --- a/src/blueprints/plugin.py +++ b/src/blueprints/plugin.py @@ -278,6 +278,8 @@ def preview(): plugin = get_plugin_instance(plugin_config) image = plugin.generate_image(plugin_settings, device_config) + if image is None: + return jsonify({"error": "An error occurred: NoneType — Chromium may not be installed"}), 500 buffer = BytesIO() image.save(buffer, format='PNG')