Skip to content

Commit cce68f1

Browse files
CopilotBaloMueller
andauthored
Merge PR fatihak#660 from upstream (live preview widget)
# Conflicts: # src/plugins/year_progress/settings.html Co-authored-by: BaloMueller <793558+BaloMueller@users.noreply.github.com>
2 parents 40ebfad + 544a0cf commit cce68f1

9 files changed

Lines changed: 250 additions & 11 deletions

File tree

src/blueprints/plugin.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
from plugins.plugin_registry import get_plugin_instance
33
from utils.app_utils import resolve_path, handle_request_files, parse_form
44
from refresh_task import ManualRefresh, PlaylistRefresh
5+
import base64
56
import json
6-
import os
77
import logging
8+
import os
9+
from io import BytesIO
810

911
logger = logging.getLogger(__name__)
1012
plugin_bp = Blueprint("plugin", __name__)
@@ -260,3 +262,38 @@ def update_now():
260262
return jsonify({"error": f"An error occurred: {str(e)}"}), 500
261263

262264
return jsonify({"success": True, "message": "Display updated"}), 200
265+
266+
267+
@plugin_bp.route('/preview', methods=['POST'])
268+
def preview():
269+
"""Generate a preview image without updating the display"""
270+
device_config = current_app.config['DEVICE_CONFIG']
271+
272+
try:
273+
plugin_settings = parse_form(request.form)
274+
plugin_settings.update(handle_request_files(request.files))
275+
plugin_id = plugin_settings.pop("plugin_id", None)
276+
if not plugin_id:
277+
return jsonify({"error": "plugin_id is required"}), 400
278+
279+
plugin_config = device_config.get_plugin(plugin_id)
280+
if not plugin_config:
281+
return jsonify({"error": f"Plugin '{plugin_id}' not found"}), 404
282+
283+
plugin = get_plugin_instance(plugin_config)
284+
image = plugin.generate_image(plugin_settings, device_config)
285+
if image is None:
286+
return jsonify({"error": "An error occurred: NoneType — Chromium may not be installed"}), 500
287+
288+
buffer = BytesIO()
289+
image.save(buffer, format='PNG')
290+
image_b64 = base64.b64encode(buffer.getvalue()).decode()
291+
292+
return jsonify({
293+
"success": True,
294+
"image": f"data:image/png;base64,{image_b64}"
295+
}), 200
296+
297+
except Exception as e:
298+
logger.exception(f"Error in preview: {str(e)}")
299+
return jsonify({"error": f"An error occurred: {str(e)}"}), 500

src/plugins/clock/settings.html

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
<div id="preview-widget"></div>
2+
13
<div class="form-group">
24
<label for="clock-face" class="form-label">Clock Face:</label>
35

@@ -46,13 +48,15 @@
4648
document.getElementById('selected-clock-face').value = selectedFaceName;
4749
document.querySelector("[name=primaryColor]").value = element.dataset.primaryColor;
4850
document.querySelector("[name=secondaryColor]").value = element.dataset.secondaryColor;
51+
52+
PreviewManager.refresh();
4953
}
5054

5155
// Default selection for the first clock face or based on plugin_settings
5256
document.addEventListener('DOMContentLoaded', () => {
5357
const clockFaceContainer = document.getElementById('clock-face-selection');
5458
const selectedClockFaceInput = document.getElementById('selected-clock-face');
55-
59+
5660
// Check if pluginSettings has a selectedClockFace value
5761
const selectedClockFaceFromSettings = pluginSettings?.selectedClockFace;
5862

@@ -75,5 +79,7 @@
7579
if (pluginSettings.secondaryColor) {
7680
document.querySelector("[name=secondaryColor]").value = pluginSettings.secondaryColor;
7781
}
82+
83+
PreviewManager.init('clock');
7884
});
79-
</script>
85+
</script>

src/plugins/comic/settings.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
<div id="preview-widget"></div>
2+
13
<div class="form-group nowrap">
24
<label for="comic" class="form-label">Comic:</label>
35
<select id="comic" name="comic" class="form-input">
@@ -35,5 +37,7 @@
3537
document.getElementById('titleCaption').checked = pluginSettings.titleCaption || false;
3638
document.getElementById('fontSize').value = pluginSettings.fontSize || '14';
3739
}
40+
41+
PreviewManager.init('comic');
3842
});
3943
</script>

src/plugins/countdown/settings.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
<div id="preview-widget"></div>
2+
13
<div class="form-group">
24
<div class="form-group">
35
<label for="title" class="form-label">Title</label>
@@ -15,5 +17,9 @@
1517
document.getElementById('title').value = pluginSettings.title || '';
1618
document.getElementById('date').value = pluginSettings.date;
1719
}
20+
21+
PreviewManager.init('countdown', {
22+
required: { date: 'select a date first' }
23+
});
1824
});
19-
</script>
25+
</script>

