Add profile color palette feature with quick-select modal

- Added per-profile color palette storage in profile JSON files
- Created Color Palette modal for managing profile colors
- Added quick-select modal window when clicking color pickers
- Implemented palette color selection to apply to active tab colors
- Added 'Use Color Picker' button in quick palette modal
- Fixed pattern selection to properly update UI
- Improved color picker interaction to prevent conflicts between quick palette and native picker
This commit is contained in:
2026-01-05 22:42:58 +13:00
parent c97ca308a7
commit 40cfe19759
6 changed files with 672 additions and 28 deletions

View File

@@ -9,6 +9,7 @@ class LightingController {
};
this.selectedColorIndex = 0;
this.updateTimeouts = {};
this.quickPaletteContext = null;
this.init();
}
@@ -27,16 +28,32 @@ class LightingController {
const response = await fetch('/api/state');
const data = await response.json();
this.state = data;
// Update current profile display
this.updateCurrentProfileDisplay();
// Update current profile display if profiles modal is open
if (document.getElementById('profiles-modal').classList.contains('active')) {
await this.loadProfiles();
await this.loadProfilePalette();
}
} catch (error) {
console.error('Failed to load state:', error);
}
}
updateCurrentProfileDisplay() {
const currentProfileName = document.getElementById('current-profile-name');
if (currentProfileName) {
const profile = this.state.current_profile || 'None';
currentProfileName.textContent = profile;
}
}
setupEventListeners() {
// Tab management
document.getElementById('add-tab-btn').addEventListener('click', () => this.showAddTabModal());
document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal());
document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab());
document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette());
document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles());
// Modal actions
@@ -44,6 +61,20 @@ class LightingController {
document.getElementById('add-tab-cancel').addEventListener('click', () => this.hideModal('add-tab-modal'));
document.getElementById('edit-tab-confirm').addEventListener('click', () => this.updateTab());
document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal'));
document.getElementById('profiles-close-btn').addEventListener('click', () => this.hideModal('profiles-modal'));
document.getElementById('color-palette-close-btn').addEventListener('click', () => this.hideModal('color-palette-modal'));
document.getElementById('quick-palette-close-btn').addEventListener('click', () => this.hideQuickPaletteModal());
document.getElementById('quick-palette-use-picker-btn').addEventListener('click', () => this.useColorPickerFromQuickPalette());
document.getElementById('create-profile-btn').addEventListener('click', () => this.createProfile());
document.getElementById('add-palette-color-btn').addEventListener('click', () => this.addPaletteColor());
document.getElementById('palette-add-color-btn').addEventListener('click', () => this.addPaletteColorFromModal());
// Enter key for new profile name
document.getElementById('new-profile-name').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.createProfile();
}
});
// Brightness and delay sliders
document.getElementById('brightness-slider').addEventListener('input', (e) => {
@@ -73,6 +104,9 @@ class LightingController {
document.getElementById('edit-tab-modal').addEventListener('click', (e) => {
if (e.target.id === 'edit-tab-modal') this.hideModal('edit-tab-modal');
});
document.getElementById('profiles-modal').addEventListener('click', (e) => {
if (e.target.id === 'profiles-modal') this.hideModal('profiles-modal');
});
}
renderTabs() {
@@ -113,7 +147,9 @@ class LightingController {
// Update IDs display
document.getElementById('current-ids').textContent = light.names.join(', ');
// Colors are handled by the color palette with individual color pickers
// Load and render colors in the palette
const colors = patternSettings.colors || ['#000000'];
this.renderColorPalette(tabName, colors);
// Update brightness slider
const brightness = settings.brightness || 127;
@@ -135,9 +171,6 @@ class LightingController {
// Render patterns
this.renderPatterns(tabName, pattern);
// Render color palette
this.renderColorPalette(tabName, colors);
}
renderPatterns(tabName, activePattern) {
@@ -178,19 +211,41 @@ class LightingController {
label.className = 'color-swatch-label';
label.textContent = `Color ${index + 1}`;
// Color picker input
// Color picker input with palette quick-select
const colorPickerWrapper = document.createElement('div');
colorPickerWrapper.style.cssText = 'position: relative; display: inline-block;';
const colorPicker = document.createElement('input');
colorPicker.type = 'color';
colorPicker.value = hexColor;
colorPicker.className = 'color-picker-input';
colorPicker.dataset.tabName = tabName;
colorPicker.dataset.colorIndex = index;
colorPicker.addEventListener('change', (e) => {
const newColor = e.target.value;
this.updateColorInPalette(tabName, index, newColor);
});
// Show quick palette on click, prevent native picker if palette has colors
const clickHandler = (e) => {
e.stopPropagation();
const palette = this.state.color_palette || [];
// Check if we're allowing native picker (set by "Use Color Picker" button)
if (palette.length > 0 && !colorPicker.dataset.allowNative) {
e.preventDefault();
this.showPaletteQuickSelect(colorPickerWrapper, tabName, index, colorPicker);
}
// If no palette colors or allowNative is set, let native picker open
};
colorPicker.addEventListener('click', clickHandler);
// Store handler reference for later removal if needed
colorPicker._clickHandler = clickHandler;
colorPickerWrapper.appendChild(colorPicker);
swatch.appendChild(preview);
swatch.appendChild(label);
swatch.appendChild(colorPicker);
swatch.appendChild(colorPickerWrapper);
swatch.addEventListener('click', (e) => {
// Don't trigger selection if clicking on the color picker
if (e.target !== colorPicker && !colorPicker.contains(e.target)) {
@@ -211,13 +266,15 @@ class LightingController {
if (!lightSettings.patterns[patternName]) lightSettings.patterns[patternName] = {};
const patternSettings = lightSettings.patterns[patternName];
// Fall back to global colors if pattern-specific colors don't exist
const globalColors = lightSettings.colors || ['#000000'];
return {
colors: patternSettings.colors || ['#000000'],
delay: patternSettings.delay || 100,
n1: patternSettings.n1 || 10,
n2: patternSettings.n2 || 10,
n3: patternSettings.n3 || 10,
n4: patternSettings.n4 || 10
colors: patternSettings.colors || globalColors,
delay: patternSettings.delay || lightSettings.delay || 100,
n1: patternSettings.n1 || lightSettings.n1 || 10,
n2: patternSettings.n2 || lightSettings.n2 || 10,
n3: patternSettings.n3 || lightSettings.n3 || 10,
n4: patternSettings.n4 || lightSettings.n4 || 10
};
}
@@ -241,8 +298,18 @@ class LightingController {
});
if (response.ok) {
// Update the pattern immediately in the state
if (this.state.lights[tabName]) {
this.state.lights[tabName].settings.pattern = patternName;
}
// Reload state from server to ensure consistency
await this.loadState();
// Reload tab content to update UI
await this.loadTabContent(tabName);
} else {
const errorText = await response.text();
console.error('Failed to set pattern:', errorText);
alert(`Failed to set pattern: ${errorText}`);
}
} catch (error) {
console.error('Failed to set pattern:', error);
@@ -555,8 +622,396 @@ class LightingController {
this.renderColorPalette(this.currentTab, patternSettings.colors);
}
showProfiles() {
alert('Profiles feature coming soon');
async showColorPalette() {
const modal = document.getElementById('color-palette-modal');
modal.classList.add('active');
// Update current profile display in palette modal
const profileNameDisplay = document.getElementById('palette-current-profile-name');
if (profileNameDisplay) {
profileNameDisplay.textContent = this.state.current_profile || 'None';
}
await this.loadProfilePalette();
}
async showProfiles() {
const modal = document.getElementById('profiles-modal');
modal.classList.add('active');
await this.loadProfiles();
await this.loadProfilePalette();
}
async loadProfiles() {
try {
const response = await fetch('/api/profiles');
const data = await response.json();
const profilesList = document.getElementById('profiles-list');
profilesList.innerHTML = '';
const currentProfile = data.current_profile || '';
this.state.current_profile = currentProfile;
this.state.color_palette = data.color_palette || [];
this.updateCurrentProfileDisplay();
if (data.profiles.length === 0) {
profilesList.innerHTML = '<p style="text-align: center; color: #888;">No profiles found</p>';
} else {
data.profiles.forEach(profileName => {
const profileItem = document.createElement('div');
profileItem.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 0.75rem; background-color: #3a3a3a; border-radius: 4px; margin-bottom: 0.5rem;';
const profileLabel = document.createElement('span');
profileLabel.textContent = profileName;
if (profileName === currentProfile) {
profileLabel.textContent = `${profileName}`;
profileLabel.style.fontWeight = 'bold';
profileLabel.style.color = '#FFD700';
}
const loadButton = document.createElement('button');
loadButton.className = 'btn btn-small';
loadButton.textContent = 'Load';
loadButton.addEventListener('click', () => this.loadProfile(profileName));
profileItem.appendChild(profileLabel);
profileItem.appendChild(loadButton);
profilesList.appendChild(profileItem);
});
}
} catch (error) {
console.error('Failed to load profiles:', error);
alert('Failed to load profiles');
}
}
async loadProfile(profileName) {
try {
const response = await fetch(`/api/profiles/${profileName}`, {
method: 'POST'
});
if (response.ok) {
await this.loadState();
this.renderTabs();
if (this.state.tab_order.length > 0) {
this.selectTab(this.state.tab_order[0]);
} else {
this.currentTab = null;
}
await this.loadProfiles(); // Refresh the profiles list
} else {
const error = await response.json();
alert(error.error || 'Failed to load profile');
}
} catch (error) {
console.error('Failed to load profile:', error);
alert('Failed to load profile');
}
}
async createProfile() {
const nameInput = document.getElementById('new-profile-name');
const profileName = nameInput.value.trim();
if (!profileName) {
alert('Profile name cannot be empty');
return;
}
try {
const response = await fetch('/api/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: profileName })
});
if (response.ok) {
nameInput.value = '';
await this.loadProfiles();
alert(`Profile '${profileName}' created successfully`);
} else {
const error = await response.json();
alert(error.error || 'Failed to create profile');
}
} catch (error) {
console.error('Failed to create profile:', error);
alert('Failed to create profile');
}
}
async loadProfilePalette() {
const currentProfile = this.state.current_profile;
if (!currentProfile) {
this.renderProfilePalette([]);
return;
}
try {
const response = await fetch(`/api/profiles/${currentProfile}/palette`);
if (response.ok) {
const data = await response.json();
this.renderProfilePalette(data.color_palette || []);
} else {
this.renderProfilePalette([]);
}
} catch (error) {
console.error('Failed to load profile palette:', error);
this.renderProfilePalette([]);
}
}
renderProfilePalette(colors) {
// Render in profiles modal
const container = document.getElementById('profile-palette-container');
if (container) {
container.innerHTML = '';
colors.forEach((color, index) => {
const swatch = this.createPaletteSwatch(color, index);
container.appendChild(swatch);
});
}
// Render in color palette modal
const paletteContainer = document.getElementById('palette-container');
if (paletteContainer) {
paletteContainer.innerHTML = '';
colors.forEach((color, index) => {
const swatch = this.createPaletteSwatch(color, index);
paletteContainer.appendChild(swatch);
});
}
}
createPaletteSwatch(color, index) {
const swatch = document.createElement('div');
swatch.style.cssText = 'width: 40px; height: 40px; background-color: ' + color + '; border: 2px solid #4a4a4a; border-radius: 4px; cursor: pointer; position: relative;';
swatch.title = `Click to apply ${color} to selected color`;
// Click to apply color to currently selected color in active tab
swatch.addEventListener('click', (e) => {
// Only apply if not clicking the remove button
if (e.target === swatch || !e.target.closest('button')) {
this.applyPaletteColorToSelected(color);
}
});
const removeBtn = document.createElement('button');
removeBtn.textContent = '×';
removeBtn.style.cssText = 'position: absolute; top: -8px; right: -8px; width: 20px; height: 20px; border-radius: 50%; background-color: #ff4444; color: white; border: none; cursor: pointer; font-size: 14px; line-height: 1; display: flex; align-items: center; justify-content: center; z-index: 10;';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.removePaletteColor(index);
});
swatch.appendChild(removeBtn);
return swatch;
}
applyPaletteColorToSelected(paletteColor) {
if (!this.currentTab) {
alert('No tab selected. Please select a tab first.');
return;
}
const pattern = this.state.lights[this.currentTab].settings.pattern;
const patternSettings = this.getPatternSettings(this.currentTab, pattern);
if (patternSettings.colors.length === 0) {
alert('No colors in the current pattern. Add a color first.');
return;
}
// Apply the palette color to the currently selected color index
const selectedIndex = this.selectedColorIndex;
if (selectedIndex >= 0 && selectedIndex < patternSettings.colors.length) {
this.updateColorInPalette(this.currentTab, selectedIndex, paletteColor);
} else {
// If no color is selected, apply to the first color
this.updateColorInPalette(this.currentTab, 0, paletteColor);
}
}
async addPaletteColor() {
const colorInput = document.getElementById('new-palette-color');
if (!colorInput) return;
await this.addPaletteColorFromInput(colorInput);
}
async addPaletteColorFromModal() {
const colorInput = document.getElementById('palette-new-color');
if (!colorInput) return;
await this.addPaletteColorFromInput(colorInput);
}
async addPaletteColorFromInput(colorInput) {
const color = colorInput.value;
const currentProfile = this.state.current_profile;
if (!currentProfile) {
alert('No profile selected');
return;
}
try {
const currentPalette = this.state.color_palette || [];
if (currentPalette.includes(color)) {
alert('Color already in palette');
return;
}
const newPalette = [...currentPalette, color];
const response = await fetch(`/api/profiles/${currentProfile}/palette`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color_palette: newPalette })
});
if (response.ok) {
this.state.color_palette = newPalette;
await this.loadProfilePalette();
} else {
alert('Failed to add color to palette');
}
} catch (error) {
console.error('Failed to add palette color:', error);
alert('Failed to add color to palette');
}
}
async removePaletteColor(index) {
const currentProfile = this.state.current_profile;
if (!currentProfile) {
alert('No profile selected');
return;
}
try {
const currentPalette = this.state.color_palette || [];
const newPalette = currentPalette.filter((_, i) => i !== index);
const response = await fetch(`/api/profiles/${currentProfile}/palette`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color_palette: newPalette })
});
if (response.ok) {
this.state.color_palette = newPalette;
await this.loadProfilePalette();
} else {
alert('Failed to remove color from palette');
}
} catch (error) {
console.error('Failed to remove palette color:', error);
alert('Failed to remove color from palette');
}
}
showPaletteQuickSelect(wrapper, tabName, colorIndex, colorPickerInput) {
const palette = this.state.color_palette || [];
if (palette.length === 0) {
// No palette colors, allow native picker to open
return;
}
// Store context for the modal
this.quickPaletteContext = {
tabName: tabName,
colorIndex: colorIndex,
colorPickerInput: colorPickerInput
};
// Show the modal
const modal = document.getElementById('quick-palette-modal');
modal.classList.add('active');
// Render palette colors in the modal
this.renderQuickPalette(palette);
}
renderQuickPalette(palette) {
const container = document.getElementById('quick-palette-container');
if (!container) return;
container.innerHTML = '';
if (palette.length === 0) {
container.innerHTML = '<p style="text-align: center; color: #888; width: 100%;">No colors in palette</p>';
return;
}
palette.forEach((color) => {
const colorBtn = document.createElement('div');
colorBtn.style.cssText = `width: 80px; height: 80px; background-color: ${color}; border: 3px solid #4a4a4a; border-radius: 8px; cursor: pointer; flex-shrink: 0; position: relative; transition: transform 0.2s, border-color 0.2s;`;
colorBtn.title = color;
// Add color hex label
const label = document.createElement('div');
label.style.cssText = 'position: absolute; bottom: -20px; left: 0; right: 0; text-align: center; font-size: 0.7rem; color: #aaa;';
label.textContent = color;
colorBtn.appendChild(label);
colorBtn.addEventListener('mouseenter', () => {
colorBtn.style.transform = 'scale(1.1)';
colorBtn.style.borderColor = '#6a5acd';
});
colorBtn.addEventListener('mouseleave', () => {
colorBtn.style.transform = 'scale(1)';
colorBtn.style.borderColor = '#4a4a4a';
});
colorBtn.addEventListener('click', () => {
if (this.quickPaletteContext) {
const { tabName, colorIndex, colorPickerInput } = this.quickPaletteContext;
this.updateColorInPalette(tabName, colorIndex, color);
colorPickerInput.value = color;
}
this.hideQuickPaletteModal();
});
container.appendChild(colorBtn);
});
}
hideQuickPaletteModal() {
const modal = document.getElementById('quick-palette-modal');
modal.classList.remove('active');
this.quickPaletteContext = null;
}
useColorPickerFromQuickPalette() {
if (this.quickPaletteContext && this.quickPaletteContext.colorPickerInput) {
const colorPickerInput = this.quickPaletteContext.colorPickerInput;
// Mark that we want to allow native picker
colorPickerInput.dataset.allowNative = 'true';
// Temporarily remove the click handler
if (colorPickerInput._clickHandler) {
colorPickerInput.removeEventListener('click', colorPickerInput._clickHandler);
}
this.hideQuickPaletteModal();
// Trigger native color picker after closing modal
setTimeout(() => {
// Try using showPicker() if available (modern browsers)
if (colorPickerInput.showPicker) {
colorPickerInput.showPicker().catch(() => {
// Fallback to click if showPicker fails
colorPickerInput.click();
});
} else {
// Fallback to click for older browsers
colorPickerInput.click();
}
// Restore the click handler after a moment
setTimeout(() => {
if (colorPickerInput._clickHandler) {
colorPickerInput.addEventListener('click', colorPickerInput._clickHandler);
}
delete colorPickerInput.dataset.allowNative;
}, 500);
}, 200);
}
}
hideModal(modalId) {