feat(ui): add run/edit workflow and improve preset color editing

Made-with: Cursor
This commit is contained in:
2026-03-21 23:15:31 +13:00
parent 91de705647
commit 9323719a85
5 changed files with 521 additions and 247 deletions

View File

@@ -4,7 +4,6 @@ document.addEventListener('DOMContentLoaded', () => {
const closeButton = document.getElementById('color-palette-close-btn');
const paletteContainer = document.getElementById('palette-container');
const paletteNewColor = document.getElementById('palette-new-color');
const paletteAddButton = document.getElementById('palette-add-color-btn');
const profileNameDisplay = document.getElementById('palette-current-profile-name');
if (!paletteButton || !paletteModal || !paletteContainer) {
@@ -177,8 +176,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (closeButton) {
closeButton.addEventListener('click', closeModal);
}
if (paletteAddButton && paletteNewColor) {
paletteAddButton.addEventListener('click', async () => {
if (paletteNewColor) {
const addSelectedColor = async () => {
const color = paletteNewColor.value;
if (!color) {
return;
@@ -188,7 +187,9 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
await savePalette([...currentPalette, color]);
});
};
// Add when the picker closes (user confirms selection).
paletteNewColor.addEventListener('change', addSelectedColor);
}
paletteModal.addEventListener('click', (event) => {
if (event.target === paletteModal) {

View File

@@ -2,6 +2,72 @@
let espnowSocket = null;
let espnowSocketReady = false;
let espnowPendingMessages = [];
let currentProfileIdCache = null;
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);
}),
);
};
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)) {
@@ -105,7 +171,6 @@ document.addEventListener('DOMContentLoaded', () => {
const presetPatternInput = document.getElementById('preset-pattern-input');
const presetColorsContainer = document.getElementById('preset-colors-container');
const presetNewColorInput = document.getElementById('preset-new-color');
const presetAddColorButton = document.getElementById('preset-add-color-btn');
const presetBrightnessInput = document.getElementById('preset-brightness-input');
const presetDelayInput = document.getElementById('preset-delay-input');
const presetDefaultButton = document.getElementById('preset-default-btn');
@@ -123,6 +188,7 @@ document.addEventListener('DOMContentLoaded', () => {
let cachedPresets = {};
let cachedPatterns = {};
let currentPresetColors = []; // Track colors for the current preset
let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors)
// Function to get max colors for current pattern
const getMaxColors = () => {
@@ -158,7 +224,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Hide/show the actions (color picker and buttons)
const colorActions = presetColorsContainer.nextElementSibling;
if (colorActions && (colorActions.querySelector('#preset-add-color-btn') || colorActions.querySelector('#preset-new-color'))) {
if (colorActions && colorActions.querySelector('#preset-new-color')) {
colorActions.style.display = shouldShow ? '' : 'none';
}
}
@@ -172,11 +238,24 @@ document.addEventListener('DOMContentLoaded', () => {
return parseInt(input.value, 10) || 0;
};
const renderPresetColors = (colors) => {
const renderPresetColors = (colors, paletteRefs) => {
if (!presetColorsContainer) return;
presetColorsContainer.innerHTML = '';
currentPresetColors = colors || [];
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();
@@ -185,7 +264,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (currentPresetColors.length === 0) {
const empty = document.createElement('p');
empty.className = 'muted-text';
empty.textContent = `No colors added. Click "Add Color" to add colors.${maxColorsText}`;
empty.textContent = `No colors added. Use the color picker to add colors.${maxColorsText}`;
presetColorsContainer.appendChild(empty);
return;
}
@@ -208,6 +287,8 @@ document.addEventListener('DOMContentLoaded', () => {
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');
@@ -222,6 +303,31 @@ document.addEventListener('DOMContentLoaded', () => {
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');
@@ -239,7 +345,9 @@ document.addEventListener('DOMContentLoaded', () => {
`;
colorPicker.addEventListener('change', (e) => {
currentPresetColors[index] = e.target.value;
renderPresetColors(currentPresetColors);
// 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) => {
@@ -271,7 +379,8 @@ document.addEventListener('DOMContentLoaded', () => {
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
currentPresetColors.splice(index, 1);
renderPresetColors(currentPresetColors);
currentPresetPaletteRefs.splice(index, 1);
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
});
// Prevent remove button from interfering with drag
removeBtn.addEventListener('mousedown', (e) => {
@@ -326,12 +435,18 @@ document.addEventListener('DOMContentLoaded', () => {
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);
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
});
presetColorsContainer.appendChild(swatchContainer);
@@ -361,7 +476,8 @@ document.addEventListener('DOMContentLoaded', () => {
const patternName = preset.pattern || '';
presetPatternInput.value = patternName;
const colors = Array.isArray(preset.colors) ? preset.colors : [];
renderPresetColors(colors);
const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs : [];
renderPresetColors(colors, paletteRefs);
presetBrightnessInput.value = preset.brightness || 0;
presetDelayInput.value = preset.delay || 0;
@@ -424,6 +540,7 @@ document.addEventListener('DOMContentLoaded', () => {
currentEditId = null;
currentEditTabId = null;
currentPresetColors = [];
currentPresetPaletteRefs = [];
setFormValues({
name: '',
pattern: '',
@@ -505,6 +622,7 @@ document.addEventListener('DOMContentLoaded', () => {
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,
@@ -633,9 +751,18 @@ document.addEventListener('DOMContentLoaded', () => {
const editButton = document.createElement('button');
editButton.className = 'btn btn-secondary btn-small';
editButton.textContent = 'Edit';
editButton.addEventListener('click', () => {
editButton.addEventListener('click', async () => {
currentEditId = presetId;
setFormValues(preset || {});
const paletteColors = await getCurrentProfilePaletteColors();
const presetForEditor = {
...(preset || {}),
colors: resolveColorsWithPaletteRefs(
(preset && preset.colors) || [],
(preset && preset.palette_refs) || [],
paletteColors,
),
};
setFormValues(presetForEditor);
openEditor();
});
@@ -698,7 +825,8 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to load presets');
}
const presets = await response.json();
renderPresets(presets);
const filtered = await filterPresetsForCurrentProfile(presets);
renderPresets(filtered);
} catch (error) {
console.error('Load presets failed:', error);
presetsList.innerHTML = '';
@@ -757,7 +885,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (!response.ok) {
throw new Error('Failed to load presets');
}
const allPresets = await response.json();
const allPresetsRaw = await response.json();
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
// Load only the current tab's presets so we can avoid duplicates within this tab.
let currentTabPresets = [];
@@ -946,12 +1075,12 @@ document.addEventListener('DOMContentLoaded', () => {
// Update color section visibility
updateColorSectionVisibility();
// Re-render colors to show updated max colors limit
renderPresetColors(currentPresetColors);
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
});
}
// Add Color button handler
if (presetAddColorButton && presetNewColorInput) {
presetAddColorButton.addEventListener('click', () => {
// Color picker auto-add handler
if (presetNewColorInput) {
const tryAddSelectedColor = () => {
const color = presetNewColorInput.value;
if (!color) return;
@@ -967,60 +1096,86 @@ document.addEventListener('DOMContentLoaded', () => {
}
currentPresetColors.push(color);
renderPresetColors(currentPresetColors);
});
currentPresetPaletteRefs.push(null);
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
};
// Add when the picker closes (user confirms selection).
presetNewColorInput.addEventListener('change', tryAddSelectedColor);
}
// Add from Palette button handler
if (presetAddFromPaletteButton) {
presetAddFromPaletteButton.addEventListener('click', () => {
const openButton = document.getElementById('color-palette-btn');
if (openButton) {
openButton.click();
}
const modal = document.getElementById('color-palette-modal');
const modalList = document.getElementById('palette-container');
if (modal) {
modal.classList.add('active');
}
if (!modalList) {
return;
}
presetAddFromPaletteButton.addEventListener('click', async () => {
try {
const paletteColors = await getCurrentProfilePaletteColors();
if (!Array.isArray(paletteColors) || paletteColors.length === 0) {
alert('No profile palette colors available.');
return;
}
const handlePick = (event) => {
const row = event.target.closest('[data-color]');
if (!row) {
return;
}
const picked = row.dataset.color;
if (!picked) {
return;
}
if (currentPresetColors.includes(picked)) {
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' : ''}.`);
if (modal) {
modal.classList.remove('active');
const modal = document.createElement('div');
modal.className = 'modal active';
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);
modal.addEventListener('click', (e) => {
if (e.target === modal) 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;
if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) {
alert('That palette color is already linked.');
return;
}
const maxColors = getMaxColors();
if (currentPresetColors.length >= maxColors) {
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
return;
}
modalList.removeEventListener('click', handlePick);
return;
}
currentPresetColors.push(picked);
renderPresetColors(currentPresetColors);
if (modal) {
modal.classList.remove('active');
}
modalList.removeEventListener('click', handlePick);
};
modalList.addEventListener('click', handlePick);
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 colors.');
}
});
}
const presetSendButton = document.getElementById('preset-send-btn');
@@ -1136,7 +1291,15 @@ document.addEventListener('DOMContentLoaded', () => {
currentEditId = presetId;
currentEditTabId = tabId || null;
await loadPatterns();
setFormValues(preset);
const paletteColors = await getCurrentProfilePaletteColors();
setFormValues({
...(preset || {}),
colors: resolveColorsWithPaletteRefs(
(preset && preset.colors) || [],
(preset && preset.palette_refs) || [],
paletteColors,
),
});
openEditor();
});
@@ -1175,11 +1338,13 @@ document.addEventListener('DOMContentLoaded', () => {
// Build an ESPNow preset message for a single preset and optionally include a select
// for the given device names, then send it via WebSocket.
// saveToDevice defaults to true.
const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
try {
const colors = Array.isArray(preset.colors) && preset.colors.length
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 message = {
v: '1',
@@ -1261,97 +1426,29 @@ try {
// Store selected preset per tab
const selectedPresets = {};
// Run vs Edit for tab 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;
// Context menu for tab presets
let presetContextMenu = null;
let presetContextTarget = null;
const ensurePresetContextMenu = () => {
if (presetContextMenu) {
return presetContextMenu;
}
const menu = document.createElement('div');
menu.id = 'preset-context-menu';
menu.style.cssText = `
position: fixed;
z-index: 2000;
background: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
padding: 0.25rem 0;
min-width: 160px;
display: none;
`;
const addItem = (label, action) => {
const item = document.createElement('button');
item.type = 'button';
item.textContent = label;
item.dataset.action = action;
item.style.cssText = `
display: block;
width: 100%;
padding: 0.4rem 0.75rem;
background: transparent;
color: #eee;
border: none;
text-align: left;
cursor: pointer;
font-size: 0.9rem;
`;
item.addEventListener('mouseover', () => {
item.style.backgroundColor = '#3a3a3a';
});
item.addEventListener('mouseout', () => {
item.style.backgroundColor = 'transparent';
});
menu.appendChild(item);
};
addItem('Edit preset…', 'edit');
menu.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn || !presetContextTarget) {
return;
}
const { presetId } = presetContextTarget;
const action = btn.dataset.action;
hidePresetContextMenu();
if (action === 'edit') {
await editPresetFromTab(presetId);
}
});
document.body.appendChild(menu);
presetContextMenu = menu;
// Hide on outside click
document.addEventListener('click', (e) => {
if (!presetContextMenu) return;
if (e.target.closest('#preset-context-menu')) return;
hidePresetContextMenu();
});
return menu;
};
const showPresetContextMenu = (x, y, tabId, presetId, preset) => {
const menu = ensurePresetContextMenu();
presetContextTarget = { tabId, presetId, preset };
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.display = 'block';
};
const hidePresetContextMenu = () => {
if (presetContextMenu) {
presetContextMenu.style.display = 'none';
}
presetContextTarget = null;
};
// Function to convert 2D grid to flat array (for backward compatibility)
const gridToArray = (presetsGrid) => {
@@ -1449,6 +1546,25 @@ const getDropTarget = (container, x, y) => {
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 tab in 2D grid
const renderTabPresets = async (tabId) => {
const presetsList = document.getElementById('presets-list-tab');
@@ -1482,47 +1598,74 @@ const renderTabPresets = async (tabId) => {
if (!presetsResponse.ok) {
throw new Error('Failed to load presets');
}
const allPresets = await presetsResponse.json();
const allPresetsRaw = await presetsResponse.json();
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
const paletteColors = await getCurrentProfilePaletteColors();
presetsList.innerHTML = '';
// Add drag and drop handlers to the container
presetsList.addEventListener('dragover', (e) => {
e.preventDefault();
const dragging = presetsList.querySelector('.dragging');
if (!dragging) return;
const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
if (dropTarget && dropTarget !== dragging) {
// Insert before drop target so the dragged item takes that cell's position
presetsList.insertBefore(dragging, dropTarget);
}
});
presetsList.addEventListener('drop', async (e) => {
e.preventDefault();
const dragging = presetsList.querySelector('.dragging');
if (!dragging) return;
// Get new grid layout from DOM
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')];
const presetIds = presetElements.map(el => el.dataset.presetId);
// Convert to 2D grid (3 columns)
const newGrid = arrayToGrid(presetIds, 3);
// Save new grid
try {
await savePresetGrid(tabId, newGrid);
// Re-render to ensure consistency
await renderTabPresets(tabId);
} catch (error) {
console.error('Failed to save preset grid:', error);
alert('Failed to save preset order. Please try again.');
// Re-render to restore original order
await renderTabPresets(tabId);
}
});
presetsList.dataset.reorderTabId = tabId;
// 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 tab 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);
}
});
}
// Get the currently selected preset for this tab
const selectedPresetId = selectedPresets[tabId];
@@ -1543,7 +1686,11 @@ const renderTabPresets = async (tabId) => {
const preset = allPresets[presetId];
if (preset) {
const isSelected = presetId === selectedPresetId;
const wrapper = createPresetButton(presetId, preset, tabId, isSelected);
const displayPreset = {
...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
};
const wrapper = createPresetButton(presetId, displayPreset, tabId, isSelected);
presetsList.appendChild(wrapper);
}
});
@@ -1555,15 +1702,22 @@ const renderTabPresets = async (tabId) => {
};
const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
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.className = 'pattern-button draggable-preset';
button.draggable = true;
button.dataset.presetId = presetId;
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 colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
const barColors = isRainbow
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
@@ -1584,38 +1738,76 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
presetNameLabel.className = 'pattern-button-label';
button.appendChild(presetNameLabel);
button.addEventListener('click', (e) => {
button.addEventListener('click', () => {
if (isDraggingPreset) return;
const presetsList = document.getElementById('presets-list-tab');
if (presetsList) {
presetsList.querySelectorAll('.pattern-button').forEach(btn => btn.classList.remove('active'));
const presetsListEl = document.getElementById('presets-list-tab');
if (presetsListEl) {
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
}
button.classList.add('active');
selectedPresets[tabId] = presetId;
const section = button.closest('.presets-section');
const section = row.closest('.presets-section');
sendSelectForCurrentTabDevices(presetId, section);
});
button.addEventListener('contextmenu', async (e) => {
e.preventDefault();
if (isDraggingPreset) return;
await editPresetFromTab(presetId, tabId, preset);
});
if (canDrag) {
row.addEventListener('dragstart', (e) => {
isDraggingPreset = true;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', presetId);
});
button.addEventListener('dragstart', (e) => {
isDraggingPreset = true;
button.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-tab');
if (presetsListEl) {
delete presetsListEl.dataset.dropTargetId;
}
document.querySelectorAll('.draggable-preset').forEach((el) => el.classList.remove('drag-over'));
setTimeout(() => {
isDraggingPreset = false;
}, 100);
});
}
button.addEventListener('dragend', (e) => {
button.classList.remove('dragging');
document.querySelectorAll('.draggable-preset').forEach(el => el.classList.remove('drag-over'));
setTimeout(() => { isDraggingPreset = false; }, 100);
});
row.appendChild(button);
return 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, tabId, preset);
});
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-danger btn-small';
removeBtn.textContent = 'Remove';
removeBtn.title = 'Remove from this tab';
removeBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (isDraggingPreset) return;
if (!window.confirm('Remove this preset from this tab?')) return;
await removePresetFromTab(tabId, presetId);
});
actions.appendChild(editBtn);
actions.appendChild(removeBtn);
row.appendChild(actions);
}
return row;
};
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
@@ -1731,3 +1923,20 @@ document.body.addEventListener('htmx:afterSwap', (event) => {
}
}
});
document.addEventListener('DOMContentLoaded', () => {
updateUiModeToggleButtons();
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
setPresetUiMode(next);
updateUiModeToggleButtons();
const mainMenu = document.getElementById('main-menu-dropdown');
if (mainMenu) mainMenu.classList.remove('open');
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
if (leftPanel) {
renderTabPresets(leftPanel.dataset.tabId);
}
});
});
});

