mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
fix(ui): wrap plugin tabs to new lines instead of scrolling (#201)
* fix(ui): wrap plugin tabs to new lines instead of scrolling Change plugin tabs row from overflow-x-auto to flex-wrap so that when many plugins are installed, tabs break to new lines instead of becoming smaller or requiring horizontal scrolling. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): use gap-x instead of space-x for proper wrapped row alignment Switch from space-x-* to gap-x-* utilities so wrapped rows align correctly without indentation on subsequent lines. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): add missing flex-wrap and gap utilities to CSS The project uses hand-written Tailwind-like CSS, not actual Tailwind. Added missing utility classes needed for plugin tabs wrapping: - flex-wrap - gap-x-4, gap-x-6, gap-x-8, gap-y-2 - lg:gap-x-6, xl:gap-x-8 responsive variants Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): apply flex-wrap to system tabs row Apply the same wrapping behavior to the system tabs row (Overview, General, WiFi, etc.) so they also wrap to new lines on smaller viewports instead of scrolling. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): constrain tab container width to enable flex-wrap Add max-w-full and overflow-hidden to tab row containers to properly constrain their width, allowing flex-wrap to work correctly. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): remove overflow-hidden that was hiding tabs Revert the max-w-full overflow-hidden approach as it was hiding content. Keep both rows using flex-wrap with gap utilities. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: Add custom-leagues widget support for soccer plugin - Add server-side template rendering for x-widget="custom-leagues" - Renders table with Name, League Code, Priority, Enabled columns - Includes inline JavaScript for add/remove row functionality - Uses indexed field naming for proper array serialization - Shows common ESPN league codes as hint This enables the soccer scoreboard plugin's custom leagues feature to work properly in the web UI. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): reduce tab gap spacing for tighter layout Reduce horizontal gap between tabs from gap-x-4/6/8 to gap-x-2/3/4 for a more compact appearance. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(widget): Replace custom-leagues with generic array-table widget - Add generic array-table widget that reads columns from schema - Support x-columns to specify which columns to display - Auto-detect columns from items.properties if x-columns not specified - Remove hardcoded custom-leagues implementation - Any plugin can now use x-widget: "array-table" for array-of-objects Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): use data attributes for array table button to avoid JSON escaping issues Move JSON blobs (item_properties and display_columns) from inline onclick to data-* attributes with proper HTML entity escaping via Jinja's |e filter. Update addArrayTableRow() to read and parse these data attributes. This fixes HTML attribute breakage caused by tojson emitting double quotes inside the onclick attribute value. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): update Add button state when array table rows change Add updateAddButtonState() helper that toggles the Add button's disabled attribute and opacity based on current row count vs maxItems. Called after addArrayTableRow() and removeArrayTableRow(), and also on page load to ensure correct initial state. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): add try/catch for JSON parsing in addArrayTableRow Wrap JSON.parse calls for data-item-properties and data-display-columns in try/catch blocks with fallback to {} and [] respectively. Logs error with raw attribute values to help debug malformed JSON. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(array-table): Fix getValue input name validation and setValue Add button state sync - Fix getValue to use early-continue guard preventing errors on inputs without names - Add updateAddButtonState call in setValue to refresh Add button state after repopulating rows * fix(ui): make Configure button larger than Uninstall in plugin manager Swapped button sizes in installed plugins section - Configure button is now the largest (flex-2), Update is medium (flex-1), and Uninstall is smallest (no flex class). This prioritizes the Configure action over Uninstall. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(ui): correct forEach continue and plugin button flex sizing - Replace invalid continue with return in array-table forEach callback - Remove redundant hidden input type check in array-table getValue - Fix plugin button sizing using inline flex styles instead of invalid flex-2 class - Configure button now properly sized at flex: 2, Update and Uninstall at flex: 1 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor(ui): reorganize plugin buttons into two-row layout Configure button now takes full width on first row, while Update and Uninstall buttons share the second row evenly. This makes Configure more prominent and separates destructive actions to a second row. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(ui): override inline-flex on Configure button to enable full width The .btn class uses display: inline-flex which prevents w-full from working. Added inline style to override with display: flex and width: 100% so the Configure button properly takes the full width of its row. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(ui): use inline styles for plugin action buttons layout Replace Tailwind classes with explicit inline styles to ensure proper two-row layout for plugin action buttons. Configure button on first row at full width, Update and Uninstall sharing second row evenly. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -113,6 +113,7 @@ body {
|
||||
|
||||
.flex { display: flex; }
|
||||
.inline-flex { display: inline-flex; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.flex-shrink-0 { flex-shrink: 0; }
|
||||
.flex-1 { flex: 1; }
|
||||
.items-center { align-items: center; }
|
||||
@@ -137,6 +138,12 @@ body {
|
||||
.gap-3 { gap: 0.75rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
.gap-6 { gap: 1.5rem; }
|
||||
.gap-x-2 { column-gap: 0.5rem; }
|
||||
.gap-x-3 { column-gap: 0.75rem; }
|
||||
.gap-x-4 { column-gap: 1rem; }
|
||||
.gap-x-6 { column-gap: 1.5rem; }
|
||||
.gap-x-8 { column-gap: 2rem; }
|
||||
.gap-y-2 { row-gap: 0.5rem; }
|
||||
|
||||
/* Enhanced Typography */
|
||||
.text-xs { font-size: 0.75rem; line-height: 1.4; }
|
||||
@@ -175,6 +182,7 @@ h4 { font-size: 1.125rem; }
|
||||
.right-4 { right: 1rem; }
|
||||
|
||||
.max-w-7xl { max-width: 56rem; }
|
||||
.max-w-full { max-width: 100%; }
|
||||
.mx-auto { margin-left: auto; margin-right: auto; }
|
||||
.overflow-x-auto { overflow-x: auto; }
|
||||
.overflow-hidden { overflow: hidden; }
|
||||
@@ -339,6 +347,8 @@ a, button, input, select, textarea {
|
||||
.lg\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.lg\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||
.lg\:gap-x-3 { column-gap: 0.75rem; }
|
||||
.lg\:gap-x-6 { column-gap: 1.5rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
@@ -349,6 +359,8 @@ a, button, input, select, textarea {
|
||||
.xl\:grid-cols-8 { grid-template-columns: repeat(8, minmax(0, 1fr)); }
|
||||
.xl\:px-12 { padding-left: 3rem; padding-right: 3rem; }
|
||||
.xl\:space-x-6 > * + * { margin-left: 1.5rem; }
|
||||
.xl\:gap-x-4 { column-gap: 1rem; }
|
||||
.xl\:gap-x-8 { column-gap: 2rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
|
||||
316
web_interface/static/v3/js/widgets/array-table.js
Normal file
316
web_interface/static/v3/js/widgets/array-table.js
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Array Table Widget
|
||||
*
|
||||
* Generic table-based array-of-objects editor.
|
||||
* Handles adding, removing, and editing array items with object properties.
|
||||
* Reads column definitions from the schema's items.properties.
|
||||
*
|
||||
* Usage in config_schema.json:
|
||||
* "my_array": {
|
||||
* "type": "array",
|
||||
* "x-widget": "array-table",
|
||||
* "x-columns": ["name", "code", "priority", "enabled"], // optional
|
||||
* "items": {
|
||||
* "type": "object",
|
||||
* "properties": {
|
||||
* "name": { "type": "string" },
|
||||
* "code": { "type": "string" },
|
||||
* "priority": { "type": "integer", "default": 50 },
|
||||
* "enabled": { "type": "boolean", "default": true }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @module ArrayTableWidget
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Ensure LEDMatrixWidgets registry exists
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('[ArrayTableWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the array-table widget
|
||||
*/
|
||||
window.LEDMatrixWidgets.register('array-table', {
|
||||
name: 'Array Table Widget',
|
||||
version: '1.0.0',
|
||||
|
||||
render: function(container, config, value, options) {
|
||||
console.log('[ArrayTableWidget] Render called (server-side rendered)');
|
||||
},
|
||||
|
||||
getValue: function(fieldId) {
|
||||
const tbody = document.getElementById(`${fieldId}_tbody`);
|
||||
if (!tbody) return [];
|
||||
|
||||
const rows = tbody.querySelectorAll('.array-table-row');
|
||||
const items = [];
|
||||
|
||||
rows.forEach((row) => {
|
||||
const item = {};
|
||||
row.querySelectorAll('input').forEach(input => {
|
||||
const name = input.getAttribute('name');
|
||||
if (!name || name.endsWith('.enabled') || input.type === 'hidden') return;
|
||||
const match = name.match(/\.\d+\.([^.]+)$/);
|
||||
if (match) {
|
||||
const propName = match[1];
|
||||
if (input.type === 'checkbox') {
|
||||
item[propName] = input.checked;
|
||||
} else if (input.type === 'number') {
|
||||
item[propName] = input.value ? parseFloat(input.value) : null;
|
||||
} else {
|
||||
item[propName] = input.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (Object.keys(item).length > 0) {
|
||||
items.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
setValue: function(fieldId, items, options) {
|
||||
if (!Array.isArray(items)) {
|
||||
console.error('[ArrayTableWidget] setValue expects an array');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options || !options.fullKey || !options.pluginId) {
|
||||
throw new Error('ArrayTableWidget.setValue requires options.fullKey and options.pluginId');
|
||||
}
|
||||
|
||||
const tbody = document.getElementById(`${fieldId}_tbody`);
|
||||
if (!tbody) {
|
||||
console.warn(`[ArrayTableWidget] tbody not found for fieldId: ${fieldId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const row = createArrayTableRow(
|
||||
fieldId,
|
||||
options.fullKey,
|
||||
index,
|
||||
options.pluginId,
|
||||
item,
|
||||
options.itemProperties || {},
|
||||
options.displayColumns || []
|
||||
);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// Refresh Add button state after repopulating rows
|
||||
updateAddButtonState(fieldId);
|
||||
},
|
||||
|
||||
handlers: {}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a table row element for array item
|
||||
*/
|
||||
function createArrayTableRow(fieldId, fullKey, index, pluginId, item, itemProperties, displayColumns) {
|
||||
item = item || {};
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'array-table-row';
|
||||
row.setAttribute('data-index', index);
|
||||
|
||||
displayColumns.forEach(colName => {
|
||||
const colDef = itemProperties[colName] || {};
|
||||
const colType = colDef.type || 'string';
|
||||
const colDefault = colDef.default !== undefined ? colDef.default : (colType === 'boolean' ? false : '');
|
||||
const colValue = item[colName] !== undefined ? item[colName] : colDefault;
|
||||
|
||||
const cell = document.createElement('td');
|
||||
cell.className = 'px-4 py-3 whitespace-nowrap';
|
||||
|
||||
if (colType === 'boolean') {
|
||||
const hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = `${fullKey}.${index}.${colName}`;
|
||||
hiddenInput.value = 'false';
|
||||
cell.appendChild(hiddenInput);
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.name = `${fullKey}.${index}.${colName}`;
|
||||
checkbox.checked = Boolean(colValue);
|
||||
checkbox.value = 'true';
|
||||
checkbox.className = 'h-4 w-4 text-blue-600';
|
||||
cell.appendChild(checkbox);
|
||||
} else if (colType === 'integer' || colType === 'number') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.name = `${fullKey}.${index}.${colName}`;
|
||||
input.value = colValue !== null && colValue !== undefined ? colValue : '';
|
||||
if (colDef.minimum !== undefined) input.min = colDef.minimum;
|
||||
if (colDef.maximum !== undefined) input.max = colDef.maximum;
|
||||
input.step = colType === 'integer' ? '1' : 'any';
|
||||
input.className = 'block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center';
|
||||
if (colDef.description) input.title = colDef.description;
|
||||
cell.appendChild(input);
|
||||
} else {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.name = `${fullKey}.${index}.${colName}`;
|
||||
input.value = colValue !== null && colValue !== undefined ? colValue : '';
|
||||
input.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
||||
if (colDef.description) input.placeholder = colDef.description;
|
||||
if (colDef.pattern) input.pattern = colDef.pattern;
|
||||
if (colDef.minLength) input.minLength = colDef.minLength;
|
||||
if (colDef.maxLength) input.maxLength = colDef.maxLength;
|
||||
cell.appendChild(input);
|
||||
}
|
||||
|
||||
row.appendChild(cell);
|
||||
});
|
||||
|
||||
// Actions cell
|
||||
const actionsCell = document.createElement('td');
|
||||
actionsCell.className = 'px-4 py-3 whitespace-nowrap text-center';
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||
removeButton.onclick = function() { removeArrayTableRow(this); };
|
||||
const removeIcon = document.createElement('i');
|
||||
removeIcon.className = 'fas fa-trash';
|
||||
removeButton.appendChild(removeIcon);
|
||||
actionsCell.appendChild(removeButton);
|
||||
row.appendChild(actionsCell);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the Add button's disabled state based on current row count
|
||||
* @param {string} fieldId - Field ID to find the tbody and button
|
||||
*/
|
||||
function updateAddButtonState(fieldId) {
|
||||
const tbody = document.getElementById(fieldId + '_tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
// Find the add button by looking for the button with matching data-field-id
|
||||
const addButton = document.querySelector(`button[data-field-id="${fieldId}"]`);
|
||||
if (!addButton) return;
|
||||
|
||||
const maxItems = parseInt(addButton.getAttribute('data-max-items'), 10);
|
||||
const currentRows = tbody.querySelectorAll('.array-table-row');
|
||||
const isAtMax = currentRows.length >= maxItems;
|
||||
|
||||
addButton.disabled = isAtMax;
|
||||
addButton.style.opacity = isAtMax ? '0.5' : '';
|
||||
}
|
||||
|
||||
// Expose for external use if needed
|
||||
window.updateArrayTableAddButtonState = updateAddButtonState;
|
||||
|
||||
/**
|
||||
* Add a new row to the array table
|
||||
* @param {HTMLElement} button - The button element with data attributes
|
||||
*/
|
||||
window.addArrayTableRow = function(button) {
|
||||
const fieldId = button.getAttribute('data-field-id');
|
||||
const fullKey = button.getAttribute('data-full-key');
|
||||
const maxItems = parseInt(button.getAttribute('data-max-items'), 10);
|
||||
const pluginId = button.getAttribute('data-plugin-id');
|
||||
|
||||
// Parse JSON with fallback on error
|
||||
let itemProperties = {};
|
||||
let displayColumns = [];
|
||||
const rawItemProps = button.getAttribute('data-item-properties') || '{}';
|
||||
const rawDisplayCols = button.getAttribute('data-display-columns') || '[]';
|
||||
|
||||
try {
|
||||
itemProperties = JSON.parse(rawItemProps);
|
||||
} catch (e) {
|
||||
console.error('[ArrayTableWidget] Failed to parse data-item-properties:', rawItemProps, e);
|
||||
itemProperties = {};
|
||||
}
|
||||
|
||||
try {
|
||||
displayColumns = JSON.parse(rawDisplayCols);
|
||||
} catch (e) {
|
||||
console.error('[ArrayTableWidget] Failed to parse data-display-columns:', rawDisplayCols, e);
|
||||
displayColumns = [];
|
||||
}
|
||||
|
||||
const tbody = document.getElementById(fieldId + '_tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const currentRows = tbody.querySelectorAll('.array-table-row');
|
||||
if (currentRows.length >= maxItems) {
|
||||
const notifyFn = window.showNotification || alert;
|
||||
notifyFn(`Maximum ${maxItems} items allowed`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = currentRows.length;
|
||||
const row = createArrayTableRow(fieldId, fullKey, newIndex, pluginId, {}, itemProperties, displayColumns);
|
||||
tbody.appendChild(row);
|
||||
|
||||
// Update button state after adding
|
||||
updateAddButtonState(fieldId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a row from the array table
|
||||
* @param {HTMLElement} button - The remove button element
|
||||
*/
|
||||
window.removeArrayTableRow = function(button) {
|
||||
const row = button.closest('tr');
|
||||
if (!row) return;
|
||||
|
||||
if (confirm('Remove this item?')) {
|
||||
const tbody = row.parentElement;
|
||||
if (!tbody) return;
|
||||
|
||||
// Get fieldId from tbody id (format: {fieldId}_tbody)
|
||||
const fieldId = tbody.id.replace('_tbody', '');
|
||||
|
||||
row.remove();
|
||||
|
||||
// Re-index remaining rows
|
||||
const rows = tbody.querySelectorAll('.array-table-row');
|
||||
rows.forEach(function(r, index) {
|
||||
r.setAttribute('data-index', index);
|
||||
r.querySelectorAll('input').forEach(function(input) {
|
||||
const name = input.getAttribute('name');
|
||||
if (name) {
|
||||
input.setAttribute('name', name.replace(/\.\d+\./, '.' + index + '.'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update button state after removing
|
||||
updateAddButtonState(fieldId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize all array table add buttons on page load
|
||||
*/
|
||||
function initArrayTableButtons() {
|
||||
const addButtons = document.querySelectorAll('button[data-field-id][data-max-items]');
|
||||
addButtons.forEach(function(button) {
|
||||
const fieldId = button.getAttribute('data-field-id');
|
||||
updateAddButtonState(fieldId);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initArrayTableButtons);
|
||||
} else {
|
||||
initArrayTableButtons();
|
||||
}
|
||||
|
||||
console.log('[ArrayTableWidget] Array table widget registered');
|
||||
})();
|
||||
@@ -1434,22 +1434,27 @@ function renderInstalledPlugins(plugins) {
|
||||
` : ''}
|
||||
|
||||
<!-- Plugin Actions -->
|
||||
<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-200">
|
||||
<button class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold"
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #e5e7eb;">
|
||||
<button class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
|
||||
style="display: flex; width: 100%; justify-content: center;"
|
||||
data-plugin-id="${escapedPluginId}"
|
||||
data-action="configure">
|
||||
<i class="fas fa-cog mr-2"></i>Configure
|
||||
</button>
|
||||
<button class="btn bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold"
|
||||
data-plugin-id="${escapedPluginId}"
|
||||
data-action="update">
|
||||
<i class="fas fa-sync mr-2"></i>Update
|
||||
</button>
|
||||
<button class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold"
|
||||
data-plugin-id="${escapedPluginId}"
|
||||
data-action="uninstall">
|
||||
<i class="fas fa-trash mr-2"></i>Uninstall
|
||||
</button>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
|
||||
style="flex: 1;"
|
||||
data-plugin-id="${escapedPluginId}"
|
||||
data-action="update">
|
||||
<i class="fas fa-sync mr-2"></i>Update
|
||||
</button>
|
||||
<button class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
|
||||
style="flex: 1;"
|
||||
data-plugin-id="${escapedPluginId}"
|
||||
data-action="uninstall">
|
||||
<i class="fas fa-trash mr-2"></i>Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user