2834 lines
100 KiB
JavaScript
2834 lines
100 KiB
JavaScript
// Shared WebSocket for ESPNow messages (presets + selects)
|
||
let espnowSocket = null;
|
||
let espnowSocketReady = false;
|
||
let espnowPendingMessages = [];
|
||
let currentProfileIdCache = null;
|
||
|
||
function coercePresetInt(v, def = 0) {
|
||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||
return v;
|
||
}
|
||
const t = parseInt(String(v), 10);
|
||
return Number.isFinite(t) ? t : def;
|
||
}
|
||
|
||
/** Style variant for wire ``n6``; presets may store ``mode`` or legacy ``n6``. */
|
||
function presetWireN6(preset, def = 0) {
|
||
if (!preset || typeof preset !== 'object') {
|
||
return def;
|
||
}
|
||
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
|
||
return coercePresetInt(preset.mode, def);
|
||
}
|
||
return coercePresetInt(preset.n6, def);
|
||
}
|
||
|
||
const getCurrentProfileId = async () => {
|
||
try {
|
||
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||
if (!res.ok) return currentProfileIdCache ? String(currentProfileIdCache) : null;
|
||
const data = await res.json();
|
||
const id = data && (data.id || (data.profile && data.profile.id));
|
||
currentProfileIdCache = id ? String(id) : null;
|
||
return currentProfileIdCache;
|
||
} catch (_) {
|
||
return currentProfileIdCache ? String(currentProfileIdCache) : null;
|
||
}
|
||
};
|
||
|
||
const filterPresetsForCurrentProfile = async (presetsObj) => {
|
||
const scoped = presetsObj && typeof presetsObj === 'object' ? presetsObj : {};
|
||
const currentProfileId = await getCurrentProfileId();
|
||
if (!currentProfileId) return scoped;
|
||
return Object.fromEntries(
|
||
Object.entries(scoped).filter(([, preset]) => {
|
||
if (!preset || typeof preset !== 'object') return false;
|
||
if (!('profile_id' in preset)) return true; // Legacy records
|
||
return String(preset.profile_id) === String(currentProfileId);
|
||
}),
|
||
);
|
||
};
|
||
try {
|
||
window.filterPresetsForCurrentProfile = filterPresetsForCurrentProfile;
|
||
} catch (e) {}
|
||
|
||
const getCurrentProfileData = async () => {
|
||
try {
|
||
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||
if (!res.ok) return null;
|
||
return await res.json();
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const getCurrentProfilePaletteColors = async () => {
|
||
const profileData = await getCurrentProfileData();
|
||
const profile = profileData && profileData.profile;
|
||
const paletteId = profile && (profile.palette_id || profile.paletteId);
|
||
if (!paletteId) return [];
|
||
try {
|
||
const res = await fetch(`/palettes/${paletteId}`, { headers: { Accept: 'application/json' } });
|
||
if (!res.ok) return [];
|
||
const pal = await res.json();
|
||
return Array.isArray(pal.colors) ? pal.colors : [];
|
||
} catch (_) {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
const resolveColorsWithPaletteRefs = (colors, paletteRefs, paletteColors) => {
|
||
const baseColors = Array.isArray(colors) ? colors : [];
|
||
const refs = Array.isArray(paletteRefs) ? paletteRefs : [];
|
||
const pal = Array.isArray(paletteColors) ? paletteColors : [];
|
||
return baseColors.map((color, idx) => {
|
||
const refRaw = refs[idx];
|
||
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||
if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) {
|
||
return pal[ref];
|
||
}
|
||
return color;
|
||
});
|
||
};
|
||
|
||
const getEspnowSocket = () => {
|
||
if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) {
|
||
return espnowSocket;
|
||
}
|
||
|
||
const wsScheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${wsScheme}//${window.location.host}/ws`;
|
||
espnowSocket = new WebSocket(wsUrl);
|
||
espnowSocketReady = false;
|
||
|
||
espnowSocket.onopen = () => {
|
||
espnowSocketReady = true;
|
||
window.dispatchEvent(new CustomEvent('deviceTcpWsOpen'));
|
||
// Flush any queued messages
|
||
espnowPendingMessages.forEach((msg) => {
|
||
try {
|
||
espnowSocket.send(msg);
|
||
} catch (err) {
|
||
console.error('Failed to send queued ESPNow message:', err);
|
||
}
|
||
});
|
||
espnowPendingMessages = [];
|
||
};
|
||
|
||
espnowSocket.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data && data.type === 'device_tcp' && typeof data.connected === 'boolean' && data.ip) {
|
||
window.dispatchEvent(
|
||
new CustomEvent('deviceTcpStatus', { detail: { ip: data.ip, connected: data.connected } }),
|
||
);
|
||
return;
|
||
}
|
||
if (data && data.type === 'device_tcp_snapshot' && Array.isArray(data.connected_ips)) {
|
||
window.dispatchEvent(
|
||
new CustomEvent('deviceTcpSnapshot', { detail: { connectedIps: data.connected_ips } }),
|
||
);
|
||
return;
|
||
}
|
||
if (data && data.error) {
|
||
console.error('ESP-NOW:', data.error);
|
||
alert('ESP-NOW send failed. ' + (data.error === 'ESP-NOW send failed' ? 'Check device WiFi/interface.' : data.error));
|
||
}
|
||
} catch (_) {
|
||
// Ignore non-JSON or non-error messages
|
||
}
|
||
};
|
||
|
||
espnowSocket.onclose = () => {
|
||
espnowSocketReady = false;
|
||
espnowSocket = null;
|
||
};
|
||
|
||
espnowSocket.onerror = (err) => {
|
||
console.error('ESPNow WebSocket error:', err);
|
||
};
|
||
|
||
return espnowSocket;
|
||
};
|
||
|
||
const sendEspnowMessage = (obj) => {
|
||
const json = JSON.stringify(obj);
|
||
const ws = getEspnowSocket();
|
||
if (espnowSocketReady && ws.readyState === WebSocket.OPEN) {
|
||
try {
|
||
ws.send(json);
|
||
} catch (err) {
|
||
console.error('Failed to send ESPNow message:', err);
|
||
}
|
||
} else {
|
||
// Queue until connection is open
|
||
espnowPendingMessages.push(json);
|
||
}
|
||
};
|
||
|
||
function tabDeviceNamesFromSection(section) {
|
||
if (typeof window.parseTabDeviceNames === 'function') {
|
||
return window.parseTabDeviceNames(section);
|
||
}
|
||
const namesAttr = section && section.getAttribute('data-device-names');
|
||
return namesAttr
|
||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||
: [];
|
||
}
|
||
|
||
/** Device names for ``presetId`` on the current zone tab (zone ``group_ids`` for presets, else tab devices). */
|
||
async function deviceNamesForPresetOnCurrentZone(presetId) {
|
||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||
const fallback = tabDeviceNamesFromSection(section);
|
||
if (!section || !presetId) return fallback;
|
||
const zm = window.zonesManager;
|
||
if (!zm || typeof zm.resolveDeviceNamesForZonePreset !== 'function') return fallback;
|
||
const zoneId = section.dataset.zoneId;
|
||
try {
|
||
const res = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||
if (!res.ok) return fallback;
|
||
const zd = await res.json();
|
||
const names = await zm.resolveDeviceNamesForZonePreset(zd, String(presetId));
|
||
return names.length ? names : fallback;
|
||
} catch (_) {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function formatPresetTargetGroupsLine(zoneDoc, groupsMap) {
|
||
const zm = window.zonesManager;
|
||
const gids =
|
||
zm && typeof zm.effectiveGroupIdsForZonePreset === 'function'
|
||
? zm.effectiveGroupIdsForZonePreset(zoneDoc || {})
|
||
: Array.isArray(zoneDoc && zoneDoc.group_ids)
|
||
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||
: [];
|
||
const parts = (gids || [])
|
||
.map((id) => {
|
||
const g = groupsMap && groupsMap[id];
|
||
const gn = g && g.name ? String(g.name).trim() : '';
|
||
return gn;
|
||
})
|
||
.filter(Boolean);
|
||
return parts.length ? parts.join(', ') : '';
|
||
}
|
||
|
||
async function postDriverSequence(sequence, targetMacs, delayS, pushOptions) {
|
||
const body = {
|
||
sequence,
|
||
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
|
||
};
|
||
if (delayS != null && delayS >= 0) {
|
||
body.delay_s = delayS;
|
||
}
|
||
const res = await fetch('/presets/push', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new Error((err && err.error) || res.statusText || 'Send failed');
|
||
}
|
||
return res.json().catch(() => ({}));
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const presetsButton = document.getElementById('presets-btn');
|
||
const presetsModal = document.getElementById('presets-modal');
|
||
const presetsCloseButton = document.getElementById('presets-close-btn');
|
||
const presetsList = document.getElementById('presets-list');
|
||
const presetsAddButton = document.getElementById('preset-add-btn');
|
||
const presetClearDeviceButton = document.getElementById('preset-clear-device-btn');
|
||
const presetEditorModal = document.getElementById('preset-editor-modal');
|
||
const presetEditorCloseButton = document.getElementById('preset-editor-close-btn');
|
||
const presetNameInput = document.getElementById('preset-name-input');
|
||
const presetPatternInput = document.getElementById('preset-pattern-input');
|
||
const presetColorsContainer = document.getElementById('preset-colors-container');
|
||
const presetNewColorInput = document.getElementById('preset-new-color');
|
||
const presetBrightnessInput = document.getElementById('preset-brightness-input');
|
||
const presetDelayInput = document.getElementById('preset-delay-input');
|
||
const presetDelayField = presetDelayInput ? presetDelayInput.closest('.preset-editor-field') : null;
|
||
const presetBackgroundInput = document.getElementById('preset-background-input');
|
||
const presetBackgroundButton = document.getElementById('preset-background-btn');
|
||
const presetManualModeInput = document.getElementById('preset-manual-mode-input');
|
||
const presetManualModeHint = document.getElementById('preset-manual-mode-hint');
|
||
const presetManualModeLabel = document.getElementById('preset-manual-mode-label');
|
||
const presetManualBeatNWrap = document.getElementById('preset-manual-beat-n-wrap');
|
||
const presetManualBeatNInput = document.getElementById('preset-manual-beat-n-input');
|
||
const presetDefaultButton = document.getElementById('preset-default-btn');
|
||
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn');
|
||
const presetSaveButton = document.getElementById('preset-save-btn');
|
||
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
|
||
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
|
||
const presetModeInput = document.getElementById('preset-mode-input');
|
||
const presetModeGroup = document.getElementById('preset-mode-group');
|
||
const presetReverseInput = document.getElementById('preset-reverse-input');
|
||
const presetReverseGroup = document.getElementById('preset-reverse-group');
|
||
|
||
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
|
||
return;
|
||
}
|
||
|
||
let currentEditId = null;
|
||
let currentEditTabId = null;
|
||
let cachedPresets = {};
|
||
let cachedPatterns = {};
|
||
let currentPresetColors = []; // Track colors for the current preset
|
||
let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors)
|
||
let currentBackgroundPaletteRef = null;
|
||
let bgPaletteResolveGen = 0;
|
||
|
||
// Function to get max colors for current pattern
|
||
const getMaxColors = () => {
|
||
if (!presetPatternInput || !presetPatternInput.value) {
|
||
return Infinity; // No pattern selected, no limit
|
||
}
|
||
const patternName = presetPatternInput.value.trim();
|
||
const patternConfig = cachedPatterns && cachedPatterns[patternName];
|
||
if (patternConfig && typeof patternConfig === 'object' && patternConfig.max_colors !== undefined) {
|
||
return patternConfig.max_colors;
|
||
}
|
||
return Infinity; // No limit if not specified
|
||
};
|
||
|
||
const resolvePatternConfig = (patternName) => {
|
||
const rawPatternName = String(patternName || '').trim();
|
||
const normalizedPatternName = rawPatternName.endsWith('.py')
|
||
? rawPatternName.slice(0, -3)
|
||
: rawPatternName;
|
||
let patternConfig =
|
||
(cachedPatterns && cachedPatterns[rawPatternName]) ||
|
||
(cachedPatterns && cachedPatterns[normalizedPatternName]) ||
|
||
null;
|
||
if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') {
|
||
const lower = normalizedPatternName.toLowerCase();
|
||
const matchedKey = Object.keys(cachedPatterns).find(
|
||
(k) => String(k).toLowerCase() === lower,
|
||
);
|
||
if (matchedKey) {
|
||
patternConfig = cachedPatterns[matchedKey];
|
||
}
|
||
}
|
||
if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') {
|
||
patternConfig = patternConfig.data;
|
||
}
|
||
if (
|
||
patternConfig &&
|
||
typeof patternConfig === 'object' &&
|
||
patternConfig.parameter_mappings &&
|
||
typeof patternConfig.parameter_mappings === 'object'
|
||
) {
|
||
const { parameter_mappings: pm, data: _data, ...rest } = patternConfig;
|
||
patternConfig = { ...rest, ...pm };
|
||
}
|
||
return patternConfig && typeof patternConfig === 'object' ? patternConfig : null;
|
||
};
|
||
|
||
/** From db/pattern.json; missing key means pattern allows manual / beat (backward compatible). */
|
||
const patternSupportsManual = (patternName) => {
|
||
const cfg = resolvePatternConfig(patternName);
|
||
if (!cfg) {
|
||
return true;
|
||
}
|
||
return cfg.supports_manual !== false;
|
||
};
|
||
|
||
const getPatternModeOptions = (patternName) => {
|
||
const cfg = resolvePatternConfig(patternName);
|
||
if (!cfg || typeof cfg.mode !== 'object' || cfg.mode === null || Array.isArray(cfg.mode)) {
|
||
return null;
|
||
}
|
||
const entries = Object.entries(cfg.mode).filter(
|
||
([, label]) => typeof label === 'string' && label.trim(),
|
||
);
|
||
if (entries.length < 2) {
|
||
return null;
|
||
}
|
||
entries.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10));
|
||
return entries;
|
||
};
|
||
|
||
const patternSupportsModes = (patternName) => getPatternModeOptions(patternName) !== null;
|
||
|
||
const patternSupportsReverse = (patternName) => {
|
||
const cfg = resolvePatternConfig(patternName);
|
||
return !!(cfg && cfg.supports_reverse);
|
||
};
|
||
|
||
const setPresetReverseFieldVisible = (show) => {
|
||
if (!presetReverseGroup) {
|
||
return;
|
||
}
|
||
presetReverseGroup.hidden = !show;
|
||
presetReverseGroup.style.display = show ? '' : 'none';
|
||
if (!show && presetReverseInput) {
|
||
presetReverseInput.checked = false;
|
||
}
|
||
};
|
||
|
||
const setPresetModeFieldVisible = (show) => {
|
||
if (!presetModeGroup) {
|
||
return;
|
||
}
|
||
presetModeGroup.hidden = !show;
|
||
presetModeGroup.style.display = show ? '' : 'none';
|
||
if (!show && presetModeInput) {
|
||
presetModeInput.innerHTML = '';
|
||
}
|
||
};
|
||
|
||
const presetStoredMode = (preset) => {
|
||
if (!preset || typeof preset !== 'object') {
|
||
return 0;
|
||
}
|
||
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
|
||
const m = parseInt(String(preset.mode), 10);
|
||
return Number.isFinite(m) ? m : 0;
|
||
}
|
||
const n6 = parseInt(String(preset.n6), 10);
|
||
return Number.isFinite(n6) ? n6 : 0;
|
||
};
|
||
|
||
const updateManualBeatNVisibility = () => {
|
||
if (!presetManualBeatNWrap) {
|
||
return;
|
||
}
|
||
const manualOn = presetManualModeInput && presetManualModeInput.checked;
|
||
const patternName = presetPatternInput ? presetPatternInput.value.trim() : '';
|
||
const ok = !patternName || patternSupportsManual(patternName);
|
||
presetManualBeatNWrap.style.display = manualOn && ok ? '' : 'none';
|
||
};
|
||
|
||
const updatePresetBackgroundButton = () => {
|
||
if (!presetBackgroundButton || !presetBackgroundInput) return;
|
||
const color = coercePresetBackground({ background: presetBackgroundInput.value });
|
||
presetBackgroundInput.value = color;
|
||
presetBackgroundButton.textContent = color;
|
||
presetBackgroundButton.style.backgroundColor = color;
|
||
presetBackgroundButton.style.color = '#fff';
|
||
presetBackgroundButton.style.borderColor = 'rgba(255, 255, 255, 0.6)';
|
||
presetBackgroundButton.title =
|
||
currentBackgroundPaletteRef != null
|
||
? `Background from profile palette (index ${currentBackgroundPaletteRef}); click to pick a custom colour`
|
||
: 'Choose background colour';
|
||
};
|
||
|
||
const updateDelayVisibilityForManualMode = () => {
|
||
if (!presetDelayField) return;
|
||
const manualOn = presetManualModeInput && presetManualModeInput.checked;
|
||
presetDelayField.style.display = manualOn ? 'none' : '';
|
||
};
|
||
|
||
const updateManualModeAvailability = () => {
|
||
if (!presetManualModeInput) {
|
||
return;
|
||
}
|
||
const patternName = presetPatternInput ? presetPatternInput.value.trim() : '';
|
||
const ok = !patternName || patternSupportsManual(patternName);
|
||
presetManualModeInput.disabled = !ok;
|
||
if (presetManualModeLabel) {
|
||
presetManualModeLabel.style.opacity = ok ? '' : '0.55';
|
||
}
|
||
if (presetManualModeHint) {
|
||
if (!patternName || ok) {
|
||
presetManualModeHint.style.display = 'none';
|
||
presetManualModeHint.textContent = '';
|
||
} else {
|
||
presetManualModeHint.style.display = '';
|
||
presetManualModeHint.textContent =
|
||
'This pattern is a poor fit for manual mode or audio beat triggers; use auto mode for best results.';
|
||
}
|
||
}
|
||
if (!ok) {
|
||
presetManualModeInput.checked = false;
|
||
}
|
||
updateManualBeatNVisibility();
|
||
updateDelayVisibilityForManualMode();
|
||
};
|
||
|
||
// Function to show/hide color section based on max_colors
|
||
const updateColorSectionVisibility = () => {
|
||
const maxColors = getMaxColors();
|
||
const shouldShow = maxColors > 0;
|
||
|
||
// Find the color label (the label before the container)
|
||
if (presetColorsContainer) {
|
||
let prev = presetColorsContainer.previousElementSibling;
|
||
while (prev) {
|
||
if (prev.tagName === 'LABEL' && prev.textContent.trim().toLowerCase().includes('color')) {
|
||
prev.style.display = shouldShow ? '' : 'none';
|
||
break;
|
||
}
|
||
prev = prev.previousElementSibling;
|
||
}
|
||
|
||
// Hide/show the container
|
||
presetColorsContainer.style.display = shouldShow ? '' : 'none';
|
||
|
||
// Hide/show the actions (color picker and buttons)
|
||
const colorActions = presetColorsContainer.nextElementSibling;
|
||
if (colorActions && colorActions.querySelector('#preset-new-color')) {
|
||
colorActions.style.display = shouldShow ? '' : 'none';
|
||
}
|
||
}
|
||
};
|
||
|
||
const getNumberInput = (id) => {
|
||
const input = document.getElementById(id);
|
||
if (!input) {
|
||
return 0;
|
||
}
|
||
const n = parseInt(String(input.value).trim(), 10);
|
||
return Number.isFinite(n) ? n : 0;
|
||
};
|
||
|
||
const renderPresetColors = (colors, paletteRefs) => {
|
||
if (!presetColorsContainer) return;
|
||
|
||
presetColorsContainer.innerHTML = '';
|
||
currentPresetColors = Array.isArray(colors) ? colors.slice() : [];
|
||
if (Array.isArray(paletteRefs)) {
|
||
currentPresetPaletteRefs = currentPresetColors.map((_, i) => {
|
||
const refRaw = paletteRefs[i];
|
||
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||
return Number.isInteger(ref) ? ref : null;
|
||
});
|
||
} else {
|
||
currentPresetPaletteRefs = currentPresetColors.map((_, i) => {
|
||
const refRaw = currentPresetPaletteRefs[i];
|
||
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||
return Number.isInteger(ref) ? ref : null;
|
||
});
|
||
}
|
||
|
||
// Get max colors for current pattern
|
||
const maxColors = getMaxColors();
|
||
const maxColorsText = maxColors !== Infinity ? ` (max ${maxColors})` : '';
|
||
|
||
if (currentPresetColors.length === 0) {
|
||
const empty = document.createElement('p');
|
||
empty.className = 'muted-text';
|
||
empty.textContent = `No colors added. Use the color picker to add colors.${maxColorsText}`;
|
||
presetColorsContainer.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
// Show max colors info if limit exists and reached
|
||
if (maxColors !== Infinity && currentPresetColors.length >= maxColors) {
|
||
const info = document.createElement('p');
|
||
info.className = 'muted-text';
|
||
info.style.cssText = 'font-size: 0.85em; margin-bottom: 0.5rem; color: #ffa500;';
|
||
info.textContent = `Maximum ${maxColors} color${maxColors !== 1 ? 's' : ''} reached for this pattern.`;
|
||
presetColorsContainer.appendChild(info);
|
||
}
|
||
|
||
const swatchContainer = document.createElement('div');
|
||
swatchContainer.style.cssText = 'display: flex; flex-wrap: nowrap; gap: 0.5rem; align-items: flex-start; overflow-x: auto;';
|
||
swatchContainer.classList.add('color-swatches-container');
|
||
|
||
currentPresetColors.forEach((color, index) => {
|
||
const swatchWrapper = document.createElement('div');
|
||
swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
|
||
swatchWrapper.draggable = true;
|
||
swatchWrapper.dataset.colorIndex = index;
|
||
const refAtIndex = currentPresetPaletteRefs[index];
|
||
swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : '';
|
||
swatchWrapper.classList.add('draggable-color-swatch');
|
||
|
||
const swatch = document.createElement('div');
|
||
swatch.style.cssText = `
|
||
width: 64px;
|
||
height: 64px;
|
||
border-radius: 8px;
|
||
background-color: ${color};
|
||
border: 2px solid #4a4a4a;
|
||
cursor: move;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||
transition: opacity 0.2s, transform 0.2s;
|
||
`;
|
||
swatch.title = `${color} - Drag to reorder`;
|
||
|
||
if (Number.isInteger(refAtIndex)) {
|
||
const linkedBadge = document.createElement('span');
|
||
linkedBadge.textContent = 'P';
|
||
linkedBadge.title = `Linked to palette color #${refAtIndex + 1}`;
|
||
linkedBadge.style.cssText = `
|
||
position: absolute;
|
||
left: -6px;
|
||
top: -6px;
|
||
min-width: 18px;
|
||
height: 18px;
|
||
border-radius: 9px;
|
||
background: #3f51b5;
|
||
color: #fff;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 11;
|
||
border: 1px solid rgba(255,255,255,0.35);
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
|
||
`;
|
||
swatchWrapper.appendChild(linkedBadge);
|
||
}
|
||
|
||
// Color picker overlay
|
||
const colorPicker = document.createElement('input');
|
||
colorPicker.type = 'color';
|
||
colorPicker.value = color;
|
||
colorPicker.style.cssText = `
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 64px;
|
||
height: 64px;
|
||
opacity: 0;
|
||
cursor: pointer;
|
||
z-index: 5;
|
||
`;
|
||
colorPicker.addEventListener('change', (e) => {
|
||
currentPresetColors[index] = e.target.value;
|
||
// Manual picker edit breaks palette linkage for this slot.
|
||
currentPresetPaletteRefs[index] = null;
|
||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||
});
|
||
// Prevent color picker from interfering with drag
|
||
colorPicker.addEventListener('mousedown', (e) => {
|
||
e.stopPropagation();
|
||
});
|
||
|
||
// Remove button
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.textContent = '×';
|
||
removeBtn.style.cssText = `
|
||
position: absolute;
|
||
top: -8px;
|
||
right: -8px;
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 50%;
|
||
background-color: #ff4444;
|
||
color: white;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10;
|
||
padding: 0;
|
||
`;
|
||
removeBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
currentPresetColors.splice(index, 1);
|
||
currentPresetPaletteRefs.splice(index, 1);
|
||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||
});
|
||
// Prevent remove button from interfering with drag
|
||
removeBtn.addEventListener('mousedown', (e) => {
|
||
e.stopPropagation();
|
||
});
|
||
|
||
// Drag event handlers for reordering
|
||
swatchWrapper.addEventListener('dragstart', (e) => {
|
||
swatchWrapper.classList.add('dragging-color');
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', index.toString());
|
||
swatch.style.opacity = '0.5';
|
||
});
|
||
|
||
swatchWrapper.addEventListener('dragend', (e) => {
|
||
swatchWrapper.classList.remove('dragging-color');
|
||
swatch.style.opacity = '1';
|
||
// Remove drag-over classes from siblings
|
||
document.querySelectorAll('.draggable-color-swatch').forEach(el => {
|
||
el.classList.remove('drag-over-color');
|
||
});
|
||
});
|
||
|
||
swatchWrapper.appendChild(swatch);
|
||
swatchWrapper.appendChild(colorPicker);
|
||
swatchWrapper.appendChild(removeBtn);
|
||
swatchContainer.appendChild(swatchWrapper);
|
||
});
|
||
|
||
// Add drag and drop handlers to the container
|
||
swatchContainer.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
const dragging = swatchContainer.querySelector('.dragging-color');
|
||
if (!dragging) return;
|
||
|
||
const afterElement = getDragAfterElementForColors(swatchContainer, e.clientX);
|
||
if (afterElement == null) {
|
||
swatchContainer.appendChild(dragging);
|
||
} else {
|
||
swatchContainer.insertBefore(dragging, afterElement);
|
||
}
|
||
});
|
||
|
||
swatchContainer.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
const dragging = swatchContainer.querySelector('.dragging-color');
|
||
if (!dragging) return;
|
||
|
||
// Get new order of colors from DOM
|
||
const colorElements = [...swatchContainer.querySelectorAll('.draggable-color-swatch')];
|
||
const newColorOrder = colorElements.map(el => {
|
||
const colorPicker = el.querySelector('input[type="color"]');
|
||
return colorPicker ? colorPicker.value : null;
|
||
}).filter(color => color !== null);
|
||
const newRefOrder = colorElements.map((el) => {
|
||
const refRaw = el.dataset.paletteRef;
|
||
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||
return Number.isInteger(ref) ? ref : null;
|
||
});
|
||
|
||
// Update current colors array
|
||
currentPresetColors = newColorOrder;
|
||
currentPresetPaletteRefs = newRefOrder;
|
||
|
||
// Re-render to update indices
|
||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||
});
|
||
|
||
presetColorsContainer.appendChild(swatchContainer);
|
||
};
|
||
|
||
// Function to get drag after element for colors (horizontal layout)
|
||
const getDragAfterElementForColors = (container, x) => {
|
||
const draggableElements = [...container.querySelectorAll('.draggable-color-swatch:not(.dragging-color)')];
|
||
|
||
return draggableElements.reduce((closest, child) => {
|
||
const box = child.getBoundingClientRect();
|
||
const offset = x - box.left - box.width / 2;
|
||
|
||
if (offset < 0 && offset > closest.offset) {
|
||
return { offset: offset, element: child };
|
||
} else {
|
||
return closest;
|
||
}
|
||
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
||
};
|
||
|
||
const setFormValues = (preset) => {
|
||
if (!presetNameInput || !presetPatternInput || !presetBrightnessInput || !presetDelayInput) {
|
||
return;
|
||
}
|
||
presetNameInput.value = preset.name || '';
|
||
const patternName = preset.pattern || '';
|
||
presetPatternInput.value = patternName;
|
||
const colors = Array.isArray(preset.colors) ? preset.colors.slice() : [];
|
||
const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs.slice() : [];
|
||
renderPresetColors(colors, paletteRefs);
|
||
presetBrightnessInput.value = preset.brightness || 0;
|
||
presetDelayInput.value = preset.delay || 0;
|
||
if (presetBackgroundInput) {
|
||
const rawBgRef = preset.background_palette_ref ?? preset.backgroundPaletteRef;
|
||
let bgRef = null;
|
||
if (rawBgRef != null && rawBgRef !== '') {
|
||
const n = typeof rawBgRef === 'number' ? rawBgRef : parseInt(String(rawBgRef), 10);
|
||
if (Number.isInteger(n) && n >= 0) {
|
||
bgRef = n;
|
||
}
|
||
}
|
||
currentBackgroundPaletteRef = bgRef;
|
||
presetBackgroundInput.value = coercePresetBackground(preset);
|
||
updatePresetBackgroundButton();
|
||
const gen = ++bgPaletteResolveGen;
|
||
void getCurrentProfilePaletteColors().then((pal) => {
|
||
if (gen !== bgPaletteResolveGen || !presetBackgroundInput) {
|
||
return;
|
||
}
|
||
presetBackgroundInput.value = resolvePresetBackgroundHex(preset, pal);
|
||
updatePresetBackgroundButton();
|
||
});
|
||
} else {
|
||
updatePresetBackgroundButton();
|
||
}
|
||
if (presetManualModeInput) {
|
||
const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true;
|
||
presetManualModeInput.checked = !autoVal;
|
||
}
|
||
if (presetManualBeatNInput) {
|
||
const raw = preset.manual_beat_n;
|
||
let n = typeof raw === 'number' ? raw : parseInt(String(raw != null ? raw : '1'), 10);
|
||
if (!Number.isFinite(n)) n = 1;
|
||
n = Math.max(1, Math.min(64, n));
|
||
presetManualBeatNInput.value = String(n);
|
||
}
|
||
|
||
// Update color section visibility based on pattern
|
||
updateColorSectionVisibility();
|
||
|
||
// Make name and pattern read-only when editing (not when creating new)
|
||
const isEditing = currentEditId !== null;
|
||
presetNameInput.disabled = isEditing;
|
||
presetPatternInput.disabled = isEditing;
|
||
if (isEditing) {
|
||
presetNameInput.style.backgroundColor = '#2a2a2a';
|
||
presetNameInput.style.cursor = 'not-allowed';
|
||
presetPatternInput.style.backgroundColor = '#2a2a2a';
|
||
presetPatternInput.style.cursor = 'not-allowed';
|
||
} else {
|
||
presetNameInput.style.backgroundColor = '';
|
||
presetNameInput.style.cursor = '';
|
||
presetPatternInput.style.backgroundColor = '';
|
||
presetPatternInput.style.cursor = '';
|
||
}
|
||
|
||
// Get pattern config to map descriptive names back to n keys
|
||
const patternConfig = cachedPatterns && cachedPatterns[patternName];
|
||
const nToLabel = {};
|
||
if (patternConfig && typeof patternConfig === 'object') {
|
||
Object.entries(patternConfig).forEach(([nKey, label]) => {
|
||
if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') {
|
||
nToLabel[nKey] = label;
|
||
}
|
||
});
|
||
}
|
||
|
||
if (presetReverseInput) {
|
||
const n5raw = preset.n5;
|
||
const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw != null ? n5raw : '0'), 10);
|
||
presetReverseInput.checked = Number.isFinite(n5) && n5 > 0;
|
||
}
|
||
|
||
// Set n values, checking both n keys and descriptive names
|
||
for (let i = 1; i <= 8; i++) {
|
||
const nKey = `n${i}`;
|
||
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
||
if (inputEl) {
|
||
if (preset[nKey] !== undefined && preset[nKey] !== null) {
|
||
const raw = preset[nKey];
|
||
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10);
|
||
inputEl.value = String(Number.isFinite(n) ? n : 0);
|
||
} else {
|
||
const label = nToLabel[nKey];
|
||
if (label && preset[label] !== undefined && preset[label] !== null) {
|
||
const rawL = preset[label];
|
||
const nL = typeof rawL === 'number' ? rawL : parseInt(String(rawL), 10);
|
||
inputEl.value = String(Number.isFinite(nL) ? nL : 0);
|
||
} else {
|
||
inputEl.value = '0';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs
|
||
updatePresetNLabels(patternName, preset);
|
||
updateManualModeAvailability();
|
||
updatePresetEditorTabActionsVisibility();
|
||
};
|
||
|
||
const clearForm = () => {
|
||
bgPaletteResolveGen += 1;
|
||
currentEditId = null;
|
||
currentEditTabId = null;
|
||
currentPresetColors = [];
|
||
currentPresetPaletteRefs = [];
|
||
setFormValues({
|
||
name: '',
|
||
pattern: '',
|
||
colors: [],
|
||
brightness: 0,
|
||
delay: 0,
|
||
n1: 0,
|
||
n2: 0,
|
||
n3: 0,
|
||
n4: 0,
|
||
n5: 0,
|
||
n6: 0,
|
||
n7: 0,
|
||
n8: 0,
|
||
background: '#000000',
|
||
auto: true,
|
||
manual_beat_n: 1,
|
||
});
|
||
if (presetManualModeInput) {
|
||
presetManualModeInput.checked = false;
|
||
}
|
||
if (presetReverseInput) {
|
||
presetReverseInput.checked = false;
|
||
}
|
||
setPresetReverseFieldVisible(false);
|
||
if (presetManualBeatNInput) {
|
||
presetManualBeatNInput.value = '1';
|
||
}
|
||
updatePresetBackgroundButton();
|
||
updateManualModeAvailability();
|
||
// Re-enable name and pattern when clearing (for new preset)
|
||
if (presetNameInput) {
|
||
presetNameInput.disabled = false;
|
||
presetNameInput.style.backgroundColor = '';
|
||
presetNameInput.style.cursor = '';
|
||
}
|
||
if (presetPatternInput) {
|
||
presetPatternInput.disabled = false;
|
||
presetPatternInput.style.backgroundColor = '';
|
||
presetPatternInput.style.cursor = '';
|
||
}
|
||
updatePresetEditorTabActionsVisibility();
|
||
};
|
||
|
||
const getActiveTabId = () => {
|
||
if (currentEditTabId) {
|
||
return currentEditTabId;
|
||
}
|
||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||
return section ? section.dataset.zoneId : null;
|
||
};
|
||
|
||
const updatePresetEditorTabActionsVisibility = async () => {
|
||
if (!presetRemoveFromTabButton) return;
|
||
if (!currentEditTabId || !currentEditId) {
|
||
presetRemoveFromTabButton.hidden = true;
|
||
return;
|
||
}
|
||
try {
|
||
const tabRes = await fetch(`/zones/${currentEditTabId}`, {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!tabRes.ok) {
|
||
presetRemoveFromTabButton.hidden = false;
|
||
return;
|
||
}
|
||
const tabData = await tabRes.json();
|
||
const allowed =
|
||
typeof window.zoneAllowsPresets === 'function'
|
||
? window.zoneAllowsPresets(tabData, currentEditTabId)
|
||
: true;
|
||
presetRemoveFromTabButton.hidden = !allowed;
|
||
} catch (e) {
|
||
presetRemoveFromTabButton.hidden = false;
|
||
}
|
||
};
|
||
|
||
const updateTabDefaultPreset = async (presetId) => {
|
||
const zoneId = getActiveTabId();
|
||
if (!zoneId) {
|
||
return;
|
||
}
|
||
try {
|
||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!tabResponse.ok) {
|
||
return;
|
||
}
|
||
const tabData = await tabResponse.json();
|
||
tabData.default_preset = presetId;
|
||
await fetch(`/zones/${zoneId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(tabData),
|
||
});
|
||
} catch (error) {
|
||
console.warn('Failed to save zone default preset:', error);
|
||
}
|
||
};
|
||
|
||
const openEditor = () => {
|
||
if (presetEditorModal) {
|
||
presetEditorModal.classList.add('active');
|
||
}
|
||
const patternName = presetPatternInput ? presetPatternInput.value : '';
|
||
const modeBefore = patternSupportsModes(patternName)
|
||
? presetStoredMode({
|
||
mode: presetModeInput ? presetModeInput.value : undefined,
|
||
n6: getNumberInput('preset-n6-input'),
|
||
})
|
||
: 0;
|
||
loadPatterns().then(() => {
|
||
updatePresetNLabels(patternName, { mode: modeBefore, n6: modeBefore });
|
||
updateColorSectionVisibility();
|
||
});
|
||
};
|
||
|
||
const closeEditor = () => {
|
||
if (presetEditorModal) {
|
||
presetEditorModal.classList.remove('active');
|
||
}
|
||
};
|
||
|
||
const buildPresetPayload = () => {
|
||
const payload = {
|
||
name: presetNameInput ? presetNameInput.value.trim() : '',
|
||
pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
|
||
colors: currentPresetColors || [],
|
||
palette_refs: currentPresetPaletteRefs || [],
|
||
// Use canonical field names expected by the device / API
|
||
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
|
||
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
|
||
background: presetBackgroundInput ? presetBackgroundInput.value : '#000000',
|
||
background_palette_ref: currentBackgroundPaletteRef != null ? currentBackgroundPaletteRef : null,
|
||
auto: presetManualModeInput ? !presetManualModeInput.checked : true,
|
||
manual_beat_n: (() => {
|
||
if (!presetManualBeatNInput) return 1;
|
||
let n = parseInt(presetManualBeatNInput.value, 10);
|
||
if (!Number.isFinite(n)) n = 1;
|
||
return Math.max(1, Math.min(64, n));
|
||
})(),
|
||
};
|
||
|
||
// Always store numeric parameters as n1..n8 (except n6 when pattern uses mode).
|
||
const modeEntries = patternSupportsModes(payload.pattern)
|
||
? getPatternModeOptions(payload.pattern)
|
||
: null;
|
||
const reverseField = patternSupportsReverse(payload.pattern);
|
||
for (let i = 1; i <= 8; i++) {
|
||
const nKey = `n${i}`;
|
||
if (modeEntries && nKey === 'n6') {
|
||
continue;
|
||
}
|
||
if (reverseField && nKey === 'n5') {
|
||
continue;
|
||
}
|
||
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
|
||
}
|
||
if (reverseField) {
|
||
payload.n5 = presetReverseInput && presetReverseInput.checked ? 1 : 0;
|
||
}
|
||
if (modeEntries && presetModeInput) {
|
||
payload.mode = parseInt(presetModeInput.value, 10) || 0;
|
||
}
|
||
|
||
return payload;
|
||
};
|
||
|
||
const normalizePatternMap = (raw) => {
|
||
if (!raw) return {};
|
||
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
||
// Support wrapped payloads like { patterns: {...} }.
|
||
if (raw.patterns && typeof raw.patterns === 'object' && !Array.isArray(raw.patterns)) {
|
||
return raw.patterns;
|
||
}
|
||
return raw;
|
||
}
|
||
if (Array.isArray(raw)) {
|
||
// Support list payloads like [{name: "blink", ...}, ...].
|
||
return raw.reduce((acc, item, idx) => {
|
||
if (item && typeof item === 'object') {
|
||
const name = item.name || item.id || String(idx);
|
||
acc[String(name)] = item;
|
||
}
|
||
return acc;
|
||
}, {});
|
||
}
|
||
return {};
|
||
};
|
||
|
||
const loadPatterns = async () => {
|
||
if (!presetPatternInput) {
|
||
return;
|
||
}
|
||
try {
|
||
// Load pattern definitions from pattern.json
|
||
let patternsPayload = null;
|
||
let response = await fetch('/patterns/definitions', {
|
||
cache: 'no-store',
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (response.ok) {
|
||
patternsPayload = await response.json();
|
||
}
|
||
|
||
let normalized = normalizePatternMap(patternsPayload);
|
||
if (!Object.keys(normalized).length) {
|
||
// Fallback when definitions route is unavailable or returns an empty map.
|
||
response = await fetch('/patterns', {
|
||
cache: 'no-store',
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!response.ok) {
|
||
return;
|
||
}
|
||
patternsPayload = await response.json();
|
||
normalized = normalizePatternMap(patternsPayload);
|
||
}
|
||
cachedPatterns = normalized;
|
||
const entries = Object.keys(cachedPatterns);
|
||
const desiredPattern = presetPatternInput.value;
|
||
presetPatternInput.innerHTML = '<option value="">Pattern</option>';
|
||
entries.forEach((patternName) => {
|
||
const option = document.createElement('option');
|
||
option.value = patternName;
|
||
option.textContent = patternName;
|
||
presetPatternInput.appendChild(option);
|
||
});
|
||
if (desiredPattern && cachedPatterns[desiredPattern]) {
|
||
presetPatternInput.value = desiredPattern;
|
||
} else if (entries.length > 0) {
|
||
let defaultPattern = entries[0];
|
||
for (const patternName of entries) {
|
||
const config = cachedPatterns[patternName];
|
||
const hasMapping = config && Object.keys(config).some((key) => {
|
||
return typeof key === 'string' && key.startsWith('n');
|
||
});
|
||
if (hasMapping) {
|
||
defaultPattern = patternName;
|
||
break;
|
||
}
|
||
}
|
||
presetPatternInput.value = defaultPattern;
|
||
}
|
||
updatePresetNLabels(presetPatternInput.value);
|
||
} catch (error) {
|
||
console.warn('Failed to load patterns:', error);
|
||
}
|
||
};
|
||
|
||
const updatePresetNLabels = (patternName, presetForMode = null) => {
|
||
const patternConfig = resolvePatternConfig(patternName);
|
||
const labels = {};
|
||
const visibleNKeys = new Set();
|
||
|
||
if (patternConfig && typeof patternConfig === 'object') {
|
||
Object.entries(patternConfig).forEach(([key, label]) => {
|
||
if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') {
|
||
const text = label.trim();
|
||
if (text) {
|
||
labels[key] = `${text}:`;
|
||
visibleNKeys.add(key);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
const modeEntries = patternSupportsModes(patternName) ? getPatternModeOptions(patternName) : null;
|
||
const reverseField = patternSupportsReverse(patternName);
|
||
if (modeEntries) {
|
||
visibleNKeys.delete('n6');
|
||
}
|
||
if (reverseField) {
|
||
visibleNKeys.delete('n5');
|
||
}
|
||
setPresetReverseFieldVisible(reverseField);
|
||
if (reverseField && presetReverseInput) {
|
||
const n5raw = presetForMode && presetForMode.n5 !== undefined ? presetForMode.n5 : 0;
|
||
const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw), 10);
|
||
presetReverseInput.checked = Number.isFinite(n5) && n5 > 0;
|
||
}
|
||
if (presetModeInput) {
|
||
if (modeEntries) {
|
||
setPresetModeFieldVisible(true);
|
||
presetModeInput.innerHTML = '';
|
||
modeEntries.forEach(([val, label]) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = val;
|
||
opt.textContent = label.trim();
|
||
presetModeInput.appendChild(opt);
|
||
});
|
||
const modeVal = presetForMode ? presetStoredMode(presetForMode) : 0;
|
||
const modeStr = String(modeVal);
|
||
if ([...presetModeInput.options].some((o) => o.value === modeStr)) {
|
||
presetModeInput.value = modeStr;
|
||
} else if (presetModeInput.options.length) {
|
||
presetModeInput.selectedIndex = 0;
|
||
}
|
||
} else {
|
||
setPresetModeFieldVisible(false);
|
||
}
|
||
}
|
||
|
||
const hasPatternMeta =
|
||
patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0;
|
||
const hasAnyNLabel = visibleNKeys.size > 0 || Boolean(modeEntries);
|
||
|
||
for (let i = 1; i <= 8; i++) {
|
||
const nKey = `n${i}`;
|
||
const labelEl = document.getElementById(`preset-${nKey}-label`);
|
||
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
|
||
const show = visibleNKeys.has(nKey);
|
||
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
||
|
||
if (labelEl) {
|
||
labelEl.textContent = show ? labels[nKey] : '';
|
||
}
|
||
if (groupEl) {
|
||
groupEl.style.display = show ? '' : 'none';
|
||
}
|
||
// Only clear hidden n inputs when we know this pattern's metadata (avoids wiping n3..n4
|
||
// while definitions are still loading, or when twinkle exists only as a driver file).
|
||
if (inputEl && !show && (hasAnyNLabel || hasPatternMeta)) {
|
||
inputEl.value = '0';
|
||
}
|
||
}
|
||
|
||
const nGrid = presetEditorModal && presetEditorModal.querySelector('.n-params-grid');
|
||
if (nGrid) {
|
||
nGrid.style.display = visibleNKeys.size > 0 ? '' : 'none';
|
||
}
|
||
updateManualModeAvailability();
|
||
};
|
||
|
||
const renderPresets = (presets) => {
|
||
presetsList.innerHTML = '';
|
||
cachedPresets = presets || {};
|
||
const entries = Object.entries(cachedPresets);
|
||
if (!entries.length) {
|
||
const empty = document.createElement('p');
|
||
empty.className = 'muted-text';
|
||
empty.textContent = 'No presets found.';
|
||
presetsList.appendChild(empty);
|
||
return;
|
||
}
|
||
entries.forEach(([presetId, preset]) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'profiles-row';
|
||
|
||
const label = document.createElement('span');
|
||
label.textContent = (preset && preset.name) || presetId;
|
||
|
||
const details = document.createElement('span');
|
||
const pattern = preset && preset.pattern ? preset.pattern : '-';
|
||
details.textContent = pattern;
|
||
details.style.color = '#aaa';
|
||
details.style.fontSize = '0.85em';
|
||
|
||
const editButton = document.createElement('button');
|
||
editButton.className = 'btn btn-secondary btn-small';
|
||
editButton.textContent = 'Edit';
|
||
editButton.addEventListener('click', async () => {
|
||
currentEditId = presetId;
|
||
currentEditTabId = null;
|
||
await loadPatterns();
|
||
const paletteColors = await getCurrentProfilePaletteColors();
|
||
const presetForEditor = {
|
||
...(preset || {}),
|
||
colors: resolveColorsWithPaletteRefs(
|
||
(preset && preset.colors) || [],
|
||
(preset && preset.palette_refs) || [],
|
||
paletteColors,
|
||
),
|
||
};
|
||
setFormValues(presetForEditor);
|
||
openEditor();
|
||
});
|
||
|
||
const sendButton = document.createElement('button');
|
||
sendButton.className = 'btn btn-primary btn-small';
|
||
sendButton.textContent = 'Send';
|
||
sendButton.title = 'Send this preset to drivers';
|
||
sendButton.addEventListener('click', () => {
|
||
// Just send the definition; selection happens when user clicks the preset.
|
||
void sendPresetViaEspNow(presetId, preset || {}, []);
|
||
});
|
||
|
||
const exportButton = document.createElement('button');
|
||
exportButton.className = 'btn btn-secondary btn-small';
|
||
exportButton.textContent = 'Export';
|
||
exportButton.addEventListener('click', async () => {
|
||
try {
|
||
const response = await fetch(`/presets/${presetId}/export`, {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error('Export failed');
|
||
}
|
||
const bundle = await response.json();
|
||
const safeName = ((preset && preset.name) || presetId).replace(/[^\w.-]+/g, '_');
|
||
window.downloadJsonFile(`preset-${safeName}.json`, bundle);
|
||
} catch (error) {
|
||
console.error('Export preset failed:', error);
|
||
alert('Failed to export preset.');
|
||
}
|
||
});
|
||
|
||
const deleteButton = document.createElement('button');
|
||
deleteButton.className = 'btn btn-danger btn-small';
|
||
deleteButton.textContent = 'Delete';
|
||
deleteButton.addEventListener('click', async () => {
|
||
const confirmed = confirm(`Delete preset "${label.textContent}"?`);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
try {
|
||
const response = await fetch(`/presets/${presetId}`, {
|
||
method: 'DELETE',
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error('Failed to delete preset');
|
||
}
|
||
await loadPresets();
|
||
if (currentEditId === presetId) {
|
||
clearForm();
|
||
}
|
||
} catch (error) {
|
||
console.error('Delete preset failed:', error);
|
||
alert('Failed to delete preset.');
|
||
}
|
||
});
|
||
|
||
row.appendChild(label);
|
||
row.appendChild(details);
|
||
row.appendChild(editButton);
|
||
row.appendChild(exportButton);
|
||
row.appendChild(sendButton);
|
||
row.appendChild(deleteButton);
|
||
presetsList.appendChild(row);
|
||
});
|
||
};
|
||
|
||
const loadPresets = async () => {
|
||
presetsList.innerHTML = '';
|
||
const loading = document.createElement('p');
|
||
loading.className = 'muted-text';
|
||
loading.textContent = 'Loading presets...';
|
||
presetsList.appendChild(loading);
|
||
|
||
try {
|
||
const response = await fetch('/presets', {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load presets');
|
||
}
|
||
const presets = await response.json();
|
||
const filtered = await filterPresetsForCurrentProfile(presets);
|
||
renderPresets(filtered);
|
||
} catch (error) {
|
||
console.error('Load presets failed:', error);
|
||
presetsList.innerHTML = '';
|
||
const errorMessage = document.createElement('p');
|
||
errorMessage.className = 'muted-text';
|
||
errorMessage.textContent = 'Failed to load presets.';
|
||
presetsList.appendChild(errorMessage);
|
||
}
|
||
};
|
||
|
||
const openModal = () => {
|
||
presetsModal.classList.add('active');
|
||
loadPresets();
|
||
};
|
||
|
||
const closeModal = () => {
|
||
presetsModal.classList.remove('active');
|
||
};
|
||
|
||
presetsButton.addEventListener('click', openModal);
|
||
if (presetsCloseButton) {
|
||
presetsCloseButton.addEventListener('click', closeModal);
|
||
}
|
||
const importPresetBtn = document.getElementById('import-preset-btn');
|
||
if (importPresetBtn) {
|
||
importPresetBtn.addEventListener('click', async () => {
|
||
const text = await window.pickJsonFile();
|
||
if (!text) return;
|
||
const bundle = window.parseJsonFileText(text);
|
||
if (!bundle || bundle.kind !== 'preset') {
|
||
alert('Invalid preset bundle file.');
|
||
return;
|
||
}
|
||
try {
|
||
const response = await fetch('/presets/import', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||
body: JSON.stringify({ bundle }),
|
||
});
|
||
if (!response.ok) {
|
||
const err = await response.json().catch(() => ({}));
|
||
throw new Error(err.error || 'Import failed');
|
||
}
|
||
await loadPresets();
|
||
} catch (error) {
|
||
console.error('Import preset failed:', error);
|
||
alert(error.message || 'Failed to import preset.');
|
||
}
|
||
});
|
||
}
|
||
|
||
if (presetsAddButton) {
|
||
presetsAddButton.addEventListener('click', () => {
|
||
clearForm();
|
||
openEditor();
|
||
});
|
||
}
|
||
if (presetClearDeviceButton) {
|
||
presetClearDeviceButton.addEventListener('click', async () => {
|
||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||
const deviceNames = tabDeviceNamesFromSection(section);
|
||
if (!deviceNames.length) {
|
||
alert('No devices found in the current zone.');
|
||
return;
|
||
}
|
||
if (!window.confirm('Clear all presets on current zone devices?')) {
|
||
return;
|
||
}
|
||
try {
|
||
const targetMacs =
|
||
typeof window.tabsManager !== 'undefined' &&
|
||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
|
||
: [];
|
||
await postDriverSequence([{ v: '1', clear_presets: true, save: true }], targetMacs);
|
||
} catch (error) {
|
||
console.error('Clear device presets failed:', error);
|
||
alert('Failed to clear presets on devices.');
|
||
}
|
||
});
|
||
}
|
||
|
||
const showAddPresetToTabModal = async (optionalTabId) => {
|
||
let zoneId = optionalTabId;
|
||
if (!zoneId) {
|
||
// Get current zone ID from the presets section
|
||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
|
||
}
|
||
if (!zoneId) {
|
||
// Fallback: try to get from URL
|
||
const pathParts = window.location.pathname.split('/');
|
||
const tabIndex = pathParts.indexOf('zones');
|
||
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
|
||
zoneId = pathParts[tabIndex + 1];
|
||
}
|
||
}
|
||
if (!zoneId) {
|
||
alert('Could not determine current zone.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const zoneCheck = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||
if (zoneCheck.ok) {
|
||
const zoneDoc = await zoneCheck.json();
|
||
if (
|
||
typeof window.zoneAllowsPresets === 'function' &&
|
||
!window.zoneAllowsPresets(zoneDoc, zoneId)
|
||
) {
|
||
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('Could not verify zone content kind:', e);
|
||
}
|
||
|
||
// Load all presets
|
||
try {
|
||
const response = await fetch('/presets', {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load presets');
|
||
}
|
||
const allPresetsRaw = await response.json();
|
||
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
|
||
|
||
// Load only the current zone's presets so we can avoid duplicates within this zone.
|
||
let currentTabPresets = [];
|
||
try {
|
||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (tabResponse.ok) {
|
||
const tabData = await tabResponse.json();
|
||
if (Array.isArray(tabData.presets_flat)) {
|
||
currentTabPresets = tabData.presets_flat.slice();
|
||
} else if (Array.isArray(tabData.presets)) {
|
||
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||
currentTabPresets = tabData.presets.slice();
|
||
} else if (Array.isArray(tabData.presets[0])) {
|
||
currentTabPresets = tabData.presets.flat();
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('Could not load current zone presets:', e);
|
||
}
|
||
|
||
// Create modal
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal active modal-child-overlay';
|
||
modal.id = 'add-preset-to-zone-modal';
|
||
modal.innerHTML = `
|
||
<div class="modal-content">
|
||
<h2>Add Preset to Zone</h2>
|
||
<div id="add-preset-list" class="profiles-list" style="max-height: 400px; overflow-y: auto;"></div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-secondary" id="add-preset-to-zone-close-btn">Close</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
const listContainer = document.getElementById('add-preset-list');
|
||
const presetNames = Object.keys(allPresets);
|
||
|
||
const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId));
|
||
if (availableToAdd.length === 0) {
|
||
listContainer.innerHTML = '<p class="muted-text">No presets to add. All presets are already in this zone, or create a preset first.</p>';
|
||
} else {
|
||
availableToAdd.forEach(presetId => {
|
||
const preset = allPresets[presetId];
|
||
const row = document.createElement('div');
|
||
row.className = 'profiles-row';
|
||
|
||
const label = document.createElement('span');
|
||
label.textContent = preset.name || presetId;
|
||
|
||
const details = document.createElement('span');
|
||
details.style.color = '#aaa';
|
||
details.style.fontSize = '0.85em';
|
||
details.textContent = preset.pattern || '-';
|
||
|
||
const addButton = document.createElement('button');
|
||
addButton.className = 'btn btn-primary btn-small';
|
||
addButton.textContent = 'Add';
|
||
addButton.addEventListener('click', async () => {
|
||
await addPresetToTab(presetId, zoneId);
|
||
modal.remove();
|
||
});
|
||
|
||
row.appendChild(label);
|
||
row.appendChild(details);
|
||
row.appendChild(addButton);
|
||
listContainer.appendChild(row);
|
||
});
|
||
}
|
||
|
||
// Close button handler
|
||
document.getElementById('add-preset-to-zone-close-btn').addEventListener('click', () => {
|
||
modal.remove();
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Failed to show add preset modal:', error);
|
||
alert('Failed to load presets.');
|
||
}
|
||
};
|
||
try {
|
||
window.showAddPresetToTabModal = showAddPresetToTabModal;
|
||
} catch (e) {}
|
||
|
||
const addPresetToTab = async (presetId, zoneId) => {
|
||
if (!zoneId) {
|
||
// Try to get zone ID from the left-panel
|
||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
|
||
|
||
if (!zoneId) {
|
||
// Fallback: try to get from URL
|
||
const pathParts = window.location.pathname.split('/');
|
||
const tabIndex = pathParts.indexOf('zones');
|
||
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
|
||
zoneId = pathParts[tabIndex + 1];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!zoneId) {
|
||
alert('Could not determine current zone.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Get current zone data
|
||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!tabResponse.ok) {
|
||
throw new Error('Failed to load zone');
|
||
}
|
||
const tabData = await tabResponse.json();
|
||
if (
|
||
typeof window.zoneAllowsPresets === 'function' &&
|
||
!window.zoneAllowsPresets(tabData, zoneId)
|
||
) {
|
||
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||
return;
|
||
}
|
||
|
||
// Normalize to flat array to check and update usage
|
||
let flat = [];
|
||
if (Array.isArray(tabData.presets_flat)) {
|
||
flat = tabData.presets_flat.slice();
|
||
} else if (Array.isArray(tabData.presets)) {
|
||
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||
flat = tabData.presets.slice();
|
||
} else if (Array.isArray(tabData.presets[0])) {
|
||
flat = tabData.presets.flat();
|
||
}
|
||
}
|
||
|
||
if (flat.includes(presetId)) {
|
||
alert('Preset is already added to this zone.');
|
||
return;
|
||
}
|
||
|
||
flat.push(presetId);
|
||
const newGrid = arrayToGrid(flat, 3);
|
||
tabData.presets = newGrid;
|
||
tabData.presets_flat = flat;
|
||
|
||
// Update zone
|
||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(tabData),
|
||
});
|
||
|
||
if (!updateResponse.ok) {
|
||
throw new Error('Failed to update zone');
|
||
}
|
||
|
||
// Reload the zone content to show the new preset
|
||
if (typeof renderTabPresets === 'function') {
|
||
await renderTabPresets(zoneId);
|
||
} else if (window.htmx) {
|
||
htmx.ajax('GET', `/zones/${zoneId}/content-fragment`, {
|
||
target: '#zone-content',
|
||
swap: 'innerHTML'
|
||
});
|
||
} else {
|
||
// Fallback: reload the page
|
||
window.location.reload();
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to add preset to zone:', error);
|
||
alert('Failed to add preset to zone.');
|
||
}
|
||
};
|
||
try {
|
||
window.addPresetToTab = addPresetToTab;
|
||
} catch (e) {}
|
||
if (presetEditorCloseButton) {
|
||
presetEditorCloseButton.addEventListener('click', closeEditor);
|
||
}
|
||
if (presetPatternInput) {
|
||
presetPatternInput.addEventListener('change', () => {
|
||
updatePresetNLabels(presetPatternInput.value);
|
||
// Update color section visibility
|
||
updateColorSectionVisibility();
|
||
// Re-render colors to show updated max colors limit
|
||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||
updateManualModeAvailability();
|
||
});
|
||
}
|
||
if (presetManualModeInput) {
|
||
presetManualModeInput.addEventListener('change', () => {
|
||
updateManualBeatNVisibility();
|
||
updateDelayVisibilityForManualMode();
|
||
});
|
||
}
|
||
if (presetBackgroundButton && presetBackgroundInput) {
|
||
presetBackgroundButton.addEventListener('click', () => {
|
||
presetBackgroundInput.click();
|
||
});
|
||
presetBackgroundInput.addEventListener('input', () => {
|
||
currentBackgroundPaletteRef = null;
|
||
updatePresetBackgroundButton();
|
||
});
|
||
}
|
||
// Color picker auto-add handler
|
||
if (presetNewColorInput) {
|
||
const tryAddSelectedColor = () => {
|
||
const color = presetNewColorInput.value;
|
||
if (!color) return;
|
||
|
||
if (currentPresetColors.includes(color)) {
|
||
alert('This color is already in the list.');
|
||
return;
|
||
}
|
||
|
||
const maxColors = getMaxColors();
|
||
if (currentPresetColors.length >= maxColors) {
|
||
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
|
||
return;
|
||
}
|
||
|
||
currentPresetColors.push(color);
|
||
currentPresetPaletteRefs.push(null);
|
||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||
};
|
||
// Add when the picker closes (user confirms selection).
|
||
presetNewColorInput.addEventListener('change', tryAddSelectedColor);
|
||
}
|
||
|
||
if (presetAddFromPaletteButton) {
|
||
presetAddFromPaletteButton.addEventListener('click', async () => {
|
||
try {
|
||
const paletteColors = await getCurrentProfilePaletteColors();
|
||
if (!Array.isArray(paletteColors) || paletteColors.length === 0) {
|
||
alert('No profile palette colors available.');
|
||
return;
|
||
}
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal active modal-child-overlay';
|
||
modal.innerHTML = `
|
||
<div class="modal-content">
|
||
<h2>Pick Palette Color</h2>
|
||
<div id="pick-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-secondary" id="pick-palette-close-btn">Close</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
const list = modal.querySelector('#pick-palette-list');
|
||
paletteColors.forEach((color, idx) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'profiles-row';
|
||
row.style.display = 'flex';
|
||
row.style.alignItems = 'center';
|
||
row.style.gap = '0.75rem';
|
||
row.dataset.paletteIndex = String(idx);
|
||
row.dataset.paletteColor = color;
|
||
row.innerHTML = `
|
||
<div style="width:28px;height:28px;border-radius:4px;border:1px solid #555;background:${color};"></div>
|
||
<span style="flex:1">${color}</span>
|
||
<button class="btn btn-primary btn-small" type="button">Use</button>
|
||
`;
|
||
list.appendChild(row);
|
||
});
|
||
|
||
const close = () => modal.remove();
|
||
modal.querySelector('#pick-palette-close-btn').addEventListener('click', close);
|
||
|
||
list.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('button');
|
||
if (!btn) return;
|
||
const row = e.target.closest('[data-palette-index]');
|
||
if (!row) return;
|
||
const color = row.dataset.paletteColor;
|
||
const ref = parseInt(row.dataset.paletteIndex, 10);
|
||
if (!color || !Number.isInteger(ref)) return;
|
||
|
||
const maxColors = getMaxColors();
|
||
if (currentPresetColors.length >= maxColors) {
|
||
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
|
||
return;
|
||
}
|
||
|
||
currentPresetColors.push(color);
|
||
currentPresetPaletteRefs.push(ref);
|
||
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||
close();
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to add from palette:', err);
|
||
alert('Failed to load palette colours.');
|
||
}
|
||
});
|
||
}
|
||
|
||
if (presetBackgroundFromPaletteButton) {
|
||
presetBackgroundFromPaletteButton.addEventListener('click', async () => {
|
||
try {
|
||
const paletteColors = await getCurrentProfilePaletteColors();
|
||
if (!Array.isArray(paletteColors) || paletteColors.length === 0) {
|
||
alert('No profile palette colours available.');
|
||
return;
|
||
}
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal active modal-child-overlay';
|
||
modal.innerHTML = `
|
||
<div class="modal-content">
|
||
<h2>Pick background colour</h2>
|
||
<div id="pick-bg-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-secondary" id="pick-bg-palette-close-btn">Close</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
const list = modal.querySelector('#pick-bg-palette-list');
|
||
paletteColors.forEach((color, idx) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'profiles-row';
|
||
row.style.display = 'flex';
|
||
row.style.alignItems = 'center';
|
||
row.style.gap = '0.75rem';
|
||
row.dataset.paletteIndex = String(idx);
|
||
row.dataset.paletteColor = color;
|
||
row.innerHTML = `
|
||
<div style="width:28px;height:28px;border-radius:4px;border:1px solid #555;background:${color};"></div>
|
||
<span style="flex:1">${color}</span>
|
||
<button class="btn btn-primary btn-small" type="button">Use</button>
|
||
`;
|
||
list.appendChild(row);
|
||
});
|
||
|
||
const close = () => modal.remove();
|
||
modal.querySelector('#pick-bg-palette-close-btn').addEventListener('click', close);
|
||
|
||
list.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('button');
|
||
if (!btn) return;
|
||
const row = e.target.closest('[data-palette-index]');
|
||
if (!row) return;
|
||
const color = row.dataset.paletteColor;
|
||
const ref = parseInt(row.dataset.paletteIndex, 10);
|
||
if (!color || !Number.isInteger(ref)) return;
|
||
|
||
currentBackgroundPaletteRef = ref;
|
||
if (presetBackgroundInput) {
|
||
presetBackgroundInput.value = color;
|
||
}
|
||
updatePresetBackgroundButton();
|
||
close();
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to pick background from palette:', err);
|
||
alert('Failed to load palette colours.');
|
||
}
|
||
});
|
||
}
|
||
const presetSendButton = document.getElementById('preset-send-btn');
|
||
|
||
if (presetSendButton) {
|
||
presetSendButton.addEventListener('click', async () => {
|
||
const payload = buildPresetPayload();
|
||
if (!payload.name) {
|
||
alert('Preset name is required to send.');
|
||
return;
|
||
}
|
||
// Send current editor values to zone devices (if any); never persist on device.
|
||
const presetId = currentEditId || payload.name;
|
||
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId);
|
||
// Auto: load + immediate select. Manual: load only; first advance on the next audio beat.
|
||
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false, '2');
|
||
});
|
||
}
|
||
|
||
if (presetDefaultButton) {
|
||
presetDefaultButton.addEventListener('click', async () => {
|
||
const payload = buildPresetPayload();
|
||
if (!payload.name) {
|
||
alert('Preset name is required.');
|
||
return;
|
||
}
|
||
const presetId = currentEditId || payload.name;
|
||
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId);
|
||
await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1');
|
||
await updateTabDefaultPreset(presetId);
|
||
await sendDefaultPreset('1', deviceNames);
|
||
});
|
||
}
|
||
|
||
if (presetRemoveFromTabButton) {
|
||
presetRemoveFromTabButton.addEventListener('click', async () => {
|
||
if (!currentEditTabId || !currentEditId) return;
|
||
if (!window.confirm('Remove this preset from this zone?')) return;
|
||
await removePresetFromTab(currentEditTabId, currentEditId);
|
||
clearForm();
|
||
closeEditor();
|
||
});
|
||
}
|
||
|
||
presetSaveButton.addEventListener('click', async () => {
|
||
const payload = buildPresetPayload();
|
||
if (!payload.name) {
|
||
alert('Preset name is required.');
|
||
return;
|
||
}
|
||
try {
|
||
const url = currentEditId ? `/presets/${currentEditId}` : '/presets';
|
||
const method = currentEditId ? 'PUT' : 'POST';
|
||
const response = await fetch(url, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error('Failed to save preset');
|
||
}
|
||
|
||
// Same device targeting as Try: per-preset zone groups when in a zone tab.
|
||
const presetIdForSend = currentEditId || payload.name;
|
||
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetIdForSend);
|
||
|
||
// Use saved preset from server response for sending
|
||
const saved = await response.json().catch(() => null);
|
||
if (saved && typeof saved === 'object') {
|
||
if (currentEditId) {
|
||
// PUT returns the preset object directly; use the existing ID
|
||
await sendPresetViaEspNow(currentEditId, saved, deviceNames, false, false, '2');
|
||
} else {
|
||
// POST returns { id: preset }
|
||
const entries = Object.entries(saved);
|
||
if (entries.length > 0) {
|
||
const [newId, presetData] = entries[0];
|
||
await sendPresetViaEspNow(newId, presetData, deviceNames, false, false, '2');
|
||
}
|
||
}
|
||
} else {
|
||
// Fallback: send what we just built
|
||
await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, false, false, '2');
|
||
}
|
||
|
||
await loadPresets();
|
||
clearForm();
|
||
closeEditor();
|
||
|
||
// Reload zone presets if we're in a zone view
|
||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||
if (leftPanel) {
|
||
const zoneId = leftPanel.dataset.zoneId;
|
||
if (zoneId && typeof renderTabPresets !== 'undefined') {
|
||
renderTabPresets(zoneId);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Save preset failed:', error);
|
||
alert('Failed to save preset.');
|
||
}
|
||
});
|
||
|
||
// Listen for edit preset events from zone preset buttons
|
||
document.addEventListener('editPreset', async (event) => {
|
||
const { presetId, preset, zoneId } = event.detail;
|
||
currentEditId = presetId;
|
||
currentEditTabId = zoneId || null;
|
||
await loadPatterns();
|
||
const paletteColors = await getCurrentProfilePaletteColors();
|
||
setFormValues({
|
||
...(preset || {}),
|
||
colors: resolveColorsWithPaletteRefs(
|
||
(preset && preset.colors) || [],
|
||
(preset && preset.palette_refs) || [],
|
||
paletteColors,
|
||
),
|
||
});
|
||
openEditor();
|
||
});
|
||
|
||
clearForm();
|
||
});
|
||
|
||
/** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */
|
||
const coercePresetAuto = (preset) => {
|
||
if (!preset || typeof preset !== 'object') {
|
||
return true;
|
||
}
|
||
const v =
|
||
preset.auto !== undefined && preset.auto !== null ? preset.auto : preset.a;
|
||
if (typeof v === 'boolean') {
|
||
return v;
|
||
}
|
||
if (v === 0 || v === '0') {
|
||
return false;
|
||
}
|
||
if (v === 1 || v === '1') {
|
||
return true;
|
||
}
|
||
if (typeof v === 'string') {
|
||
const l = v.trim().toLowerCase();
|
||
if (['false', '0', 'no', 'off'].includes(l)) {
|
||
return false;
|
||
}
|
||
if (['true', '1', 'yes', 'on'].includes(l)) {
|
||
return true;
|
||
}
|
||
}
|
||
return true;
|
||
};
|
||
|
||
/** Preset background colour; accepts #RRGGBB or [r,g,b]. */
|
||
const coercePresetBackground = (preset) => {
|
||
if (!preset || typeof preset !== 'object') {
|
||
return '#000000';
|
||
}
|
||
const raw = preset.background !== undefined && preset.background !== null ? preset.background : preset.bg;
|
||
if (typeof raw === 'string') {
|
||
const s = raw.trim();
|
||
if (/^#[0-9a-fA-F]{6}$/.test(s)) {
|
||
return s.toUpperCase();
|
||
}
|
||
}
|
||
if (Array.isArray(raw) && raw.length === 3) {
|
||
const r = coercePresetInt(raw[0], 0);
|
||
const g = coercePresetInt(raw[1], 0);
|
||
const b = coercePresetInt(raw[2], 0);
|
||
const clamp = (n) => Math.max(0, Math.min(255, n));
|
||
return `#${clamp(r).toString(16).padStart(2, '0')}${clamp(g).toString(16).padStart(2, '0')}${clamp(b).toString(16).padStart(2, '0')}`.toUpperCase();
|
||
}
|
||
return '#000000';
|
||
};
|
||
|
||
/** Resolved background hex; uses ``background_palette_ref`` when set and palette is available. */
|
||
const resolvePresetBackgroundHex = (preset, paletteColors) => {
|
||
if (!preset || typeof preset !== 'object') {
|
||
return coercePresetBackground(preset);
|
||
}
|
||
const rawRef =
|
||
preset.background_palette_ref !== undefined && preset.background_palette_ref !== null
|
||
? preset.background_palette_ref
|
||
: preset.backgroundPaletteRef;
|
||
const ref = typeof rawRef === 'number' ? rawRef : parseInt(String(rawRef != null ? rawRef : ''), 10);
|
||
const pal = Array.isArray(paletteColors) ? paletteColors : [];
|
||
if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) {
|
||
const c = String(pal[ref]).trim();
|
||
if (/^#[0-9a-fA-F]{6}$/i.test(c)) {
|
||
return c.toUpperCase();
|
||
}
|
||
}
|
||
return coercePresetBackground(preset);
|
||
};
|
||
|
||
/** Audio beat stride for manual presets (led-controller only; firmware ignores this key). */
|
||
const coerceManualBeatN = (preset) => {
|
||
if (!preset || typeof preset !== 'object') return 1;
|
||
const raw = preset.manual_beat_n;
|
||
let n = typeof raw === 'number' ? raw : parseInt(String(raw != null ? raw : '1'), 10);
|
||
if (!Number.isFinite(n)) n = 1;
|
||
return Math.max(1, Math.min(64, n));
|
||
};
|
||
|
||
// Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP).
|
||
// Send order:
|
||
// 1) preset payload (optionally with save)
|
||
// 2) optional select for device names (never with save)
|
||
// saveToDevice defaults to true.
|
||
const sendPresetViaEspNow = async (
|
||
presetId,
|
||
preset,
|
||
deviceNames,
|
||
saveToDevice = true,
|
||
setDefault = false,
|
||
devicePresetId = null,
|
||
pushOptions = null,
|
||
) => {
|
||
try {
|
||
const baseColors = Array.isArray(preset.colors) && preset.colors.length
|
||
? preset.colors
|
||
: ['#FFFFFF'];
|
||
const paletteColors = await getCurrentProfilePaletteColors();
|
||
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
|
||
|
||
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
|
||
const presetAuto = coercePresetAuto(preset);
|
||
const presetBackground = resolvePresetBackgroundHex(preset, paletteColors);
|
||
const presetMessage = {
|
||
v: '1',
|
||
presets: {
|
||
[wirePresetId]: {
|
||
pattern: preset.pattern || 'off',
|
||
colors,
|
||
bg: presetBackground,
|
||
delay: typeof preset.delay === 'number' ? preset.delay : 100,
|
||
brightness: typeof preset.brightness === 'number'
|
||
? preset.brightness
|
||
: (typeof preset.br === 'number' ? preset.br : 127),
|
||
auto: presetAuto,
|
||
a: presetAuto,
|
||
n1: coercePresetInt(preset.n1),
|
||
n2: coercePresetInt(preset.n2),
|
||
n3: coercePresetInt(preset.n3),
|
||
n4: coercePresetInt(preset.n4),
|
||
n5: coercePresetInt(preset.n5),
|
||
n6: presetWireN6(preset),
|
||
manual_beat_n: coerceManualBeatN(preset),
|
||
},
|
||
},
|
||
};
|
||
if (saveToDevice) {
|
||
presetMessage.save = true;
|
||
}
|
||
if (setDefault) {
|
||
presetMessage.default = wirePresetId;
|
||
}
|
||
|
||
const names = Array.isArray(deviceNames) ? deviceNames : [];
|
||
const targetMacs =
|
||
names.length > 0 &&
|
||
typeof window.tabsManager !== 'undefined' &&
|
||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||
? await window.tabsManager.resolveTabDeviceMacs(names)
|
||
: [];
|
||
|
||
const sequence = [presetMessage];
|
||
// Auto: apply preset immediately via select. Manual: load definition only — first step is on the next audio beat.
|
||
if (names.length > 0 && presetAuto) {
|
||
const select = {};
|
||
names.forEach((name) => {
|
||
if (name) {
|
||
select[name] = [wirePresetId];
|
||
}
|
||
});
|
||
if (Object.keys(select).length > 0) {
|
||
sequence.push({ v: '1', select });
|
||
}
|
||
}
|
||
|
||
await postDriverSequence(sequence, targetMacs, 0.05, pushOptions);
|
||
} catch (error) {
|
||
console.error('Failed to send preset to devices:', error);
|
||
alert('Failed to send preset to devices.');
|
||
}
|
||
};
|
||
|
||
const sendDefaultPreset = async (presetId, deviceNames) => {
|
||
if (!presetId) {
|
||
alert('Select a preset to set as default.');
|
||
return;
|
||
}
|
||
const nameTargets = Array.isArray(deviceNames)
|
||
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
|
||
: [];
|
||
const message = { v: '1', default: presetId };
|
||
message.save = true;
|
||
if (nameTargets.length > 0) {
|
||
message.targets = nameTargets;
|
||
}
|
||
const macTargets =
|
||
nameTargets.length > 0 &&
|
||
typeof window.tabsManager !== 'undefined' &&
|
||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||
? await window.tabsManager.resolveTabDeviceMacs(nameTargets)
|
||
: [];
|
||
try {
|
||
await postDriverSequence([message], macTargets);
|
||
} catch (e) {
|
||
console.error('sendDefaultPreset:', e);
|
||
alert('Failed to send default preset to devices.');
|
||
}
|
||
};
|
||
|
||
const sendPresetSelectViaEspNow = async (presetId, deviceNames) => {
|
||
if (!presetId) {
|
||
return;
|
||
}
|
||
const nameTargets = Array.isArray(deviceNames)
|
||
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
|
||
: [];
|
||
if (!nameTargets.length) {
|
||
return;
|
||
}
|
||
const select = {};
|
||
nameTargets.forEach((name) => {
|
||
select[name] = [String(presetId)];
|
||
});
|
||
const macTargets =
|
||
nameTargets.length > 0 &&
|
||
typeof window.tabsManager !== 'undefined' &&
|
||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||
? await window.tabsManager.resolveTabDeviceMacs(nameTargets)
|
||
: [];
|
||
await postDriverSequence([{ v: '1', select }], macTargets);
|
||
};
|
||
|
||
// Expose for other scripts (zones.js) so they can reuse the shared WebSocket.
|
||
try {
|
||
window.sendPresetViaEspNow = sendPresetViaEspNow;
|
||
window.postDriverSequence = postDriverSequence;
|
||
// Expose a generic ESPNow sender so other scripts (zones.js) can send
|
||
// non-preset messages such as global brightness.
|
||
window.sendEspnowRaw = sendEspnowMessage;
|
||
window.getEspnowSocket = getEspnowSocket;
|
||
} catch (e) {
|
||
// window may not exist in some environments; ignore.
|
||
}
|
||
|
||
// Store selected preset per zone (single-select; one tile active, one driver push per click).
|
||
const zoneSelectedPresetIds = {};
|
||
const zonePresetSelectionOrder = {};
|
||
|
||
function ensureZonePresetSelection(zoneId) {
|
||
const z = String(zoneId);
|
||
if (!zoneSelectedPresetIds[z]) zoneSelectedPresetIds[z] = new Set();
|
||
if (!zonePresetSelectionOrder[z]) zonePresetSelectionOrder[z] = [];
|
||
}
|
||
|
||
function pruneZonePresetSelection(zoneId, validIdSet) {
|
||
const z = String(zoneId);
|
||
ensureZonePresetSelection(z);
|
||
const set = zoneSelectedPresetIds[z];
|
||
for (const id of [...set]) {
|
||
if (!validIdSet.has(String(id))) set.delete(id);
|
||
}
|
||
zonePresetSelectionOrder[z] = (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
|
||
}
|
||
|
||
function getOrderedZonePresetSelection(zoneId) {
|
||
const z = String(zoneId);
|
||
ensureZonePresetSelection(z);
|
||
const set = zoneSelectedPresetIds[z];
|
||
return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
|
||
}
|
||
|
||
/** Preset id that should show the tile outline (last click in selection order). */
|
||
function getLastZonePresetSelectionId(zoneId) {
|
||
const order = getOrderedZonePresetSelection(zoneId);
|
||
return order.length ? String(order[order.length - 1]) : null;
|
||
}
|
||
|
||
async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPresets) {
|
||
const pid = String(presetId);
|
||
const body = (allPresets && allPresets[pid]) || preset;
|
||
if (!body) return;
|
||
const names =
|
||
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
|
||
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
|
||
: [];
|
||
await sendPresetViaEspNow(pid, body, names, false, false, '2');
|
||
}
|
||
|
||
// Store selected preset per zone
|
||
const selectedPresets = {};
|
||
// Store selected preset payload per zone for beat-trigger reliability.
|
||
const selectedPresetPayloads = {};
|
||
// Run vs Edit for zone preset strip (in-memory only — each full page load starts in run mode)
|
||
let presetUiMode = 'run';
|
||
|
||
const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run');
|
||
|
||
const setPresetUiMode = (mode) => {
|
||
presetUiMode = mode === 'edit' ? 'edit' : 'run';
|
||
};
|
||
|
||
const updateUiModeToggleButtons = () => {
|
||
const mode = getPresetUiMode();
|
||
// Label is the mode you switch *to* (opposite of current)
|
||
const label = mode === 'edit' ? 'Run mode' : 'Edit mode';
|
||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||
btn.textContent = label;
|
||
btn.setAttribute('aria-pressed', mode === 'edit' ? 'true' : 'false');
|
||
btn.classList.toggle('ui-mode-toggle--edit', mode === 'edit');
|
||
});
|
||
document.body.classList.toggle('preset-ui-edit', mode === 'edit');
|
||
document.body.classList.toggle('preset-ui-run', mode === 'run');
|
||
};
|
||
// Track if we're currently dragging a preset
|
||
let isDraggingPreset = false;
|
||
|
||
// Function to convert 2D grid to flat array (for backward compatibility)
|
||
const gridToArray = (presetsGrid) => {
|
||
if (!presetsGrid || !Array.isArray(presetsGrid)) {
|
||
return [];
|
||
}
|
||
// If it's already a flat array (old format), return it
|
||
if (presetsGrid.length > 0 && typeof presetsGrid[0] === 'string') {
|
||
return presetsGrid;
|
||
}
|
||
// If it's a 2D grid, flatten it
|
||
if (Array.isArray(presetsGrid[0])) {
|
||
return presetsGrid.flat();
|
||
}
|
||
// If it's an array of objects with positions, convert to grid then flatten
|
||
if (presetsGrid.length > 0 && typeof presetsGrid[0] === 'object' && presetsGrid[0].id) {
|
||
// Find max row and col
|
||
let maxRow = 0, maxCol = 0;
|
||
presetsGrid.forEach(p => {
|
||
if (p.row > maxRow) maxRow = p.row;
|
||
if (p.col > maxCol) maxCol = p.col;
|
||
});
|
||
// Create grid
|
||
const grid = Array(maxRow + 1).fill(null).map(() => Array(maxCol + 1).fill(null));
|
||
presetsGrid.forEach(p => {
|
||
grid[p.row][p.col] = p.id;
|
||
});
|
||
return grid.flat().filter(id => id !== null);
|
||
}
|
||
return [];
|
||
};
|
||
|
||
// Function to convert flat array to 2D grid
|
||
const arrayToGrid = (presetIds, columns = 3) => {
|
||
if (!presetIds || !Array.isArray(presetIds)) {
|
||
return [];
|
||
}
|
||
const grid = [];
|
||
for (let i = 0; i < presetIds.length; i += columns) {
|
||
grid.push(presetIds.slice(i, i + columns));
|
||
}
|
||
return grid;
|
||
};
|
||
|
||
// Function to save preset grid for a zone
|
||
const savePresetGrid = async (zoneId, presetGrid) => {
|
||
try {
|
||
// Get current zone data
|
||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!tabResponse.ok) {
|
||
throw new Error('Failed to load zone');
|
||
}
|
||
const tabData = await tabResponse.json();
|
||
if (
|
||
typeof window.zoneAllowsPresets === 'function' &&
|
||
!window.zoneAllowsPresets(tabData, zoneId)
|
||
) {
|
||
throw new Error('This zone is for sequences only.');
|
||
}
|
||
|
||
// Store as 2D grid
|
||
tabData.presets = presetGrid;
|
||
// Also store as flat array for backward compatibility
|
||
tabData.presets_flat = presetGrid.flat();
|
||
|
||
// Save updated zone
|
||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(tabData),
|
||
});
|
||
|
||
if (!updateResponse.ok) {
|
||
throw new Error('Failed to save preset grid');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to save preset grid:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// Function to get drop target: the cell that contains the cursor (or closest if in a gap)
|
||
const getDropTarget = (container, x, y) => {
|
||
const draggableElements = [...container.querySelectorAll('.draggable-preset:not(.dragging)')];
|
||
// First try: find the element whose rect contains the cursor
|
||
const containing = draggableElements.find((child) => {
|
||
const box = child.getBoundingClientRect();
|
||
return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
|
||
});
|
||
if (containing) return containing;
|
||
// Fallback: closest element by distance to center
|
||
const closest = draggableElements.reduce((best, child) => {
|
||
const box = child.getBoundingClientRect();
|
||
const cx = box.left + box.width / 2;
|
||
const cy = box.top + box.height / 2;
|
||
const d = Math.hypot(x - cx, y - cy);
|
||
return d < best.distance ? { distance: d, element: child } : best;
|
||
}, { distance: Infinity });
|
||
return closest.element;
|
||
};
|
||
|
||
/**
|
||
* Move dragged tile onto the drop target's slot.
|
||
* When moving down the list (fromIdx < toIdx), insertBefore(dragging, dropTarget) lands one index
|
||
* too early; use the next element sibling so the item occupies the target slot.
|
||
*/
|
||
const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
|
||
const siblings = [...presetsList.querySelectorAll('.draggable-preset')];
|
||
const fromIdx = siblings.indexOf(dragging);
|
||
const toIdx = siblings.indexOf(dropTarget);
|
||
if (fromIdx === -1 || toIdx === -1) return;
|
||
|
||
if (fromIdx < toIdx) {
|
||
const next = dropTarget.nextElementSibling;
|
||
presetsList.insertBefore(dragging, next);
|
||
} else {
|
||
presetsList.insertBefore(dragging, dropTarget);
|
||
}
|
||
};
|
||
|
||
// Function to render presets for a specific zone in 2D grid
|
||
/**
|
||
* @param {string} zoneId
|
||
* @param {{ stopSequencePlayback?: boolean }} [options] - pass `{ stopSequencePlayback: true }` only when
|
||
* the UI action should stop server zone sequence playback (default: do not POST /sequences/stop).
|
||
*/
|
||
const renderTabPresets = async (zoneId, options = {}) => {
|
||
const presetsList = document.getElementById('presets-list-zone');
|
||
if (!presetsList) return;
|
||
|
||
const stopSeq = options.stopSequencePlayback === true;
|
||
if (stopSeq && typeof window.stopZoneSequencePlayback === 'function') {
|
||
// Pass false: an earlier render's stop() can finish after this pass rebuilds the DOM and
|
||
// would otherwise clear .active from new sequence tiles (breaks edit/run selection).
|
||
await window.stopZoneSequencePlayback(false);
|
||
}
|
||
|
||
try {
|
||
const [tabResponse, groupsStripRes, presetsResponse] = await Promise.all([
|
||
fetch(`/zones/${zoneId}`, {
|
||
headers: { Accept: 'application/json' },
|
||
}),
|
||
fetch('/groups', { headers: { Accept: 'application/json' } }),
|
||
fetch('/presets', {
|
||
headers: { Accept: 'application/json' },
|
||
}),
|
||
]);
|
||
if (!tabResponse.ok) {
|
||
throw new Error('Failed to load zone');
|
||
}
|
||
const tabData = await tabResponse.json();
|
||
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
|
||
const ck =
|
||
typeof window.effectiveZoneContentKind === 'function'
|
||
? window.effectiveZoneContentKind(tabData)
|
||
: typeof window.normalizeZoneContentKind === 'function'
|
||
? window.normalizeZoneContentKind(tabData)
|
||
: 'presets';
|
||
|
||
// Get presets - support both 2D grid and flat array (for backward compatibility)
|
||
let presetGrid = tabData.presets;
|
||
if (!presetGrid || !Array.isArray(presetGrid)) {
|
||
// Try to get from flat array or convert old format
|
||
const flatArray = tabData.presets_flat || tabData.presets || [];
|
||
presetGrid = arrayToGrid(flatArray, 3); // Default to 3 columns
|
||
} else if (presetGrid.length > 0 && typeof presetGrid[0] === 'string') {
|
||
// It's a flat array, convert to grid
|
||
presetGrid = arrayToGrid(presetGrid, 3);
|
||
}
|
||
if (ck === 'sequences') {
|
||
presetGrid = [];
|
||
}
|
||
|
||
if (!presetsResponse.ok) {
|
||
throw new Error('Failed to load presets');
|
||
}
|
||
const allPresetsRaw = await presetsResponse.json();
|
||
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
|
||
const paletteColors = await getCurrentProfilePaletteColors();
|
||
|
||
presetsList.innerHTML = '';
|
||
presetsList.dataset.reorderTabId = zoneId;
|
||
|
||
// Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise)
|
||
if (!presetsList.dataset.dragWired) {
|
||
presetsList.dataset.dragWired = '1';
|
||
// dragenter + dropEffect tell the browser this zone accepts a move (avoids ⊘ cursor)
|
||
presetsList.addEventListener('dragenter', (e) => {
|
||
if (getPresetUiMode() !== 'edit') return;
|
||
e.preventDefault();
|
||
});
|
||
presetsList.addEventListener('dragover', (e) => {
|
||
if (getPresetUiMode() !== 'edit') return;
|
||
e.preventDefault();
|
||
try {
|
||
e.dataTransfer.dropEffect = 'move';
|
||
} catch (_) {}
|
||
const dragging = presetsList.querySelector('.dragging');
|
||
if (!dragging) return;
|
||
|
||
const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
|
||
// Keep dragover side-effect free; commit placement only on drop.
|
||
if (!dropTarget || dropTarget === dragging) {
|
||
delete presetsList.dataset.dropTargetId;
|
||
return;
|
||
}
|
||
presetsList.dataset.dropTargetId = dropTarget.dataset.presetId || '';
|
||
});
|
||
|
||
presetsList.addEventListener('drop', async (e) => {
|
||
if (getPresetUiMode() !== 'edit') return;
|
||
e.preventDefault();
|
||
const dragging = presetsList.querySelector('.dragging');
|
||
if (!dragging) return;
|
||
const targetId = presetsList.dataset.dropTargetId;
|
||
if (targetId) {
|
||
const dropTarget = presetsList.querySelector(`.draggable-preset[data-preset-id="${targetId}"]:not(.dragging)`);
|
||
if (dropTarget) {
|
||
insertDraggingOntoTarget(presetsList, dragging, dropTarget);
|
||
}
|
||
}
|
||
delete presetsList.dataset.dropTargetId;
|
||
|
||
const saveId = presetsList.dataset.reorderTabId;
|
||
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')];
|
||
const presetIds = presetElements.map((el) => el.dataset.presetId);
|
||
|
||
const newGrid = arrayToGrid(presetIds, 3);
|
||
|
||
try {
|
||
if (!saveId) {
|
||
console.warn('No zone id for preset reorder save');
|
||
return;
|
||
}
|
||
await savePresetGrid(saveId, newGrid);
|
||
await renderTabPresets(saveId);
|
||
} catch (error) {
|
||
console.error('Failed to save preset grid:', error);
|
||
alert('Failed to save preset order. Please try again.');
|
||
const fallbackId = presetsList.dataset.reorderTabId;
|
||
if (fallbackId) await renderTabPresets(fallbackId);
|
||
}
|
||
});
|
||
}
|
||
|
||
const flatPresets = presetGrid.flat().filter(id => id);
|
||
const validIdSet = new Set(flatPresets.map((id) => String(id)));
|
||
pruneZonePresetSelection(zoneId, validIdSet);
|
||
|
||
const hasSeq =
|
||
Array.isArray(tabData.sequence_ids) &&
|
||
tabData.sequence_ids.some((x) => x != null && String(x).trim());
|
||
|
||
if (flatPresets.length === 0) {
|
||
const empty = document.createElement('p');
|
||
empty.className = 'muted-text';
|
||
empty.style.gridColumn = '1 / -1'; // Span all columns
|
||
if (ck === 'sequences') {
|
||
if (!hasSeq) {
|
||
empty.textContent =
|
||
"No sequences on this zone yet. Open the zone's Edit menu to add one.";
|
||
presetsList.appendChild(empty);
|
||
}
|
||
} else {
|
||
empty.textContent =
|
||
'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
|
||
presetsList.appendChild(empty);
|
||
}
|
||
} else {
|
||
flatPresets.forEach((presetId) => {
|
||
const preset = allPresets[presetId];
|
||
if (preset) {
|
||
ensureZonePresetSelection(zoneId);
|
||
const lastSelectedId = getLastZonePresetSelectionId(zoneId);
|
||
const isSelected =
|
||
lastSelectedId !== null && lastSelectedId === String(presetId);
|
||
const displayPreset = {
|
||
...preset,
|
||
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
|
||
background: resolvePresetBackgroundHex(preset, paletteColors),
|
||
};
|
||
const wrapper = createPresetButton(
|
||
presetId,
|
||
displayPreset,
|
||
zoneId,
|
||
isSelected,
|
||
tabData,
|
||
groupsMapStrip,
|
||
allPresets,
|
||
);
|
||
presetsList.appendChild(wrapper);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (
|
||
typeof window.appendZoneSequenceTiles === 'function' &&
|
||
(typeof window.zoneAllowsSequences !== 'function' ||
|
||
window.zoneAllowsSequences(tabData, zoneId))
|
||
) {
|
||
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to render zone presets:', error);
|
||
presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>';
|
||
}
|
||
};
|
||
|
||
const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, groupsMap, allPresets) => {
|
||
const uiMode = getPresetUiMode();
|
||
|
||
const row = document.createElement('div');
|
||
const canDrag = uiMode === 'edit';
|
||
row.className = `preset-tile-row preset-tile-row--${uiMode}${canDrag ? ' draggable-preset' : ''}`;
|
||
row.draggable = canDrag;
|
||
row.dataset.presetId = presetId;
|
||
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'pattern-button preset-tile-main';
|
||
if (isSelected) {
|
||
button.classList.add('active');
|
||
}
|
||
|
||
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
|
||
const pat = (preset.pattern || '').toLowerCase();
|
||
const mode = presetWireN6(preset);
|
||
const isRainbow = pat === 'rainbow' || (pat === 'colour_cycle' && mode === 1);
|
||
const barColors = isRainbow
|
||
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
|
||
: colors;
|
||
if (barColors.length > 0) {
|
||
const n = barColors.length;
|
||
const stops = barColors.flatMap((c, i) => {
|
||
const start = (100 * i / n).toFixed(2);
|
||
const end = (100 * (i + 1) / n).toFixed(2);
|
||
return [`${c} ${start}%`, `${c} ${end}%`];
|
||
}).join(', ');
|
||
button.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.4)), linear-gradient(to right, ${stops})`;
|
||
}
|
||
|
||
const presetNameLabel = document.createElement('span');
|
||
presetNameLabel.textContent = preset.name || presetId;
|
||
presetNameLabel.style.fontWeight = 'bold';
|
||
presetNameLabel.className = 'pattern-button-label';
|
||
button.appendChild(presetNameLabel);
|
||
|
||
const groupsText = formatPresetTargetGroupsLine(tabData || {}, groupsMap || {});
|
||
if (groupsText) {
|
||
const groupsSpan = document.createElement('span');
|
||
groupsSpan.className = 'preset-tile-groups';
|
||
groupsSpan.textContent = groupsText;
|
||
button.appendChild(groupsSpan);
|
||
}
|
||
|
||
const bgSwatch = document.createElement('span');
|
||
const bgColor = coercePresetBackground(preset);
|
||
bgSwatch.title = `Background: ${bgColor}`;
|
||
bgSwatch.style.cssText = `
|
||
position: absolute;
|
||
left: 4px;
|
||
bottom: 4px;
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 2px;
|
||
background: ${bgColor};
|
||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
|
||
pointer-events: none;
|
||
z-index: 2;
|
||
`;
|
||
button.appendChild(bgSwatch);
|
||
|
||
const isManualPreset = preset && !coercePresetAuto(preset);
|
||
if (isManualPreset) {
|
||
const manualBadge = document.createElement('span');
|
||
manualBadge.textContent = '1';
|
||
manualBadge.title = 'Manual preset';
|
||
manualBadge.style.cssText = `
|
||
position: absolute;
|
||
right: 4px;
|
||
bottom: 4px;
|
||
min-width: 16px;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
background: rgba(0, 0, 0, 0.72);
|
||
color: #fff;
|
||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
line-height: 14px;
|
||
text-align: center;
|
||
pointer-events: none;
|
||
z-index: 2;
|
||
`;
|
||
button.appendChild(manualBadge);
|
||
}
|
||
|
||
button.addEventListener('click', () => {
|
||
if (isDraggingPreset) return;
|
||
console.info('Preset button pressed', { zoneId, presetId, name: (preset && preset.name) || presetId });
|
||
const presetsListEl = document.getElementById('presets-list-zone');
|
||
ensureZonePresetSelection(zoneId);
|
||
const z = String(zoneId);
|
||
const set = zoneSelectedPresetIds[z];
|
||
const idStr = String(presetId);
|
||
const wasSelected = set.has(idStr);
|
||
set.clear();
|
||
zonePresetSelectionOrder[z] = [];
|
||
if (!wasSelected) {
|
||
set.add(idStr);
|
||
zonePresetSelectionOrder[z] = [idStr];
|
||
}
|
||
const outlinePresetId = getLastZonePresetSelectionId(zoneId);
|
||
if (presetsListEl) {
|
||
presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
|
||
const pid = rw.dataset.presetId;
|
||
const btnEl = rw.querySelector('.preset-tile-main');
|
||
if (!btnEl || !pid) return;
|
||
if (outlinePresetId && String(pid) === outlinePresetId) btnEl.classList.add('active');
|
||
else btnEl.classList.remove('active');
|
||
});
|
||
}
|
||
if (!wasSelected) {
|
||
selectedPresets[zoneId] = idStr;
|
||
selectedPresetPayloads[zoneId] = (allPresets && allPresets[idStr]) || preset;
|
||
void sendZonePresetSelection(zoneId, tabData, idStr, preset, allPresets);
|
||
} else {
|
||
delete selectedPresets[zoneId];
|
||
delete selectedPresetPayloads[zoneId];
|
||
}
|
||
});
|
||
|
||
if (canDrag) {
|
||
row.addEventListener('dragstart', (e) => {
|
||
isDraggingPreset = true;
|
||
row.classList.add('dragging');
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', presetId);
|
||
});
|
||
|
||
row.addEventListener('dragend', () => {
|
||
row.classList.remove('dragging');
|
||
const presetsListEl = document.getElementById('presets-list-zone');
|
||
if (presetsListEl) {
|
||
delete presetsListEl.dataset.dropTargetId;
|
||
}
|
||
document.querySelectorAll('.draggable-preset').forEach((el) => el.classList.remove('drag-over'));
|
||
setTimeout(() => {
|
||
isDraggingPreset = false;
|
||
}, 100);
|
||
});
|
||
}
|
||
|
||
const top = document.createElement('div');
|
||
top.className = 'preset-tile-row-top';
|
||
top.appendChild(button);
|
||
|
||
if (uiMode === 'edit') {
|
||
const actions = document.createElement('div');
|
||
actions.className = 'preset-tile-actions';
|
||
|
||
const editBtn = document.createElement('button');
|
||
editBtn.type = 'button';
|
||
editBtn.className = 'btn btn-secondary btn-small';
|
||
editBtn.textContent = 'Edit';
|
||
editBtn.title = 'Edit preset';
|
||
editBtn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (isDraggingPreset) return;
|
||
editPresetFromTab(presetId, zoneId, preset);
|
||
});
|
||
|
||
actions.appendChild(editBtn);
|
||
top.appendChild(actions);
|
||
}
|
||
|
||
row.appendChild(top);
|
||
|
||
return row;
|
||
};
|
||
|
||
const editPresetFromTab = async (presetId, zoneId, existingPreset) => {
|
||
try {
|
||
let preset = existingPreset;
|
||
if (!preset) {
|
||
// Fallback: load the preset data from the server if we weren't given it
|
||
const response = await fetch(`/presets/${presetId}`, {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load preset');
|
||
}
|
||
preset = await response.json();
|
||
}
|
||
|
||
// Dispatch a custom event to trigger the edit in the DOMContentLoaded scope
|
||
const editEvent = new CustomEvent('editPreset', {
|
||
detail: { presetId, preset, zoneId }
|
||
});
|
||
document.dispatchEvent(editEvent);
|
||
} catch (error) {
|
||
console.error('Failed to load preset for editing:', error);
|
||
alert('Failed to load preset for editing.');
|
||
}
|
||
};
|
||
|
||
// Remove a preset from a specific zone (does not delete the preset itself)
|
||
// Expected call style: removePresetFromTab(zoneId, presetId)
|
||
const removePresetFromTab = async (zoneId, presetId) => {
|
||
if (!zoneId) {
|
||
// Try to get zone ID from the left-panel
|
||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||
zoneId = leftPanel ? leftPanel.dataset.zoneId : null;
|
||
|
||
if (!zoneId) {
|
||
// Fallback: try to get from URL
|
||
const pathParts = window.location.pathname.split('/');
|
||
const tabIndex = pathParts.indexOf('zones');
|
||
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
|
||
zoneId = pathParts[tabIndex + 1];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!zoneId) {
|
||
alert('Could not determine current zone.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Get current zone data
|
||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (!tabResponse.ok) {
|
||
throw new Error('Failed to load zone');
|
||
}
|
||
const tabData = await tabResponse.json();
|
||
if (
|
||
typeof window.zoneAllowsPresets === 'function' &&
|
||
!window.zoneAllowsPresets(tabData, zoneId)
|
||
) {
|
||
alert('This zone is for sequences only.');
|
||
return;
|
||
}
|
||
|
||
// Normalize to flat array
|
||
let flat = [];
|
||
if (Array.isArray(tabData.presets_flat)) {
|
||
flat = tabData.presets_flat.slice();
|
||
} else if (Array.isArray(tabData.presets)) {
|
||
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||
flat = tabData.presets.slice();
|
||
} else if (Array.isArray(tabData.presets[0])) {
|
||
flat = tabData.presets.flat();
|
||
}
|
||
}
|
||
|
||
const beforeLen = flat.length;
|
||
flat = flat.filter(id => String(id) !== String(presetId));
|
||
if (flat.length === beforeLen) {
|
||
alert('Preset is not in this zone.');
|
||
return;
|
||
}
|
||
|
||
const newGrid = arrayToGrid(flat, 3);
|
||
tabData.presets = newGrid;
|
||
tabData.presets_flat = flat;
|
||
|
||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(tabData),
|
||
});
|
||
if (!updateResponse.ok) {
|
||
throw new Error('Failed to update zone presets');
|
||
}
|
||
|
||
await renderTabPresets(zoneId);
|
||
} catch (error) {
|
||
console.error('Failed to remove preset from zone:', error);
|
||
alert('Failed to remove preset from zone.');
|
||
}
|
||
};
|
||
try {
|
||
window.removePresetFromTab = removePresetFromTab;
|
||
} catch (e) {}
|
||
try {
|
||
window.renderTabPresets = renderTabPresets;
|
||
window.getPresetUiMode = getPresetUiMode;
|
||
} catch (e) {}
|
||
|
||
// Listen for HTMX swaps to render presets
|
||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||
if (event.target && event.target.id === 'zone-content') {
|
||
// Get zone ID from the left-panel
|
||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||
if (leftPanel) {
|
||
const zoneId = leftPanel.dataset.zoneId;
|
||
if (zoneId) {
|
||
renderTabPresets(zoneId);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
updateUiModeToggleButtons();
|
||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
|
||
setPresetUiMode(next);
|
||
updateUiModeToggleButtons();
|
||
if (next === 'run') {
|
||
['devices-modal', 'edit-device-modal'].forEach((id) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.classList.remove('active');
|
||
});
|
||
}
|
||
const mainMenu = document.getElementById('main-menu-dropdown');
|
||
if (mainMenu) mainMenu.classList.remove('open');
|
||
// Preset strip re-renders from `zones.js` after `loadZones()` (no driver/playback side effects).
|
||
});
|
||
});
|
||
});
|