View File

@@ -77,6 +77,11 @@ header h1 {
background-color: #333;
}
/* Header/menu actions that should only appear in Edit mode */
body.preset-ui-run .edit-mode-only {
display: none !important;
}
.btn {
padding: 0.45rem 0.9rem;
border: none;
@@ -596,6 +601,52 @@ header h1 {
position: relative;
}
/* Preset tile: main button + optional edit/remove (Edit mode) */
.preset-tile-row {
display: flex;
flex-direction: row;
align-items: stretch;
min-width: 0;
min-height: 0;
}
.preset-tile-row--run .preset-tile-actions {
display: none;
}
.preset-tile-main {
flex: 1;
min-width: 0;
height: 5rem;
}
.preset-tile-actions {
display: flex;
flex-direction: column;
gap: 0.2rem;
justify-content: center;
flex-shrink: 0;
padding: 0.15rem 0 0.15rem 0.25rem;
}
.preset-tile-actions .btn {
flex: 1 1 0;
min-height: 0;
padding: 0.15rem 0.35rem;
font-size: 0.68rem;
line-height: 1.15;
white-space: nowrap;
}
.ui-mode-toggle--edit {
background-color: #4a3f8f;
border: 1px solid #7b6fd6;
}
.ui-mode-toggle--edit:hover {
background-color: #5a4f9f;
}
/* Preset select buttons inside the tab grid */
#presets-list-tab .pattern-button {
display: flex;

View File

@@ -15,24 +15,26 @@
</div>
</div>
<div class="header-actions">
<button class="btn btn-secondary" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</button>
<button class="btn btn-secondary" id="presets-btn">Presets</button>
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Color Palette</button>
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary edit-mode-only" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary" id="settings-btn">Settings</button>
<button class="btn btn-secondary" id="help-btn">Help</button>
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
</div>
<div class="header-menu-mobile">
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" data-target="tabs-btn">Tabs</button>
<button type="button" data-target="color-palette-btn">Color Palette</button>
<button type="button" data-target="presets-btn">Presets</button>
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Color Palette</button>
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" data-target="patterns-btn">Patterns</button>
<button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
<button type="button" class="edit-mode-only" data-target="profiles-btn">Profiles</button>
<button type="button" data-target="settings-btn">Settings</button>
<button type="button" data-target="help-btn">Help</button>
</div>
@@ -92,6 +94,12 @@
<input type="text" id="new-profile-name" placeholder="Profile name">
<button class="btn btn-primary" id="create-profile-btn">Create</button>
</div>
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
<input type="checkbox" id="new-profile-seed-dj">
DJ tab
</label>
</div>
<div id="profiles-list" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
@@ -126,9 +134,8 @@
<label>Colors</label>
<div id="preset-colors-container" class="preset-colors-container"></div>
<div class="profiles-actions">
<input type="color" id="preset-new-color" value="#ffffff">
<button class="btn btn-secondary btn-small" id="preset-add-color-btn">Add Color</button>
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button>
<input type="color" id="preset-new-color" value="#ffffff" title="Choose color (auto-adds)">
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">From Palette</button>
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
@@ -204,7 +211,6 @@
<div id="palette-container" class="profiles-list"></div>
<div class="profiles-actions">
<input type="color" id="palette-new-color" value="#ffffff">
<button class="btn btn-primary" id="palette-add-color-btn">Add Color</button>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
@@ -228,9 +234,10 @@
<h3>Presets in a tab</h3>
<ul>
<li><strong>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li>
<li><strong>Edit preset</strong>: right-click a preset tile and choose <strong>Edit preset…</strong>.</li>
<li><strong>Remove from tab</strong>: right-click a preset tile and choose <strong>Remove from this tab</strong> (the preset itself is not deleted, only its link from this tab).</li>
<li><strong>Reorder presets</strong>: drag preset tiles to change their order; the new layout is saved automatically.</li>
<li><strong>Run vs Edit mode</strong>: use the mode button in the menu (it shows the mode you will <em>switch to</em>). In <strong>Edit mode</strong>, each preset tile shows <strong>Edit</strong> and <strong>Remove</strong> on the right.</li>
<li><strong>Edit preset</strong>: switch to <strong>Edit mode</strong> (menu button) and use <strong>Edit</strong> on each tile.</li>
<li><strong>Remove from tab</strong>: in <strong>Edit mode</strong>, use <strong>Remove</strong> on the tile (the preset itself is not deleted, only its link from this tab).</li>
<li><strong>Reorder presets</strong>: in <strong>Edit mode</strong> only, drag tiles to change order; the layout saves automatically.</li>
</ul>
<h3>Presets, profiles & colors</h3>