src/plugins/todo_list/settings.html

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
gap: 1rem;
66
margin-bottom: 1rem;
77
}
8-
8+
99
.list {
1010
display: flex;
1111
flex-direction: column;
@@ -31,6 +31,8 @@
3131

3232
</style>
3333

34+
<div id="preview-widget"></div>
35+
3436
<div class="form-group">
3537
<div class="form-group nowrap">
3638
<label for="title" class="form-label">Title</label>
@@ -39,9 +41,9 @@
3941
<div class="form-group nowrap">
4042
<label for="listStyle" class="form-label">List Style:</label>
4143
<select id="listStyle" name="listStyle" class="form-input">
42-
<option value="disc">Disc ()</option>
43-
<option value="square">Square ()</option>
44-
<option value="'\25C6 '">Diamond ()</option>
44+
<option value="disc">Disc (&#9679;)</option>
45+
<option value="square">Square (&#9724;)</option>
46+
<option value="'\25C6 '">Diamond (&#9670;)</option>
4547
<option value="decimal">Decimal</option>
4648
<option value="lower-roman">Roman Numeral</option>
4749
<option value="lower-alpha">Alphabetical</option>
@@ -90,6 +92,7 @@
9092
list.querySelector(".delete-button").onclick = () => {
9193
list.remove();
9294
listCount--;
95+
PreviewManager.refresh();
9396
};
9497

9598
container.appendChild(list);
@@ -116,10 +119,17 @@
116119

117120
// Handle Add button
118121
addBtn.onclick = () => {
119-
if (listCount < maxLists) createList();
122+
if (listCount < maxLists) {
123+
createList();
124+
PreviewManager.refresh();
125+
}
120126
};
121127

122128
document.getElementById('fontSize').value = fontSize;
123129
document.getElementById('listStyle').value = listStyle;
130+
131+
PreviewManager.init('todo_list', {
132+
required: { 'list[]': 'add a list first' }
133+
});
124134
});
125-
</script>
135+
</script>

src/plugins/wpotd/settings.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
<div id="preview-widget"></div>
2+
13
<span style="color: var(--text-primary);">If the date field is blank it will default to today's date. This is useful when adding to a playlist.</span>
24
<div class="form-group">
35
<label for="customDate" class="form-label">Date (optional)</label>
@@ -46,5 +48,7 @@
4648
$date.value = "";
4749
$toggleShrink.checked = true;
4850
$toggleShrink.value = "true";}
51+
52+
PreviewManager.init('wpotd');
4953
});
50-
</script>
54+
</script>

src/plugins/year_progress/settings.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
</select>
1313
</div>
1414

