mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
feat(display): drag-and-drop plugin rotation order for the primary display mode
The primary rotation's order was invisible and unconfigurable: modes are registered in parallel-load COMPLETION order, so rotation order actually varied between restarts. Only the niche Vegas Scroll mode had a working order UI. This adds real, persisted ordering end to end: Backend: - config.template.json: new display.plugin_rotation_order (default [], fully backward compatible). - display_controller.py: _apply_plugin_rotation_order() rebuilds available_modes grouped by plugin per the configured list (each plugin's modes keep their declared order; unlisted plugins follow in existing relative order; empty config = exact no-op). Applied at startup after parallel load and after live enable/disable reconcile (before the existing _resync_mode_index_after_change, which preserves the current mode). Mirrors vegas_mode get_ordered_plugins() semantics. - api_v3.py save_main_config: accepts plugin_rotation_order as a JSON array (same parse/guard pattern as vegas_plugin_order). Frontend: - New shared widget static/v3/js/widgets/plugin-order-list.js: the Vegas section's drag-and-drop list factored out verbatim (native HTML5 drag events, saved-order-first rendering, hidden-input JSON sync), parameterized by container/order-input/optional exclude-checkbox/badge. - display.html: Vegas section now calls the shared module; its ~130-line inline copy of the same logic is deleted. - durations.html: new "Rotation Order" card above the durations grid using the same module, posting plugin_rotation_order with the existing form. Deviation from plan, deliberate: durations stay as their own mode-keyed grid rather than inline in the drag rows - verified display_durations keys are MODE names (display_controller.py resolves duration per mode_key), not plugin ids, and one plugin can own several modes, so the planned 1:1 inline pairing was wrong. Validation: py_compile on both Python files; _apply_plugin_rotation_order unit-tested standalone (configured order applied, empty-config no-op, unknown ids skipped - 3/3); both templates render with balanced divs, the hidden input carries the saved order, and the old inline implementation is confirmed gone; config.template.json parses. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5ebd55d02b
commit
d49cb5851d
@@ -961,6 +961,22 @@ def save_main_config():
|
||||
return jsonify({"status": "error", "message": "sync_follower_position must be left or right"}), 400
|
||||
current_config["sync"]["follower_position"] = pos_val
|
||||
|
||||
# Handle primary rotation order (JSON array of plugin ids; same
|
||||
# parse/validate pattern as vegas_plugin_order above)
|
||||
if 'plugin_rotation_order' in data:
|
||||
try:
|
||||
if isinstance(data['plugin_rotation_order'], str):
|
||||
parsed = json.loads(data['plugin_rotation_order'])
|
||||
else:
|
||||
parsed = data['plugin_rotation_order']
|
||||
if 'display' not in current_config:
|
||||
current_config['display'] = {}
|
||||
current_config['display']['plugin_rotation_order'] = (
|
||||
list(parsed) if isinstance(parsed, (list, tuple)) else []
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
logger.warning("Malformed plugin_rotation_order ignored")
|
||||
|
||||
# Handle display durations
|
||||
duration_fields = [k for k in data.keys() if k.endswith('_duration') or k in ['default_duration', 'transition_duration']]
|
||||
if duration_fields:
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Plugin Order List — shared drag-and-drop reorder list of enabled plugins.
|
||||
*
|
||||
* Factored out of the Vegas Scroll section of display.html so both Vegas mode
|
||||
* and the primary rotation (Durations tab) use one implementation. Renders
|
||||
* one draggable row per enabled plugin into a container and keeps a hidden
|
||||
* input's value in sync as a JSON array of plugin ids in display order.
|
||||
*
|
||||
* Usage:
|
||||
* PluginOrderList.init({
|
||||
* containerId: 'vegas_plugin_order', // rows render here
|
||||
* orderInputId: 'vegas_plugin_order_value', // hidden input, JSON array of ids
|
||||
* excludedInputId: 'vegas_excluded_plugins_value', // optional: adds an
|
||||
* // include-checkbox per row; unchecked ids collect here (JSON array)
|
||||
* showVegasModeBadge: true // optional: Scroll/Fixed/Static badge
|
||||
* });
|
||||
*
|
||||
* The container re-renders from /api/v3/plugins/installed each init; the
|
||||
* hidden input(s) must already hold the saved order/exclusions (JSON).
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text == null ? '' : String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function escapeAttr(text) {
|
||||
return escapeHtml(text).replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const MODE_LABELS = {
|
||||
'scroll': { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' },
|
||||
'fixed': { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' },
|
||||
'static': { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' }
|
||||
};
|
||||
|
||||
function init(options) {
|
||||
const container = document.getElementById(options.containerId);
|
||||
const orderInput = document.getElementById(options.orderInputId);
|
||||
const excludedInput = options.excludedInputId ? document.getElementById(options.excludedInputId) : null;
|
||||
if (!container || !orderInput) return;
|
||||
|
||||
function syncInputs() {
|
||||
const order = [];
|
||||
const excluded = [];
|
||||
container.querySelectorAll('.plugin-order-item').forEach(item => {
|
||||
const pluginId = item.dataset.pluginId;
|
||||
order.push(pluginId);
|
||||
const checkbox = item.querySelector('.plugin-order-include');
|
||||
if (checkbox && !checkbox.checked) excluded.push(pluginId);
|
||||
});
|
||||
orderInput.value = JSON.stringify(order);
|
||||
if (excludedInput) excludedInput.value = JSON.stringify(excluded);
|
||||
}
|
||||
|
||||
function setupDragAndDrop() {
|
||||
let draggedItem = null;
|
||||
container.querySelectorAll('.plugin-order-item').forEach(item => {
|
||||
item.addEventListener('dragstart', function(e) {
|
||||
draggedItem = this;
|
||||
this.style.opacity = '0.5';
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
item.addEventListener('dragend', function() {
|
||||
this.style.opacity = '1';
|
||||
draggedItem = null;
|
||||
syncInputs();
|
||||
});
|
||||
item.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const rect = this.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (e.clientY < midY) {
|
||||
this.style.borderTop = '2px solid #3b82f6';
|
||||
this.style.borderBottom = '';
|
||||
} else {
|
||||
this.style.borderBottom = '2px solid #3b82f6';
|
||||
this.style.borderTop = '';
|
||||
}
|
||||
});
|
||||
item.addEventListener('dragleave', function() {
|
||||
this.style.borderTop = '';
|
||||
this.style.borderBottom = '';
|
||||
});
|
||||
item.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.borderTop = '';
|
||||
this.style.borderBottom = '';
|
||||
if (draggedItem && draggedItem !== this) {
|
||||
const rect = this.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (e.clientY < midY) {
|
||||
container.insertBefore(draggedItem, this);
|
||||
} else {
|
||||
container.insertBefore(draggedItem, this.nextSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fetch('/api/v3/plugins/installed')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const allPlugins = (data.data && data.data.plugins) || data.plugins || [];
|
||||
const plugins = allPlugins.filter(p => p.enabled);
|
||||
if (plugins.length === 0) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 italic">No enabled plugins</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let currentOrder = [];
|
||||
let excluded = [];
|
||||
try {
|
||||
currentOrder = JSON.parse(orderInput.value || '[]');
|
||||
if (excludedInput) excluded = JSON.parse(excludedInput.value || '[]');
|
||||
} catch (e) {
|
||||
console.error('Error parsing saved plugin order:', e);
|
||||
}
|
||||
|
||||
// Saved order first, then any newly enabled plugins.
|
||||
const orderedPlugins = [];
|
||||
currentOrder.forEach(id => {
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
if (plugin) orderedPlugins.push(plugin);
|
||||
});
|
||||
plugins.forEach(plugin => {
|
||||
if (!orderedPlugins.find(p => p.id === plugin.id)) orderedPlugins.push(plugin);
|
||||
});
|
||||
|
||||
let html = '';
|
||||
orderedPlugins.forEach(plugin => {
|
||||
const safePluginId = escapeAttr(plugin.id);
|
||||
const safePluginName = escapeHtml(plugin.name || plugin.id);
|
||||
let rowInner;
|
||||
if (excludedInput) {
|
||||
const isExcluded = excluded.includes(plugin.id);
|
||||
rowInner = `
|
||||
<label class="flex items-center flex-1">
|
||||
<input type="checkbox"
|
||||
class="plugin-order-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2"
|
||||
${!isExcluded ? 'checked' : ''}>
|
||||
<span class="text-sm font-medium text-gray-700">${safePluginName}</span>
|
||||
</label>`;
|
||||
} else {
|
||||
rowInner = `<span class="text-sm font-medium text-gray-700 flex-1">${safePluginName}</span>`;
|
||||
}
|
||||
let badge = '';
|
||||
if (options.showVegasModeBadge) {
|
||||
const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed';
|
||||
const modeInfo = MODE_LABELS[vegasMode] || MODE_LABELS['fixed'];
|
||||
badge = `
|
||||
<span class="text-xs ${modeInfo.color} ml-2" title="Vegas display mode: ${modeInfo.label}">
|
||||
<i class="fas ${modeInfo.icon} mr-1"></i>${modeInfo.label}
|
||||
</span>`;
|
||||
}
|
||||
html += `
|
||||
<div class="flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move plugin-order-item"
|
||||
data-plugin-id="${safePluginId}" draggable="true">
|
||||
<i class="fas fa-grip-vertical text-gray-400 mr-3"></i>
|
||||
${rowInner}${badge}
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
|
||||
setupDragAndDrop();
|
||||
container.querySelectorAll('.plugin-order-include').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', syncInputs);
|
||||
});
|
||||
syncInputs();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching plugins:', error);
|
||||
container.innerHTML = '<p class="text-sm text-red-500">Error loading plugins</p>';
|
||||
});
|
||||
}
|
||||
|
||||
window.PluginOrderList = { init: init };
|
||||
})();
|
||||
@@ -4480,6 +4480,7 @@
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/registry.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/base-widget.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/notification.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-order-list.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/file-upload.js') }}?v=20260307" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/checkbox-group.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
|
||||
|
||||
@@ -638,182 +638,25 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize plugin order list
|
||||
// Initialize plugin order list via the shared drag-and-drop module
|
||||
// (static/v3/js/widgets/plugin-order-list.js) — the same component the
|
||||
// Durations tab uses for the primary rotation order.
|
||||
function initPluginOrderList() {
|
||||
const container = document.getElementById('vegas_plugin_order');
|
||||
if (!container) return;
|
||||
|
||||
// Fetch available plugins
|
||||
fetch('/api/v3/plugins/installed')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Handle both {data: {plugins: []}} and {plugins: []} response formats
|
||||
const allPlugins = data.data?.plugins || data.plugins || [];
|
||||
if (!allPlugins || allPlugins.length === 0) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 italic">No plugins available</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current order and exclusions
|
||||
const orderInput = document.getElementById('vegas_plugin_order_value');
|
||||
const excludedInput = document.getElementById('vegas_excluded_plugins_value');
|
||||
let currentOrder = [];
|
||||
let excluded = [];
|
||||
|
||||
try {
|
||||
currentOrder = JSON.parse(orderInput.value || '[]');
|
||||
excluded = JSON.parse(excludedInput.value || '[]');
|
||||
} catch (e) {
|
||||
console.error('Error parsing vegas config:', e);
|
||||
}
|
||||
|
||||
// Build ordered plugin list (only enabled plugins)
|
||||
const plugins = allPlugins.filter(p => p.enabled);
|
||||
const orderedPlugins = [];
|
||||
|
||||
// First add plugins in current order
|
||||
currentOrder.forEach(id => {
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
if (plugin) orderedPlugins.push(plugin);
|
||||
});
|
||||
|
||||
// Then add remaining plugins
|
||||
plugins.forEach(plugin => {
|
||||
if (!orderedPlugins.find(p => p.id === plugin.id)) {
|
||||
orderedPlugins.push(plugin);
|
||||
}
|
||||
});
|
||||
|
||||
// Build HTML with display mode indicators
|
||||
let html = '';
|
||||
orderedPlugins.forEach((plugin, index) => {
|
||||
const isExcluded = excluded.includes(plugin.id);
|
||||
// Determine display mode (from plugin config or default)
|
||||
const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed';
|
||||
const modeLabels = {
|
||||
'scroll': { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' },
|
||||
'fixed': { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' },
|
||||
'static': { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' }
|
||||
};
|
||||
const modeInfo = modeLabels[vegasMode] || modeLabels['fixed'];
|
||||
// Escape plugin metadata to prevent XSS
|
||||
const safePluginId = escapeAttr(plugin.id);
|
||||
const safePluginName = escapeHtml(plugin.name || plugin.id);
|
||||
html += `
|
||||
<div class="flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move vegas-plugin-item"
|
||||
data-plugin-id="${safePluginId}" draggable="true">
|
||||
<i class="fas fa-grip-vertical text-gray-400 mr-3"></i>
|
||||
<label class="flex items-center flex-1">
|
||||
<input type="checkbox"
|
||||
class="vegas-plugin-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2"
|
||||
${!isExcluded ? 'checked' : ''}>
|
||||
<span class="text-sm font-medium text-gray-700">${safePluginName}</span>
|
||||
</label>
|
||||
<span class="text-xs ${modeInfo.color} ml-2" title="Vegas display mode: ${modeInfo.label}">
|
||||
<i class="fas ${modeInfo.icon} mr-1"></i>${modeInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html || '<p class="text-sm text-gray-500 italic">No enabled plugins</p>';
|
||||
|
||||
// Setup drag and drop
|
||||
setupDragAndDrop(container);
|
||||
|
||||
// Setup checkbox handlers
|
||||
container.querySelectorAll('.vegas-plugin-include').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', updatePluginConfig);
|
||||
});
|
||||
|
||||
// Initialize hidden inputs with current state
|
||||
updatePluginConfig();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching plugins:', error);
|
||||
container.innerHTML = '<p class="text-sm text-red-500">Error loading plugins</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function setupDragAndDrop(container) {
|
||||
let draggedItem = null;
|
||||
|
||||
container.querySelectorAll('.vegas-plugin-item').forEach(item => {
|
||||
item.addEventListener('dragstart', function(e) {
|
||||
draggedItem = this;
|
||||
this.style.opacity = '0.5';
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
|
||||
item.addEventListener('dragend', function() {
|
||||
this.style.opacity = '1';
|
||||
draggedItem = null;
|
||||
updatePluginConfig();
|
||||
});
|
||||
|
||||
item.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const rect = this.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
|
||||
if (e.clientY < midY) {
|
||||
this.style.borderTop = '2px solid #3b82f6';
|
||||
this.style.borderBottom = '';
|
||||
} else {
|
||||
this.style.borderBottom = '2px solid #3b82f6';
|
||||
this.style.borderTop = '';
|
||||
}
|
||||
});
|
||||
|
||||
item.addEventListener('dragleave', function() {
|
||||
this.style.borderTop = '';
|
||||
this.style.borderBottom = '';
|
||||
});
|
||||
|
||||
item.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.borderTop = '';
|
||||
this.style.borderBottom = '';
|
||||
|
||||
if (draggedItem && draggedItem !== this) {
|
||||
const rect = this.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
|
||||
if (e.clientY < midY) {
|
||||
container.insertBefore(draggedItem, this);
|
||||
} else {
|
||||
container.insertBefore(draggedItem, this.nextSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!document.getElementById('vegas_plugin_order')) return;
|
||||
if (!window.PluginOrderList) {
|
||||
// Widget script is deferred; retry briefly if this partial's
|
||||
// inline script runs first.
|
||||
setTimeout(initPluginOrderList, 100);
|
||||
return;
|
||||
}
|
||||
window.PluginOrderList.init({
|
||||
containerId: 'vegas_plugin_order',
|
||||
orderInputId: 'vegas_plugin_order_value',
|
||||
excludedInputId: 'vegas_excluded_plugins_value',
|
||||
showVegasModeBadge: true
|
||||
});
|
||||
}
|
||||
|
||||
function updatePluginConfig() {
|
||||
const container = document.getElementById('vegas_plugin_order');
|
||||
const orderInput = document.getElementById('vegas_plugin_order_value');
|
||||
const excludedInput = document.getElementById('vegas_excluded_plugins_value');
|
||||
|
||||
if (!container || !orderInput || !excludedInput) return;
|
||||
|
||||
const order = [];
|
||||
const excluded = [];
|
||||
|
||||
container.querySelectorAll('.vegas-plugin-item').forEach(item => {
|
||||
const pluginId = item.dataset.pluginId;
|
||||
const checkbox = item.querySelector('.vegas-plugin-include');
|
||||
|
||||
order.push(pluginId);
|
||||
if (checkbox && !checkbox.checked) {
|
||||
excluded.push(pluginId);
|
||||
}
|
||||
});
|
||||
|
||||
orderInput.value = JSON.stringify(order);
|
||||
excludedInput.value = JSON.stringify(excluded);
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -16,6 +16,20 @@
|
||||
novalidate
|
||||
onsubmit="fixInvalidNumberInputs(this); return true;">
|
||||
|
||||
<!-- Primary rotation order: drag to reorder which plugin shows first,
|
||||
second, ... in the normal display rotation. Saved as
|
||||
display.plugin_rotation_order and applied by the display
|
||||
controller on startup and live plugin enable/disable. -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-1">Rotation Order</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">Drag plugins to set the order they rotate on the display. Each plugin's screens keep their own order within its turn. Takes effect after saving and restarting the display.</p>
|
||||
<div id="rotation_plugin_order" class="space-y-2 bg-white rounded-lg p-3 border border-gray-200">
|
||||
<p class="text-sm text-gray-500 italic">Loading plugins…</p>
|
||||
</div>
|
||||
<input type="hidden" id="rotation_plugin_order_value" name="plugin_rotation_order"
|
||||
value='{{ main_config.display.get("plugin_rotation_order", [])|tojson }}'>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for key, value in main_config.display.display_durations.items() %}
|
||||
<div class="form-group" id="setting-durations-{{ key }}" data-setting-key="display.display_durations.{{ key }}">
|
||||
@@ -43,3 +57,26 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// Shared drag-and-drop plugin list (static/v3/js/widgets/plugin-order-list.js,
|
||||
// same module the Vegas Scroll section uses).
|
||||
function initRotationOrderList() {
|
||||
if (!document.getElementById('rotation_plugin_order')) return;
|
||||
if (!window.PluginOrderList) {
|
||||
setTimeout(initRotationOrderList, 100);
|
||||
return;
|
||||
}
|
||||
window.PluginOrderList.init({
|
||||
containerId: 'rotation_plugin_order',
|
||||
orderInputId: 'rotation_plugin_order_value'
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initRotationOrderList);
|
||||
} else {
|
||||
initRotationOrderList();
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user