From a5c10d6f78f2277fa29fb820a3bff2e1ed6785f4 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:04:21 -0500 Subject: [PATCH] fix(web-ui): Fix file upload widget and plugin action buttons (#165) * fix(plugins): Resolve plugin ID determination error in action buttons - Fix server-side template parameter order for executePluginAction - Add data-plugin-id attributes to action buttons in all templates - Enhance executePluginAction with comprehensive fallback logic - Support retrieving pluginId from DOM, Alpine context, and config state - Fixes 'Unable to determine plugin ID' error for Spotify/YouTube auth * fix(plugins): Add missing button IDs and status divs in server-side action template - Add action-{id}-{index} IDs to action buttons - Add action-status-{id}-{index} status divs for each action - Match client-side template structure for consistency - Fixes 'Action elements not found' error * fix(api): Fix indentation error in execute_plugin_action function - Fix incorrect else block indentation that caused 500 errors - Correct indentation for OAuth flow and simple script execution paths - Resolves syntax error preventing plugin actions from executing * fix(api): Improve error handling for plugin actions and config saves - Add better JSON parsing error handling with request details - Add detailed permission error messages for secrets file saves - Include file path and permission status in error responses - Helps diagnose 400 errors on action execution and 500 errors on config saves * fix(api): Add detailed permission error handling for secrets config saves - Add PermissionError-specific handling with permission checks - Include directory and file permission status in error logs - Provide more helpful error messages with file paths - Helps diagnose permission issues when saving config_secrets.json * fix(config): Add permission check and actionable error message for config saves - Check file writability before attempting write - Show file owner and current permissions in error message - Provide exact command to fix permissions (chown + chmod) - Helps diagnose and resolve permission issues with config_secrets.json * fix(config): Preserve detailed permission error messages - Handle PermissionError separately to preserve detailed error messages - Ensure actionable permission fix commands are included in error response - Prevents detailed error messages from being lost in exception chain * fix(config): Remove overly strict pre-write permission check - Remove pre-write file existence/writability check that was blocking valid writes - Let actual file write operation determine success/failure - Provide detailed error messages only when write actually fails - Fixes regression where config_secrets.json saves were blocked unnecessarily * fix(config): Use atomic writes for config_secrets.json to handle permission issues - Write to temp file first, then atomically move to final location - Works even when existing file isn't writable (as long as directory is writable) - Matches pattern used elsewhere in codebase (disk_cache, atomic_manager) - Fixes permission errors when saving secrets configuration * chore: Update music plugin submodule to include live_priority fix * fix(plugins): Improve plugin ID determination in dynamic button generation - Update generateFormFromSchema to pass currentPluginConfig?.pluginId and add data attributes - Update generateSimpleConfigForm to pass currentPluginConfig?.pluginId and add data attributes - Scope fallback 6 DOM lookup to button context instead of document-wide search - Ensures correct plugin tab selection when multiple plugins are present - Maintains existing try/catch error handling and logging * chore: Update music plugin submodule to fix has_live_priority enabled attribute * chore: Update music plugin submodule - remove redundant music_priority_mode * fix(web-ui): Fix file upload widget detection for nested plugin properties - Added helper function to get schema properties by full key path - Enhanced x-widget detection to check both property object and schema directly - Improved upload config retrieval with fallback to schema - Added debug logging for file-upload widget detection - Fixes issue where static-image plugin file upload widget was not rendering The file upload widget was not being detected for nested properties like image_config.images because the x-widget attribute wasn't being checked in the schema directly. This fix ensures the widget is properly detected and rendered even when nested deep in the configuration structure. * fix(web-ui): Improve file upload widget detection with direct schema fallback - Fixed getSchemaProperty helper function to correctly navigate nested paths - Added direct schema lookup fallback for image_config.images path - Enhanced debug logging to diagnose widget detection issues - Simplified widget detection logic while maintaining robustness * fix(web-ui): Add aggressive schema lookup for file-upload widget detection - Always try direct schema navigation for image_config.images - Added general direct lookup fallback if getSchemaProperty fails - Enhanced debug logging with schema existence checks - Prioritize schema lookup over prop object for x-widget detection * fix(web-ui): Add direct check for top-level images field in file upload detection - Added specific check for top-level 'images' field (flattened schema) - Enhanced debug logging to show all x-widget detection attempts - Improved widget detection to check prop object more thoroughly * fix(web-ui): Prioritize prop object for x-widget detection - Check prop object first (should have x-widget from schema) - Then fall back to schema lookup - Enhanced debug logging to show all detection attempts * fix(web-ui): Add aggressive direct detection for images file upload widget - Added direct check for 'images' field in schema.properties.images - Multiple fallback detection methods (direct, prop object, schema lookup) - Simplified logic to explicitly check for file-upload widget - Enhanced debug logging to show detection path * fix(web-ui): Add file upload widget support to server-side Jinja2 template - Added check for x-widget: file-upload in array field rendering - Renders file upload drop zone with drag-and-drop support - Displays uploaded images list with delete and schedule buttons - Falls back to comma-separated text input for regular arrays - Fixes file upload widget not appearing in static-image plugin * feat(web-ui): Add route to serve plugin asset files from assets directory - Added /assets/plugins//uploads/ route - Serves uploaded images and other assets with proper content types - Includes security checks to prevent directory traversal - Fixes 404 errors when displaying uploaded plugin images * fix(web-ui): Fix import for send_from_directory in plugin assets route * feat(web-ui): Load uploaded images from metadata file when rendering config form - Populates images field from .metadata.json if not in config - Ensures uploaded images appear in form even before config is saved - Merges metadata images with existing config images to avoid duplicates * fix(web-ui): Fix PROJECT_ROOT reference in image metadata loading * docs(web-ui): Add reminder to save configuration after file upload - Added informational note below upload widget - Reminds users to save config after uploading files - Uses amber color and info icon for visibility * fix(web-ui): Move plugin asset serving route to main app - Moved /assets/plugins/... route from api_v3 blueprint to main app - Blueprint has /api/v3 prefix, but route needs to be at /assets/... - Fixes 404 errors when trying to display uploaded images - Route must be on main app for correct URL path * security(web-ui): Fix path containment check in plugin asset serving - Replace string startswith() with proper path resolution using os.path.commonpath() - Prevents prefix-based directory traversal bypasses - Uses resolved absolute paths to ensure true path containment - Handles ValueError for cross-drive paths (Windows compatibility) * security(web-ui): Remove traceback exposure from plugin asset serving errors - Return generic error message instead of full traceback in production - Log exceptions server-side using app.logger.exception() - Only include detailed error information when app.debug is True - Prevents leaking internal implementation details to clients * fix(web-ui): Assign currentPluginConfig to window for template access - Assign currentPluginConfig to window.currentPluginConfig when building the object - Fixes empty pluginId in template interpolation for plugin action buttons - Ensures window.currentPluginConfig?.pluginId is available in onclick handlers - Prevents executePluginAction from receiving empty pluginId parameter * chore: Update music plugin submodule to include .gitignore --------- Co-authored-by: Chuck --- plugins/ledmatrix-music | 2 +- web_interface/app.py | 85 ++++++++++++- web_interface/blueprints/api_v3.py | 3 +- web_interface/blueprints/pages_v3.py | 34 +++++ web_interface/static/v3/plugins_manager.js | 6 + web_interface/templates/v3/base.html | 118 +++++++++++++++++- .../templates/v3/partials/plugin_config.html | 113 +++++++++++++++-- 7 files changed, 346 insertions(+), 15 deletions(-) diff --git a/plugins/ledmatrix-music b/plugins/ledmatrix-music index 3bcae7bf..ded4fe91 160000 --- a/plugins/ledmatrix-music +++ b/plugins/ledmatrix-music @@ -1 +1 @@ -Subproject commit 3bcae7bf8471a4525430d1f690fa99e05d37c2c4 +Subproject commit ded4fe91e4fa337c53641bc3174197eac86d246a diff --git a/web_interface/app.py b/web_interface/app.py index 6fdf7067..5c03d0c2 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -1,4 +1,4 @@ -from flask import Flask, Blueprint, render_template, request, redirect, url_for, flash, jsonify, Response +from flask import Flask, Blueprint, render_template, request, redirect, url_for, flash, jsonify, Response, send_from_directory import json import os import sys @@ -141,6 +141,89 @@ api_v3.cache_manager = CacheManager() app.register_blueprint(pages_v3, url_prefix='/v3') app.register_blueprint(api_v3, url_prefix='/api/v3') +# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path) +@app.route('/assets/plugins//uploads/', methods=['GET']) +def serve_plugin_asset(plugin_id, filename): + """Serve uploaded asset files from assets/plugins/{plugin_id}/uploads/""" + try: + # Build the asset directory path + assets_dir = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' + assets_dir = assets_dir.resolve() + + # Security check: ensure the assets directory exists and is within project_root + if not assets_dir.exists() or not assets_dir.is_dir(): + return jsonify({'status': 'error', 'message': 'Asset directory not found'}), 404 + + # Ensure we're serving from within the assets directory (prevent directory traversal) + # Use proper path resolution instead of string prefix matching to prevent bypasses + assets_dir_resolved = assets_dir.resolve() + project_root_resolved = project_root.resolve() + + # Check that assets_dir is actually within project_root using commonpath + try: + common_path = os.path.commonpath([str(assets_dir_resolved), str(project_root_resolved)]) + if common_path != str(project_root_resolved): + return jsonify({'status': 'error', 'message': 'Invalid asset path'}), 403 + except ValueError: + # commonpath raises ValueError if paths are on different drives (Windows) + return jsonify({'status': 'error', 'message': 'Invalid asset path'}), 403 + + # Resolve the requested file path + requested_file = (assets_dir / filename).resolve() + + # Security check: ensure file is within the assets directory using proper path comparison + # Use commonpath to ensure assets_dir is a true parent of requested_file + try: + common_path = os.path.commonpath([str(requested_file), str(assets_dir_resolved)]) + if common_path != str(assets_dir_resolved): + return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403 + except ValueError: + # commonpath raises ValueError if paths are on different drives (Windows) + return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403 + + # Check if file exists + if not requested_file.exists() or not requested_file.is_file(): + return jsonify({'status': 'error', 'message': 'File not found'}), 404 + + # Determine content type based on file extension + content_type = 'application/octet-stream' + if filename.lower().endswith(('.png', '.jpg', '.jpeg')): + content_type = 'image/jpeg' if filename.lower().endswith(('.jpg', '.jpeg')) else 'image/png' + elif filename.lower().endswith('.gif'): + content_type = 'image/gif' + elif filename.lower().endswith('.bmp'): + content_type = 'image/bmp' + elif filename.lower().endswith('.webp'): + content_type = 'image/webp' + elif filename.lower().endswith('.svg'): + content_type = 'image/svg+xml' + elif filename.lower().endswith('.json'): + content_type = 'application/json' + elif filename.lower().endswith('.txt'): + content_type = 'text/plain' + + # Use send_from_directory to serve the file + return send_from_directory(str(assets_dir), filename, mimetype=content_type) + + except Exception as e: + # Log the exception with full traceback server-side + import traceback + app.logger.exception('Error serving plugin asset file') + + # Return generic error message to client (avoid leaking internal details) + # Only include detailed error information when in debug mode + if app.debug: + return jsonify({ + 'status': 'error', + 'message': str(e), + 'traceback': traceback.format_exc() + }), 500 + else: + return jsonify({ + 'status': 'error', + 'message': 'Internal server error' + }), 500 + # Helper function to check if AP mode is active def is_ap_mode_active(): """ diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 73867d1e..dcc4653b 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -1,4 +1,4 @@ -from flask import Blueprint, request, jsonify, Response +from flask import Blueprint, request, jsonify, Response, send_from_directory import json import os import sys @@ -5319,6 +5319,7 @@ def serve_plugin_static(plugin_id, file_path): import traceback return jsonify({'status': 'error', 'message': str(e), 'traceback': traceback.format_exc()}), 500 + @api_v3.route('/plugins/calendar/upload-credentials', methods=['POST']) def upload_calendar_credentials(): """Upload credentials.json file for calendar plugin""" diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 5c0c8f2e..43ce3324 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -336,6 +336,40 @@ def _load_plugin_config_partial(plugin_id): full_config = pages_v3.config_manager.load_config() config = full_config.get(plugin_id, {}) + # Load uploaded images from metadata file if images field exists in schema + # This ensures uploaded images appear even if config hasn't been saved yet + schema_path_temp = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json" + if schema_path_temp.exists(): + try: + with open(schema_path_temp, 'r', encoding='utf-8') as f: + temp_schema = json.load(f) + # Check if schema has an images field with x-widget: file-upload + if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or + temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'): + # Load metadata file + # Get PROJECT_ROOT relative to this file + project_root = Path(__file__).parent.parent.parent + metadata_file = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' / '.metadata.json' + if metadata_file.exists(): + try: + with open(metadata_file, 'r', encoding='utf-8') as mf: + metadata = json.load(mf) + # Convert metadata dict to list of image objects + images_from_metadata = list(metadata.values()) + # Only use metadata images if config doesn't have images or config images is empty + if not config.get('images') or len(config.get('images', [])) == 0: + config['images'] = images_from_metadata + else: + # Merge: add metadata images that aren't already in config + config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')} + new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids] + if new_images: + config['images'] = config.get('images', []) + new_images + except Exception as e: + print(f"Warning: Could not load metadata for {plugin_id}: {e}") + except Exception: + pass # Will load schema properly below + # Get plugin schema schema = {} schema_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json" diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 9b2d325f..4f918c92 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -2161,6 +2161,8 @@ function generatePluginConfigForm(pluginId, config) { schema: schemaData.data.schema, webUiActions: webUiActions }; + // Also assign to window for global access in template interpolations + window.currentPluginConfig = currentPluginConfig; // Also update state currentPluginConfigState.schema = schemaData.data.schema; console.log('[DEBUG] Calling generateFormFromSchema...'); @@ -2168,12 +2170,16 @@ function generatePluginConfigForm(pluginId, config) { } else { // Fallback to simple form if no schema currentPluginConfig = { pluginId: pluginId, schema: null, webUiActions: webUiActions }; + // Also assign to window for global access in template interpolations + window.currentPluginConfig = currentPluginConfig; return generateSimpleConfigForm(config, webUiActions); } }) .catch(error => { console.error('Error loading schema:', error); currentPluginConfig = { pluginId: pluginId, schema: null, webUiActions: [] }; + // Also assign to window for global access in template interpolations + window.currentPluginConfig = currentPluginConfig; return generateSimpleConfigForm(config, []); }); } diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index e27105e4..e765533f 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -2509,17 +2509,75 @@ // Only log once per plugin to avoid spam (Alpine.js may call this multiple times during rendering) if (!this._configFormLogged || this._configFormLogged !== pluginId) { console.log('[DEBUG] generateConfigForm called for', pluginId, 'with', webUiActions?.length || 0, 'actions'); + // Debug: Check if image_config.images has x-widget in schema + if (schema && schema.properties && schema.properties.image_config) { + const imgConfig = schema.properties.image_config; + if (imgConfig.properties && imgConfig.properties.images) { + const imagesProp = imgConfig.properties.images; + console.log('[DEBUG] Schema check - image_config.images:', { + type: imagesProp.type, + 'x-widget': imagesProp['x-widget'], + 'has x-widget': 'x-widget' in imagesProp, + keys: Object.keys(imagesProp) + }); + } + } this._configFormLogged = pluginId; } if (!schema || !schema.properties) { return this.generateSimpleConfigForm(config, webUiActions, pluginId); } + // Helper function to get schema property by full key path + const getSchemaProperty = (schemaObj, keyPath) => { + if (!schemaObj || !schemaObj.properties) return null; + const keys = keyPath.split('.'); + let current = schemaObj.properties; + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (!current || !current[k]) { + return null; + } + + const prop = current[k]; + // If this is the last key, return the property + if (i === keys.length - 1) { + return prop; + } + + // If this property has nested properties, navigate deeper + if (prop && typeof prop === 'object' && prop.properties) { + current = prop.properties; + } else { + // Can't navigate deeper + return null; + } + } + return null; + }; + const generateFieldHtml = (key, prop, value, prefix = '') => { const fullKey = prefix ? `${prefix}.${key}` : key; const label = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); const description = prop.description || ''; let html = ''; + + // Debug: Log property structure for arrays to help diagnose file-upload widget issues + if (prop.type === 'array') { + // Also check schema directly as fallback + const schemaProp = getSchemaProperty(schema, fullKey); + const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null; + + console.log('[DEBUG generateFieldHtml] Array property:', fullKey, { + 'prop.x-widget': prop['x-widget'], + 'prop.x_widget': prop['x_widget'], + 'schema.x-widget': xWidgetFromSchema, + 'hasOwnProperty(x-widget)': prop.hasOwnProperty('x-widget'), + 'x-widget in prop': 'x-widget' in prop, + 'all prop keys': Object.keys(prop), + 'schemaProp keys': schemaProp ? Object.keys(schemaProp) : 'null' + }); + } // Handle nested objects if (prop.type === 'object' && prop.properties) { @@ -2575,6 +2633,12 @@ const nestedValue = hasValue ? nestedConfig[nestedKey] : (nestedProp.default !== undefined ? nestedProp.default : (isNestedObject ? {} : (nestedProp.type === 'array' ? [] : (nestedProp.type === 'boolean' ? false : '')))); + + // Debug logging for file-upload widgets + if (nestedProp.type === 'array' && (nestedProp['x-widget'] === 'file-upload' || nestedProp['x_widget'] === 'file-upload')) { + console.log('[DEBUG] Found file-upload widget in nested property:', nestedKey, 'fullKey:', fullKey + '.' + nestedKey, 'prop:', nestedProp); + } + html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey); }); @@ -2651,11 +2715,59 @@ html += helpText; } } else if (prop.type === 'array') { - // Check if this is a file upload widget - if (prop['x-widget'] === 'file-upload') { + // AGGRESSIVE file upload widget detection + // For 'images' field in static-image plugin, always check schema directly + let isFileUpload = false; + let uploadConfig = {}; + + // Direct check: if this is the 'images' field and schema has it with x-widget + if (fullKey === 'images' && schema && schema.properties && schema.properties.images) { + const imagesSchema = schema.properties.images; + if (imagesSchema['x-widget'] === 'file-upload' || imagesSchema['x_widget'] === 'file-upload') { + isFileUpload = true; + uploadConfig = imagesSchema['x-upload-config'] || imagesSchema['x_upload_config'] || {}; + console.log('[DEBUG] ✅ Direct detection: images field has file-upload widget', uploadConfig); + } + } + + // Fallback: check prop object (should have x-widget if schema loaded correctly) + if (!isFileUpload) { + const xWidgetFromProp = prop['x-widget'] || prop['x_widget'] || prop.xWidget; + if (xWidgetFromProp === 'file-upload') { + isFileUpload = true; + uploadConfig = prop['x-upload-config'] || prop['x_upload_config'] || {}; + console.log('[DEBUG] ✅ Detection via prop object'); + } + } + + // Fallback: schema property lookup + if (!isFileUpload) { + let schemaProp = getSchemaProperty(schema, fullKey); + if (!schemaProp && fullKey === 'images' && schema && schema.properties && schema.properties.images) { + schemaProp = schema.properties.images; + } + const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null; + if (xWidgetFromSchema === 'file-upload') { + isFileUpload = true; + uploadConfig = schemaProp['x-upload-config'] || schemaProp['x_upload_config'] || {}; + console.log('[DEBUG] ✅ Detection via schema lookup'); + } + } + + // Debug logging for ALL array fields to diagnose + console.log('[DEBUG] Array field check:', fullKey, { + 'isFileUpload': isFileUpload, + 'prop keys': Object.keys(prop), + 'prop.x-widget': prop['x-widget'], + 'schema.properties.images exists': !!(schema && schema.properties && schema.properties.images), + 'schema.properties.images.x-widget': (schema && schema.properties && schema.properties.images) ? schema.properties.images['x-widget'] : null, + 'uploadConfig': uploadConfig + }); + + if (isFileUpload) { + console.log('[DEBUG] ✅ Rendering file-upload widget for', fullKey, 'with config:', uploadConfig); // Use the file upload widget from plugins.html // We'll need to call a function that exists in the global scope - const uploadConfig = prop['x-upload-config'] || {}; const maxFiles = uploadConfig.max_files || 10; const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']; const maxSizeMB = uploadConfig.max_size_mb || 5; diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 292aee4f..7b8f7619 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -58,16 +58,111 @@ {% if field_type == 'integer' %}step="1"{% else %}step="any"{% endif %} class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500"> - {# Array of strings (comma-separated) #} + {# Array - check if it's a file upload widget #} {% elif field_type == 'array' %} - {% set array_value = value if value is not none else (prop.default if prop.default is defined else []) %} - -

Separate multiple values with commas

+ {% set x_widget = prop.get('x-widget') or prop.get('x_widget') %} + {% if x_widget == 'file-upload' %} + {# File upload widget for arrays #} + {% set upload_config = prop.get('x-upload-config') or {} %} + {% set max_files = upload_config.get('max_files', 10) %} + {% set allowed_types = upload_config.get('allowed_types', ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']) %} + {% set max_size_mb = upload_config.get('max_size_mb', 5) %} + {% set plugin_id_from_config = upload_config.get('plugin_id', plugin_id) %} + {% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %} + +
+ +
+ + +

Drag and drop images here or click to browse

+

Max {{ max_files }} files, {{ max_size_mb }}MB each (PNG, JPG, GIF, BMP)

+
+

+ + Remember to save configuration after upload +

+ + +
+ {% for img in array_value %} + {% set img_id = img.get('id', loop.index0) %} + {% set img_schedule = img.get('schedule', {}) %} + {% set has_schedule = img_schedule.get('enabled', false) and img_schedule.get('mode') and img_schedule.get('mode') != 'always' %} +
+
+
+ {{ img.get('filename', '') }} + +
+

{{ img.get('original_filename') or img.get('filename', 'Image') }}

+

+ {% if img.get('size') %}{{ (img.get('size') / 1024)|round }} KB{% endif %} + {% if img.get('uploaded_at') %} • {{ img.get('uploaded_at') }}{% endif %} +

+ {% if has_schedule %} +

+ Scheduled +

+ {% endif %} +
+
+
+ + +
+
+ +
+ {% endfor %} +
+ + + +
+ {% else %} + {# Regular array input (comma-separated) #} + {% set array_value = value if value is not none else (prop.default if prop.default is defined else []) %} + +

Separate multiple values with commas

+ {% endif %} {# Text input (default) #} {% else %}