mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
fix(web): render file-upload drop zone for string-type config fields (#271)
* feat: add March Madness plugin and tournament round logos New dedicated March Madness plugin with scrolling tournament ticker: - Fetches NCAA tournament data from ESPN scoreboard API - Shows seeded matchups with team logos, live scores, and round separators - Highlights upsets (higher seed beating lower seed) in gold - Auto-enables during tournament window (March 10 - April 10) - Configurable for NCAAM and NCAAW tournaments - Vegas mode support via get_vegas_content() Tournament round logo assets: - MARCH_MADNESS.png, ROUND_64.png, ROUND_32.png - SWEET_16.png, ELITE_8.png, FINAL_4.png, CHAMPIONSHIP.png Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(store): prevent bulk-update from stalling on bundled/in-repo plugins Three related bugs caused the bulk plugin update to stall at 3/19: 1. Bundled plugins (e.g. starlark-apps, shipped with LEDMatrix rather than the plugin registry) had no metadata file, so update_plugin() returned False → API returned 500 → frontend queue halted. Fix: check for .plugin_metadata.json with install_type=bundled and return True immediately (these plugins update with LEDMatrix itself). 2. git config --get remote.origin.url (without --local) walked up the directory tree and found the parent LEDMatrix repo's remote URL for plugins that live inside plugin-repos/. This caused the store manager to attempt a 60-second git clone of the wrong repo for every update. Fix: use --local to scope the lookup to the plugin directory only. 3. hello-world manifest.json had a trailing comma causing JSON parse errors on every plugin discovery cycle (fixed on devpi directly). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(march-madness): address PR #263 code review findings - Replace self.is_enabled with BasePlugin.self.enabled in update(), display(), and supports_dynamic_duration() so runtime toggles work - Support quarter-based period labels for NCAAW (Q1..Q4 vs H1..H2), detected via league key or status_detail content - Use live refresh interval (60s) for cache max_age during live games instead of hardcoded 300s - Narrow broad except in _load_round_logos to (OSError, ValueError) with a fallback except Exception using logger.exception for traces - Remove unused `situation` local variable from _parse_event() - Add numpy>=1.24.0 to requirements.txt (imported but was missing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web): render file-upload drop zone for string-type config fields String fields with x-widget: "file-upload" were falling through to a plain text input because the template only handled the array case. Adds a dedicated drop zone branch for string fields and corresponding handleSingleFileSelect/handleSingleFileUpload JS handlers that POST to the x-upload-config endpoint. Fixes credentials.json upload for the calendar plugin. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(march-madness): address PR #271 code review findings Inline fixes: - manager.py: swap min_duration/max_duration if misconfigured, log warning - manager.py: call session.close() and null session in cleanup() to prevent socket leaks on constrained hardware - manager.py: remove blocking network I/O from display(); update() is the sole fetch path (already uses 60s live-game interval) - manager.py: guard scroll_helper None before create_scrolling_image() in _create_ticker_image() to prevent crash when ScrollHelper is unavailable - store_manager.py: replace bare "except Exception: pass" with debug log including plugin_id and path when reading .plugin_metadata.json - file-upload.js: add endpoint guard (error if uploadEndpoint is falsy), client-side extension validation from data-allowed-extensions, and response.ok check before response.json() in handleSingleFileUpload - plugin_config.html: add data-allowed-extensions attribute to single-file input so JS handler can read the allowed extensions list Nitpick fixes: - manager.py: use logger.exception() (includes traceback) instead of logger.error() for league fetch errors - manager.py: remove redundant "{e}" from logger.exception() calls for round logo and March Madness logo load errors Not fixed (by design): - manifest.json repo naming: monorepo pattern is correct per project docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(march-madness): address second round of PR #271 code review findings Inline fixes: - requirements.txt: bump Pillow to >=9.1.0 (required for Image.Resampling.LANCZOS) - file-upload.js: replace all statusDiv.innerHTML assignments with safe DOM creation (textContent + createElement) to prevent XSS from untrusted strings - plugin_config.html: add role="button", tabindex="0", aria-label, onkeydown (Enter/Space) to drop zone for keyboard accessibility; add aria-live="polite" to status div for screen-reader announcements - file-upload.js: tighten handleFileDrop endpoint check to non-empty string (dataset.uploadEndpoint.trim() !== '') so an empty attribute falls back to the multi-file handler Nitpick fixes: - manager.py: remove redundant cached_image/cached_array reassignments after create_scrolling_image() which already sets them internally - manager.py: narrow bare except in _get_team_logo to (FileNotFoundError, OSError, ValueError) for expected I/O errors; log unexpected exceptions - store_manager.py: narrow except to (OSError, ValueError) when reading .plugin_metadata.json so unrelated exceptions propagate Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -71,7 +71,12 @@
|
||||
window.handleFileDrop = function(event, fieldId) {
|
||||
event.preventDefault();
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
if (files.length === 0) return;
|
||||
// Route to single-file handler if this is a string file-upload widget
|
||||
const fileInput = document.getElementById(`${fieldId}_file_input`);
|
||||
if (fileInput && fileInput.dataset.uploadEndpoint && fileInput.dataset.uploadEndpoint.trim() !== '') {
|
||||
window.handleSingleFileUpload(fieldId, files[0]);
|
||||
} else {
|
||||
window.handleFiles(fieldId, Array.from(files));
|
||||
}
|
||||
};
|
||||
@@ -88,6 +93,118 @@
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle single-file select for string file-upload widgets (e.g. credentials.json)
|
||||
* @param {Event} event - Change event
|
||||
* @param {string} fieldId - Field ID
|
||||
*/
|
||||
window.handleSingleFileSelect = function(event, fieldId) {
|
||||
const files = event.target.files;
|
||||
if (files.length > 0) {
|
||||
window.handleSingleFileUpload(fieldId, files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a single file for string file-upload widgets
|
||||
* Reads upload config from data attributes on the file input element.
|
||||
* @param {string} fieldId - Field ID
|
||||
* @param {File} file - File to upload
|
||||
*/
|
||||
window.handleSingleFileUpload = async function(fieldId, file) {
|
||||
const fileInput = document.getElementById(`${fieldId}_file_input`);
|
||||
if (!fileInput) return;
|
||||
|
||||
const uploadEndpoint = fileInput.dataset.uploadEndpoint;
|
||||
const targetFilename = fileInput.dataset.targetFilename || 'file.json';
|
||||
const maxSizeMB = parseFloat(fileInput.dataset.maxSizeMb || '1');
|
||||
const allowedExtensions = (fileInput.dataset.allowedExtensions || '.json')
|
||||
.split(',').map(e => e.trim().toLowerCase());
|
||||
|
||||
const statusDiv = document.getElementById(`${fieldId}_upload_status`);
|
||||
const notifyFn = window.showNotification || console.log;
|
||||
|
||||
// Guard: endpoint must be configured
|
||||
if (!uploadEndpoint) {
|
||||
notifyFn('No upload endpoint configured for this field', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate extension
|
||||
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
|
||||
if (!allowedExtensions.includes(fileExt)) {
|
||||
notifyFn(`File must be one of: ${allowedExtensions.join(', ')}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||
notifyFn(`File exceeds ${maxSizeMB}MB limit`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-2 text-xs text-gray-500';
|
||||
statusDiv.textContent = '';
|
||||
const spinner = document.createElement('i');
|
||||
spinner.className = 'fas fa-spinner fa-spin mr-1';
|
||||
statusDiv.appendChild(spinner);
|
||||
statusDiv.appendChild(document.createTextNode('Uploading...'));
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(uploadEndpoint, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Server error ${response.status}: ${body}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-2 text-xs text-green-600';
|
||||
statusDiv.textContent = '';
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-check-circle mr-1';
|
||||
statusDiv.appendChild(icon);
|
||||
statusDiv.appendChild(document.createTextNode(`Uploaded: ${targetFilename}`));
|
||||
}
|
||||
// Update hidden input with the target filename
|
||||
const hiddenInput = document.getElementById(fieldId);
|
||||
if (hiddenInput) hiddenInput.value = targetFilename;
|
||||
notifyFn(`${targetFilename} uploaded successfully`, 'success');
|
||||
} else {
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-2 text-xs text-red-600';
|
||||
statusDiv.textContent = '';
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-exclamation-circle mr-1';
|
||||
statusDiv.appendChild(icon);
|
||||
statusDiv.appendChild(document.createTextNode(`Upload failed: ${data.message}`));
|
||||
}
|
||||
notifyFn(`Upload failed: ${data.message}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-2 text-xs text-red-600';
|
||||
statusDiv.textContent = '';
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-exclamation-circle mr-1';
|
||||
statusDiv.appendChild(icon);
|
||||
statusDiv.appendChild(document.createTextNode(`Upload error: ${error.message}`));
|
||||
}
|
||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle multiple files upload
|
||||
* @param {string} fieldId - Field ID
|
||||
|
||||
@@ -537,7 +537,45 @@
|
||||
{% else %}
|
||||
{% set str_widget = prop.get('x-widget') or prop.get('x_widget') %}
|
||||
{% set str_value = value if value is not none else (prop.default if prop.default is defined else '') %}
|
||||
{% if str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
|
||||
{% if str_widget == 'file-upload' %}
|
||||
{# Single-file upload widget for string fields (e.g., credentials.json) #}
|
||||
{% set upload_config = prop.get('x-upload-config') or {} %}
|
||||
{% set upload_endpoint = upload_config.get('upload_endpoint', '') %}
|
||||
{% set target_filename = upload_config.get('target_filename', 'file.json') %}
|
||||
{% set max_size_mb = upload_config.get('max_size_mb', 1) %}
|
||||
{% set allowed_extensions = upload_config.get('allowed_extensions', ['.json']) %}
|
||||
<div id="{{ field_id }}_upload_widget" class="mt-1">
|
||||
<div id="{{ field_id }}_drop_zone"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Upload {{ target_filename }}"
|
||||
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
|
||||
ondragover="event.preventDefault()"
|
||||
data-field-id="{{ field_id }}"
|
||||
onclick="document.getElementById('{{ field_id }}_file_input').click()"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('{{ field_id }}_file_input').click();}">
|
||||
<input type="file"
|
||||
id="{{ field_id }}_file_input"
|
||||
accept="{{ allowed_extensions|join(',') }}"
|
||||
style="display: none;"
|
||||
data-field-id="{{ field_id }}"
|
||||
data-upload-endpoint="{{ upload_endpoint }}"
|
||||
data-target-filename="{{ target_filename }}"
|
||||
data-max-size-mb="{{ max_size_mb }}"
|
||||
data-allowed-extensions="{{ allowed_extensions|join(',') }}"
|
||||
onchange="window.handleSingleFileSelect(event, this.dataset.fieldId)">
|
||||
<i class="fas fa-cloud-upload-alt text-2xl text-gray-400 mb-1"></i>
|
||||
<p class="text-sm text-gray-600">Click to upload {{ target_filename }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Max {{ max_size_mb }}MB ({{ allowed_extensions|join(', ') }})</p>
|
||||
</div>
|
||||
<div id="{{ field_id }}_upload_status" class="mt-2 text-xs text-gray-500" aria-live="polite"></div>
|
||||
<input type="hidden"
|
||||
id="{{ field_id }}"
|
||||
name="{{ full_key }}"
|
||||
value="{{ str_value }}">
|
||||
</div>
|
||||
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
|
||||
{# Render widget container #}
|
||||
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user