|
| 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 | +})(); |
0 commit comments