15+
<div id="preview-widget"></div>
16+
1517
<script>
1618
document.addEventListener('DOMContentLoaded', () => {
1719
const language = document.getElementById('language');
@@ -30,5 +32,7 @@
3032
}
3133

3234
language.value = hasOption ? selectedLanguage : 'en';
35+
36+
PreviewManager.init('year_progress');
3337
});
3438
</script>
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* Live Preview Manager for InkyPi Plugin Settings
3+
*
4+
* Generates a live preview image when plugin settings change,
5+
* without pushing to the physical display.
6+
*
7+
* Usage in a plugin's settings.html:
8+
*
9+
* <!-- Add preview widget (use any unique prefix for IDs) -->
10+
* <div id="preview-widget"></div>
11+
*
12+
* <script>
13+
* document.addEventListener('DOMContentLoaded', () => {
14+
* PreviewManager.init('your_plugin_id');
15+
* });
16+
* </script>
17+
*
18+
* The widget auto-renders into #preview-widget and listens to all
19+
* form inputs for changes. Call PreviewManager.refresh() after
20+
* dynamically adding form elements (e.g. new list items).
21+
*/
22+
const PreviewManager = (() => {
23+
let timeout = null;
24+
let pluginId = null;
25+
let requiredFields = {};
26+
27+
function render(containerId) {
28+
const container = document.getElementById(containerId);
29+
if (!container) return;
30+
31+
container.innerHTML = `
32+
<div class="form-group">
33+
<label class="form-label">Preview</label>
34+
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
35+
<div style="flex: 1; min-width: 200px; max-width: 45%;">
36+
<p style="margin: 0 0 5px 0; font-size: 12px; color: var(--text-muted, #666);">Current</p>
37+
<div style="border: 1px solid var(--border-color, #ccc); border-radius: 8px; overflow: hidden; background: #f5f5f5;">
38+
<img id="preview-current"
39+
src="/static/images/current_image.png?${Date.now()}"
40+
alt="Current Display"
41+
style="width: 100%; display: block;"
42+
onerror="this.style.display='none'">
43+
</div>
44+
</div>
45+
<div style="flex: 1; min-width: 200px; max-width: 45%;">
46+
<p style="margin: 0 0 5px 0; font-size: 12px; color: var(--text-muted, #666);">Preview <span id="preview-status" style="font-style: italic;"></span></p>
47+
<div style="border: 2px dashed var(--primary-color, #4CAF50); border-radius: 8px; overflow: hidden; background: #f5f5f5;">
48+
<img id="preview-image"
49+
alt="Preview with new settings"
50+
style="width: 100%; display: block;">
51+
</div>
52+
</div>
53+
</div>
54+
</div>`;
55+
}
56+
57+
function checkRequired() {
58+
const form = document.querySelector('form');
59+
if (!form) return null;
60+
for (const [field, label] of Object.entries(requiredFields)) {
61+
if (field.endsWith('[]')) {
62+
const elements = form.querySelectorAll(`[name="${field}"]`);
63+
const hasValue = Array.from(elements).some(el => el.value.trim());
64+
if (!hasValue) return label;
65+
} else {
66+
const el = form.querySelector(`[name="${field}"]`);
67+
if (!el || !el.value.trim()) return label;
68+
}
69+
}
70+
return null;
71+
}
72+
73+
function generate() {
74+
clearTimeout(timeout);
75+
timeout = setTimeout(async () => {
76+
const statusEl = document.getElementById('preview-status');
77+
const previewImg = document.getElementById('preview-image');
78+
if (!statusEl || !previewImg) return;
79+
80+
const missingLabel = checkRequired();
81+
if (missingLabel) {
82+
previewImg.style.display = 'none';
83+
statusEl.textContent = `(${missingLabel})`;
84+
return;
85+
}
86+
87+
statusEl.textContent = '(loading...)';
88+
89+
const form = document.querySelector('form');
90+
if (!form) return;
91+
92+
const formData = new FormData(form);
93+
formData.append('plugin_id', pluginId);
94+
95+
try {
96+
const response = await fetch('/preview', {
97+
method: 'POST',
98+
body: formData
99+
});
100+
101+
const result = await response.json();
102+
if (result.success) {
103+
previewImg.src = result.image;
104+
previewImg.style.display = 'block';
105+
statusEl.textContent = '';
106+
} else {
107+
previewImg.style.display = 'none';
108+
const err = result.error || '';
109+
if (err.includes('Chromium') || err.includes('NoneType')) {
110+
statusEl.textContent = '(needs Chromium — works on Pi)';
111+
} else if (err.includes('required')) {
112+
// Missing required field — show friendly hint
113+
const field = err.match(/(\w+) is required/i);
114+
statusEl.textContent = field ? `(no ${field[1].toLowerCase()} set yet)` : '(fill in required fields)';
115+
} else {
116+
// Show the actual error (strip "An error occurred: " prefix)
117+
const msg = err.replace(/^An error occurred:\s*/i, '');
118+
statusEl.textContent = msg ? `(${msg})` : '(preview unavailable)';
119+
}
120+
console.error('Preview error:', err);
121+
}
122+
} catch (err) {
123+
previewImg.style.display = 'none';
124+
statusEl.textContent = '(preview unavailable)';
125+
console.error('Preview error:', err);
126+
}
127+
}, 500);
128+
}
129+
130+
function listenToForm() {
131+
document.querySelectorAll('form select, form input, form textarea').forEach(el => {
132+
if (el.dataset.previewBound) return;
133+
el.addEventListener('change', generate);
134+
el.addEventListener('input', generate);
135+
el.dataset.previewBound = '1';
136+
});
137+
}
138+
139+
return {
140+
/**
141+
* Initialize preview for a plugin.
142+
* @param {string} id - The plugin_id (e.g. 'clock', 'countdown')
143+
* @param {Object} [options] - Configuration options
144+
* @param {Object} [options.required] - Map of field names to hint messages for client-side validation
145+
* @param {string} [options.containerId='preview-widget'] - ID of the container element
146+
*/
147+
init(id, options = {}) {
148+
pluginId = id;
149+
requiredFields = options.required || {};
150+
render(options.containerId || 'preview-widget');
151+
listenToForm();
152+
generate();
153+
},
154+
155+
/** Trigger a preview refresh (call after dynamically adding form elements) */
156+
refresh() {
157+
listenToForm();
158+
generate();
159+
},
160+
161+
/** Refresh the "current" image (call after a successful Update Now) */
162+
refreshCurrent() {
163+
const img = document.getElementById('preview-current');
164+
if (img) img.src = '/static/images/current_image.png?' + Date.now();
165+
}
166+
};
167+
})();

src/templates/plugin.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<script src="{{ url_for('static', filename='scripts/dark_mode.js') }}"></script>
1818
<script src="{{ url_for('static', filename='scripts/response_modal.js') }}"></script>
1919
<script src="{{ url_for('static', filename='scripts/refresh_settings_manager.js') }}"></script>
20+
<script src="{{ url_for('static', filename='scripts/preview_manager.js') }}"></script>
2021
<!-- Select2 CSS -->
2122
<link href="{{ url_for('static', filename='styles/select2.min.css') }}" rel="stylesheet" />
2223
<!-- jQuery -->

0 commit comments

Comments
 (0)