Update UI for palettes, presets, and patterns
This commit is contained in:
1750
src/static/app.js
Normal file
1750
src/static/app.js
Normal file
File diff suppressed because it is too large
Load Diff
144
src/static/color_palette.js
Normal file
144
src/static/color_palette.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const paletteButton = document.getElementById('color-palette-btn');
|
||||||
|
const paletteModal = document.getElementById('color-palette-modal');
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentProfileId = null;
|
||||||
|
let currentPalette = [];
|
||||||
|
let currentProfileName = null;
|
||||||
|
|
||||||
|
const renderPalette = () => {
|
||||||
|
paletteContainer.innerHTML = '';
|
||||||
|
if (!currentPalette.length) {
|
||||||
|
const empty = document.createElement('p');
|
||||||
|
empty.className = 'muted-text';
|
||||||
|
empty.textContent = 'No colors in palette.';
|
||||||
|
paletteContainer.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentPalette.forEach((color, index) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'profiles-row';
|
||||||
|
row.dataset.color = color;
|
||||||
|
|
||||||
|
const swatch = document.createElement('div');
|
||||||
|
swatch.style.width = '28px';
|
||||||
|
swatch.style.height = '28px';
|
||||||
|
swatch.style.borderRadius = '4px';
|
||||||
|
swatch.style.backgroundColor = color;
|
||||||
|
swatch.style.border = '1px solid #4a4a4a';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = color;
|
||||||
|
|
||||||
|
const removeButton = document.createElement('button');
|
||||||
|
removeButton.className = 'btn btn-danger btn-small';
|
||||||
|
removeButton.textContent = 'Remove';
|
||||||
|
removeButton.addEventListener('click', async () => {
|
||||||
|
const updated = currentPalette.filter((_, i) => i !== index);
|
||||||
|
await savePalette(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(swatch);
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(removeButton);
|
||||||
|
paletteContainer.appendChild(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPalette = async () => {
|
||||||
|
try {
|
||||||
|
const currentResponse = await fetch('/profiles/current', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!currentResponse.ok) {
|
||||||
|
throw new Error('Failed to load current profile');
|
||||||
|
}
|
||||||
|
const currentData = await currentResponse.json();
|
||||||
|
currentProfileId = currentData.id || null;
|
||||||
|
const profile = currentData.profile || null;
|
||||||
|
currentProfileName = profile ? profile.name : null;
|
||||||
|
if (profileNameDisplay) {
|
||||||
|
profileNameDisplay.textContent = currentProfileName || currentProfileId || 'None';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentProfileId || !profile) {
|
||||||
|
currentPalette = [];
|
||||||
|
renderPalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPalette = profile.palette || profile.color_palette || [];
|
||||||
|
renderPalette();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load palette:', error);
|
||||||
|
currentPalette = [];
|
||||||
|
renderPalette();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePalette = async (newPalette) => {
|
||||||
|
if (!currentProfileId) {
|
||||||
|
alert('No profile selected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/profiles/current', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
palette: newPalette,
|
||||||
|
color_palette: newPalette,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to save palette');
|
||||||
|
}
|
||||||
|
currentPalette = newPalette;
|
||||||
|
renderPalette();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save palette:', error);
|
||||||
|
alert('Failed to save palette.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
paletteModal.classList.add('active');
|
||||||
|
loadPalette();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
paletteModal.classList.remove('active');
|
||||||
|
};
|
||||||
|
|
||||||
|
paletteButton.addEventListener('click', openModal);
|
||||||
|
if (closeButton) {
|
||||||
|
closeButton.addEventListener('click', closeModal);
|
||||||
|
}
|
||||||
|
if (paletteAddButton && paletteNewColor) {
|
||||||
|
paletteAddButton.addEventListener('click', async () => {
|
||||||
|
const color = paletteNewColor.value;
|
||||||
|
if (!color) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentPalette.includes(color)) {
|
||||||
|
alert('Color already in palette.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await savePalette([...currentPalette, color]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
paletteModal.addEventListener('click', (event) => {
|
||||||
|
if (event.target === paletteModal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
86
src/static/patterns.js
Normal file
86
src/static/patterns.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const patternsButton = document.getElementById('patterns-btn');
|
||||||
|
const patternsModal = document.getElementById('patterns-modal');
|
||||||
|
const patternsCloseButton = document.getElementById('patterns-close-btn');
|
||||||
|
const patternsList = document.getElementById('patterns-list');
|
||||||
|
|
||||||
|
if (!patternsButton || !patternsModal || !patternsList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPatterns = (patterns) => {
|
||||||
|
patternsList.innerHTML = '';
|
||||||
|
const entries = Object.entries(patterns || {});
|
||||||
|
if (!entries.length) {
|
||||||
|
const empty = document.createElement('p');
|
||||||
|
empty.className = 'muted-text';
|
||||||
|
empty.textContent = 'No patterns found.';
|
||||||
|
patternsList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entries.forEach(([patternName, data]) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'profiles-row';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = patternName;
|
||||||
|
|
||||||
|
const details = document.createElement('span');
|
||||||
|
const minDelay = data && data.min_delay !== undefined ? data.min_delay : '-';
|
||||||
|
const maxDelay = data && data.max_delay !== undefined ? data.max_delay : '-';
|
||||||
|
details.textContent = `${minDelay}–${maxDelay} ms`;
|
||||||
|
details.style.color = '#aaa';
|
||||||
|
details.style.fontSize = '0.85em';
|
||||||
|
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(details);
|
||||||
|
patternsList.appendChild(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPatterns = async () => {
|
||||||
|
patternsList.innerHTML = '';
|
||||||
|
const loading = document.createElement('p');
|
||||||
|
loading.className = 'muted-text';
|
||||||
|
loading.textContent = 'Loading patterns...';
|
||||||
|
patternsList.appendChild(loading);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/patterns', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load patterns');
|
||||||
|
}
|
||||||
|
const patterns = await response.json();
|
||||||
|
renderPatterns(patterns);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load patterns failed:', error);
|
||||||
|
patternsList.innerHTML = '';
|
||||||
|
const errorMessage = document.createElement('p');
|
||||||
|
errorMessage.className = 'muted-text';
|
||||||
|
errorMessage.textContent = 'Failed to load patterns.';
|
||||||
|
patternsList.appendChild(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
patternsModal.classList.add('active');
|
||||||
|
loadPatterns();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
patternsModal.classList.remove('active');
|
||||||
|
};
|
||||||
|
|
||||||
|
patternsButton.addEventListener('click', openModal);
|
||||||
|
if (patternsCloseButton) {
|
||||||
|
patternsCloseButton.addEventListener('click', closeModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
patternsModal.addEventListener('click', (event) => {
|
||||||
|
if (event.target === patternsModal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
381
src/static/presets.js
Normal file
381
src/static/presets.js
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
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 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 presetColorsInput = document.getElementById('preset-colors-input');
|
||||||
|
const presetBrightnessInput = document.getElementById('preset-brightness-input');
|
||||||
|
const presetDelayInput = document.getElementById('preset-delay-input');
|
||||||
|
const presetSaveButton = document.getElementById('preset-save-btn');
|
||||||
|
const presetClearButton = document.getElementById('preset-clear-btn');
|
||||||
|
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
|
||||||
|
|
||||||
|
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton || !presetClearButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentEditId = null;
|
||||||
|
let cachedPresets = {};
|
||||||
|
let cachedPatterns = {};
|
||||||
|
|
||||||
|
const getNumberInput = (id) => {
|
||||||
|
const input = document.getElementById(id);
|
||||||
|
if (!input) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return parseInt(input.value, 10) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseColors = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((color) => color.trim())
|
||||||
|
.filter((color) => color.length > 0)
|
||||||
|
.map((color) => (color.startsWith('#') ? color : `#${color}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFormValues = (preset) => {
|
||||||
|
if (!presetNameInput || !presetPatternInput || !presetColorsInput || !presetBrightnessInput || !presetDelayInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
presetNameInput.value = preset.name || '';
|
||||||
|
presetPatternInput.value = preset.pattern || '';
|
||||||
|
presetColorsInput.value = Array.isArray(preset.colors) ? preset.colors.join(',') : '';
|
||||||
|
presetBrightnessInput.value = preset.brightness || 0;
|
||||||
|
presetDelayInput.value = preset.delay || 0;
|
||||||
|
document.getElementById('preset-n1-input').value = preset.n1 || 0;
|
||||||
|
document.getElementById('preset-n2-input').value = preset.n2 || 0;
|
||||||
|
document.getElementById('preset-n3-input').value = preset.n3 || 0;
|
||||||
|
document.getElementById('preset-n4-input').value = preset.n4 || 0;
|
||||||
|
document.getElementById('preset-n5-input').value = preset.n5 || 0;
|
||||||
|
document.getElementById('preset-n6-input').value = preset.n6 || 0;
|
||||||
|
document.getElementById('preset-n7-input').value = preset.n7 || 0;
|
||||||
|
document.getElementById('preset-n8-input').value = preset.n8 || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearForm = () => {
|
||||||
|
currentEditId = null;
|
||||||
|
setFormValues({
|
||||||
|
name: '',
|
||||||
|
pattern: '',
|
||||||
|
colors: [],
|
||||||
|
brightness: 0,
|
||||||
|
delay: 0,
|
||||||
|
n1: 0,
|
||||||
|
n2: 0,
|
||||||
|
n3: 0,
|
||||||
|
n4: 0,
|
||||||
|
n5: 0,
|
||||||
|
n6: 0,
|
||||||
|
n7: 0,
|
||||||
|
n8: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditor = () => {
|
||||||
|
if (presetEditorModal) {
|
||||||
|
presetEditorModal.classList.add('active');
|
||||||
|
}
|
||||||
|
loadPatterns().then(() => {
|
||||||
|
updatePresetNLabels(presetPatternInput ? presetPatternInput.value : '');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
if (presetEditorModal) {
|
||||||
|
presetEditorModal.classList.remove('active');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPresetPayload = () => {
|
||||||
|
return {
|
||||||
|
name: presetNameInput ? presetNameInput.value.trim() : '',
|
||||||
|
pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
|
||||||
|
colors: parseColors(presetColorsInput ? presetColorsInput.value : ''),
|
||||||
|
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
|
||||||
|
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
|
||||||
|
n1: getNumberInput('preset-n1-input'),
|
||||||
|
n2: getNumberInput('preset-n2-input'),
|
||||||
|
n3: getNumberInput('preset-n3-input'),
|
||||||
|
n4: getNumberInput('preset-n4-input'),
|
||||||
|
n5: getNumberInput('preset-n5-input'),
|
||||||
|
n6: getNumberInput('preset-n6-input'),
|
||||||
|
n7: getNumberInput('preset-n7-input'),
|
||||||
|
n8: getNumberInput('preset-n8-input'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPatterns = async () => {
|
||||||
|
if (!presetPatternInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/patterns', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const patterns = await response.json();
|
||||||
|
cachedPatterns = patterns || {};
|
||||||
|
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.values(config).some((value) => {
|
||||||
|
return typeof value === 'string' && value.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) => {
|
||||||
|
const labels = {};
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
labels[`n${i}`] = `n${i}:`;
|
||||||
|
}
|
||||||
|
const patternConfig = cachedPatterns && cachedPatterns[patternName];
|
||||||
|
if (patternConfig && typeof patternConfig === 'object') {
|
||||||
|
Object.entries(patternConfig).forEach(([label, key]) => {
|
||||||
|
if (typeof key === 'string' && key.startsWith('n')) {
|
||||||
|
labels[key] = `${label}:`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
const labelEl = document.getElementById(`preset-n${i}-label`);
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = labels[`n${i}`];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
currentEditId = presetId;
|
||||||
|
setFormValues(preset || {});
|
||||||
|
openEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
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(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();
|
||||||
|
renderPresets(presets);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
if (presetsAddButton) {
|
||||||
|
presetsAddButton.addEventListener('click', () => {
|
||||||
|
clearForm();
|
||||||
|
openEditor();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (presetEditorCloseButton) {
|
||||||
|
presetEditorCloseButton.addEventListener('click', closeEditor);
|
||||||
|
}
|
||||||
|
presetClearButton.addEventListener('click', clearForm);
|
||||||
|
if (presetPatternInput) {
|
||||||
|
presetPatternInput.addEventListener('change', () => {
|
||||||
|
updatePresetNLabels(presetPatternInput.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 || !presetColorsInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePick = (event) => {
|
||||||
|
const row = event.target.closest('[data-color]');
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = row.dataset.color;
|
||||||
|
if (!picked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentColors = parseColors(presetColorsInput.value);
|
||||||
|
if (!currentColors.includes(picked)) {
|
||||||
|
currentColors.push(picked);
|
||||||
|
presetColorsInput.value = currentColors.join(',');
|
||||||
|
}
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
modalList.removeEventListener('click', handlePick);
|
||||||
|
};
|
||||||
|
|
||||||
|
modalList.addEventListener('click', handlePick);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
await loadPresets();
|
||||||
|
clearForm();
|
||||||
|
closeEditor();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save preset failed:', error);
|
||||||
|
alert('Failed to save preset.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
presetsModal.addEventListener('click', (event) => {
|
||||||
|
if (event.target === presetsModal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (presetEditorModal) {
|
||||||
|
presetEditorModal.addEventListener('click', (event) => {
|
||||||
|
if (event.target === presetEditorModal) {
|
||||||
|
closeEditor();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearForm();
|
||||||
|
});
|
||||||
186
src/static/profiles.js
Normal file
186
src/static/profiles.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const profilesButton = document.getElementById("profiles-btn");
|
||||||
|
const profilesModal = document.getElementById("profiles-modal");
|
||||||
|
const profilesCloseButton = document.getElementById("profiles-close-btn");
|
||||||
|
const profilesList = document.getElementById("profiles-list");
|
||||||
|
const newProfileInput = document.getElementById("new-profile-name");
|
||||||
|
const createProfileButton = document.getElementById("create-profile-btn");
|
||||||
|
|
||||||
|
if (!profilesButton || !profilesModal || !profilesList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
profilesModal.classList.add("active");
|
||||||
|
loadProfiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
profilesModal.classList.remove("active");
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderProfiles = (profiles, currentProfileId) => {
|
||||||
|
profilesList.innerHTML = "";
|
||||||
|
let entries = [];
|
||||||
|
|
||||||
|
if (Array.isArray(profiles)) {
|
||||||
|
entries = profiles.map((profileId) => [profileId, {}]);
|
||||||
|
} else if (profiles && typeof profiles === "object") {
|
||||||
|
entries = Object.entries(profiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "muted-text";
|
||||||
|
empty.textContent = "No profiles found.";
|
||||||
|
profilesList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach(([profileId, profile]) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "profiles-row";
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = (profile && profile.name) || profileId;
|
||||||
|
if (String(profileId) === String(currentProfileId)) {
|
||||||
|
label.textContent = `✓ ${label.textContent}`;
|
||||||
|
label.style.fontWeight = "bold";
|
||||||
|
label.style.color = "#FFD700";
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyButton = document.createElement("button");
|
||||||
|
applyButton.className = "btn btn-secondary btn-small profiles-apply-btn";
|
||||||
|
applyButton.textContent = "Apply";
|
||||||
|
applyButton.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/profiles/${profileId}/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to apply profile");
|
||||||
|
}
|
||||||
|
await loadProfiles();
|
||||||
|
document.body.dispatchEvent(new Event("tabs-updated"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Apply profile failed:", error);
|
||||||
|
alert("Failed to apply profile.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteButton = document.createElement("button");
|
||||||
|
deleteButton.className = "btn btn-danger btn-small";
|
||||||
|
deleteButton.textContent = "Delete";
|
||||||
|
deleteButton.addEventListener("click", async () => {
|
||||||
|
const confirmed = confirm(`Delete profile "${label.textContent}"?`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/profiles/${profileId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to delete profile");
|
||||||
|
}
|
||||||
|
await loadProfiles();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete profile failed:", error);
|
||||||
|
alert("Failed to delete profile.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(applyButton);
|
||||||
|
row.appendChild(deleteButton);
|
||||||
|
profilesList.appendChild(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProfiles = async () => {
|
||||||
|
profilesList.innerHTML = "";
|
||||||
|
const loading = document.createElement("p");
|
||||||
|
loading.className = "muted-text";
|
||||||
|
loading.textContent = "Loading profiles...";
|
||||||
|
profilesList.appendChild(loading);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/profiles", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to load profiles");
|
||||||
|
}
|
||||||
|
const profiles = await response.json();
|
||||||
|
let currentProfileId = null;
|
||||||
|
try {
|
||||||
|
const currentResponse = await fetch("/profiles/current", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (currentResponse.ok) {
|
||||||
|
const currentData = await currentResponse.json();
|
||||||
|
currentProfileId = currentData.id || null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to load current profile:", error);
|
||||||
|
}
|
||||||
|
renderProfiles(profiles, currentProfileId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Load profiles failed:", error);
|
||||||
|
profilesList.innerHTML = "";
|
||||||
|
const errorMessage = document.createElement("p");
|
||||||
|
errorMessage.className = "muted-text";
|
||||||
|
errorMessage.textContent = "Failed to load profiles.";
|
||||||
|
profilesList.appendChild(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createProfile = async () => {
|
||||||
|
if (!newProfileInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = newProfileInput.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
alert("Profile name cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch("/profiles", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to create profile");
|
||||||
|
}
|
||||||
|
newProfileInput.value = "";
|
||||||
|
await loadProfiles();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Create profile failed:", error);
|
||||||
|
alert("Failed to create profile.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
profilesButton.addEventListener("click", openModal);
|
||||||
|
if (profilesCloseButton) {
|
||||||
|
profilesCloseButton.addEventListener("click", closeModal);
|
||||||
|
}
|
||||||
|
if (createProfileButton) {
|
||||||
|
createProfileButton.addEventListener("click", createProfile);
|
||||||
|
}
|
||||||
|
if (newProfileInput) {
|
||||||
|
newProfileInput.addEventListener("keypress", (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
createProfile();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
profilesModal.addEventListener("click", (event) => {
|
||||||
|
if (event.target === profilesModal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
548
src/static/style.css
Normal file
548
src/static/style.css
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
color: white;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 2px solid #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-container {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border-bottom: 2px solid #4a4a4a;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
background-color: #6a5acd;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
flex: 0 0 50%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 2px solid #4a4a4a;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ids-display {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel-toggle {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel.collapsed {
|
||||||
|
flex: 0 0 48px;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel.collapsed .left-panel-body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel.collapsed .left-panel-toggle {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group label {
|
||||||
|
min-width: 100px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: #6a5acd;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb:hover {
|
||||||
|
background-color: #7a6add;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: #6a5acd;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb:hover {
|
||||||
|
background-color: #7a6add;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Red slider */
|
||||||
|
#red-slider {
|
||||||
|
accent-color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#red-slider::-webkit-slider-thumb {
|
||||||
|
background-color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#red-slider::-moz-range-thumb {
|
||||||
|
background-color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green slider */
|
||||||
|
#green-slider {
|
||||||
|
accent-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
#green-slider::-webkit-slider-thumb {
|
||||||
|
background-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
#green-slider::-moz-range-thumb {
|
||||||
|
background-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blue slider */
|
||||||
|
#blue-slider {
|
||||||
|
accent-color: #0000ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blue-slider::-webkit-slider-thumb {
|
||||||
|
background-color: #0000ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blue-slider::-moz-range-thumb {
|
||||||
|
background-color: #0000ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brightness slider */
|
||||||
|
#brightness-slider {
|
||||||
|
accent-color: #ffff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
#brightness-slider::-webkit-slider-thumb {
|
||||||
|
background-color: #ffff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
#brightness-slider::-moz-range-thumb {
|
||||||
|
background-color: #ffff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-value {
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-params-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-params-section h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-params-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-param-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-param-group label {
|
||||||
|
min-width: 40px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6a5acd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patterns-section,
|
||||||
|
.presets-section,
|
||||||
|
.color-palette-section {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 2px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patterns-section h3,
|
||||||
|
.presets-section h3,
|
||||||
|
.color-palette-section h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patterns-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-button {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-button:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-button.active {
|
||||||
|
background-color: #6a5acd;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-button.default-preset {
|
||||||
|
border: 2px solid #6a5acd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-palette {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch:hover {
|
||||||
|
border-color: #6a5acd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch.selected {
|
||||||
|
border-color: #FFD700;
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch-preview {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-input {
|
||||||
|
width: 60px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-input::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-input::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-input::-moz-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6a5acd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
262
src/static/tab_palette.js
Normal file
262
src/static/tab_palette.js
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
let selectedIndex = null;
|
||||||
|
|
||||||
|
const getTab = async (tabId) => {
|
||||||
|
const response = await fetch(`/tabs/${tabId}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('No tab found');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTabColors = async (tabId, colors) => {
|
||||||
|
const response = await fetch(`/tabs/${tabId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ colors }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to save tab colors');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPalette = (paletteContainer, colors, onColorChange, onRemoveColor, onReorder) => {
|
||||||
|
paletteContainer.innerHTML = '';
|
||||||
|
if (!colors.length) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'muted-text';
|
||||||
|
empty.textContent = 'No colors in palette.';
|
||||||
|
paletteContainer.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
colors.forEach((color, index) => {
|
||||||
|
const swatch = document.createElement('div');
|
||||||
|
swatch.className = 'color-swatch';
|
||||||
|
swatch.draggable = true;
|
||||||
|
swatch.dataset.index = String(index);
|
||||||
|
if (index === selectedIndex) {
|
||||||
|
swatch.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.className = 'color-swatch-preview';
|
||||||
|
preview.style.backgroundColor = color;
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'color-swatch-label';
|
||||||
|
label.textContent = color;
|
||||||
|
|
||||||
|
const colorPicker = document.createElement('input');
|
||||||
|
colorPicker.type = 'color';
|
||||||
|
colorPicker.className = 'color-picker-input';
|
||||||
|
colorPicker.value = color;
|
||||||
|
colorPicker.addEventListener('change', async (event) => {
|
||||||
|
const newColor = event.target.value;
|
||||||
|
await onColorChange(index, newColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeButton = document.createElement('button');
|
||||||
|
removeButton.className = 'btn btn-danger btn-small';
|
||||||
|
removeButton.textContent = 'Remove';
|
||||||
|
removeButton.addEventListener('click', async (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
await onRemoveColor(index);
|
||||||
|
});
|
||||||
|
|
||||||
|
swatch.addEventListener('dragstart', (event) => {
|
||||||
|
event.dataTransfer.setData('text/plain', String(index));
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
});
|
||||||
|
swatch.addEventListener('dragover', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
});
|
||||||
|
swatch.addEventListener('drop', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const fromIndex = parseInt(event.dataTransfer.getData('text/plain'), 10);
|
||||||
|
const toIndex = parseInt(swatch.dataset.index || '-1', 10);
|
||||||
|
if (Number.isNaN(fromIndex) || Number.isNaN(toIndex) || fromIndex === toIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onReorder(fromIndex, toIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
swatch.appendChild(preview);
|
||||||
|
swatch.appendChild(label);
|
||||||
|
swatch.appendChild(colorPicker);
|
||||||
|
swatch.appendChild(removeButton);
|
||||||
|
swatch.addEventListener('click', () => {
|
||||||
|
selectedIndex = index;
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
colorPicker.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
paletteContainer.appendChild(swatch);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initTabPalette = async () => {
|
||||||
|
const paletteContainer = document.getElementById('color-palette');
|
||||||
|
const addButton = document.getElementById('tab-color-add-btn');
|
||||||
|
const addFromPaletteButton = document.getElementById('tab-color-add-from-palette-btn');
|
||||||
|
const colorInput = document.getElementById('tab-color-input');
|
||||||
|
|
||||||
|
if (!paletteContainer || !addButton || !colorInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabId = paletteContainer.dataset.tabId;
|
||||||
|
if (!tabId) {
|
||||||
|
renderPalette(paletteContainer, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabData;
|
||||||
|
try {
|
||||||
|
tabData = await getTab(tabId);
|
||||||
|
} catch (error) {
|
||||||
|
renderPalette(paletteContainer, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let colors = tabData.colors || [];
|
||||||
|
if (!Array.isArray(colors)) {
|
||||||
|
colors = [];
|
||||||
|
}
|
||||||
|
const onRemoveColor = async (index) => {
|
||||||
|
if (index === null || index < 0 || index >= colors.length) {
|
||||||
|
alert('Select a color to remove.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = colors.filter((_, i) => i !== index);
|
||||||
|
const saved = await saveTabColors(tabId, updated);
|
||||||
|
colors = saved.colors || updated;
|
||||||
|
selectedIndex = null;
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove color:', error);
|
||||||
|
alert('Failed to remove color.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReorder = async (fromIndex, toIndex) => {
|
||||||
|
if (fromIndex < 0 || fromIndex >= colors.length || toIndex < 0 || toIndex >= colors.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = [...colors];
|
||||||
|
const [moved] = updated.splice(fromIndex, 1);
|
||||||
|
updated.splice(toIndex, 0, moved);
|
||||||
|
const saved = await saveTabColors(tabId, updated);
|
||||||
|
colors = saved.colors || updated;
|
||||||
|
selectedIndex = toIndex;
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reorder colors:', error);
|
||||||
|
alert('Failed to reorder colors.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onColorChange = async (index, newColor) => {
|
||||||
|
if (!newColor || index < 0 || index >= colors.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = [...colors];
|
||||||
|
updated[index] = newColor;
|
||||||
|
const saved = await saveTabColors(tabId, updated);
|
||||||
|
colors = saved.colors || updated;
|
||||||
|
selectedIndex = index;
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update color:', error);
|
||||||
|
alert('Failed to update color.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
|
||||||
|
addButton.onclick = async () => {
|
||||||
|
const newColor = colorInput.value;
|
||||||
|
if (!newColor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (colors.includes(newColor)) {
|
||||||
|
alert('Color already in palette.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = [...colors, newColor];
|
||||||
|
const saved = await saveTabColors(tabId, updated);
|
||||||
|
colors = saved.colors || updated;
|
||||||
|
selectedIndex = colors.length - 1;
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add color:', error);
|
||||||
|
alert('Failed to add color.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (addFromPaletteButton) {
|
||||||
|
addFromPaletteButton.onclick = () => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePick = async (event) => {
|
||||||
|
const row = event.target.closest('[data-color]');
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = row.dataset.color;
|
||||||
|
if (!picked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!colors.includes(picked)) {
|
||||||
|
const updated = [...colors, picked];
|
||||||
|
const saved = await saveTabColors(tabId, updated);
|
||||||
|
colors = saved.colors || updated;
|
||||||
|
selectedIndex = colors.indexOf(picked);
|
||||||
|
renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
|
||||||
|
}
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add palette color:', error);
|
||||||
|
alert('Failed to add palette color.');
|
||||||
|
} finally {
|
||||||
|
modalList.removeEventListener('click', handlePick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
modalList.addEventListener('click', handlePick);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||||
|
if (event.target && event.target.id === 'tab-content') {
|
||||||
|
selectedIndex = null;
|
||||||
|
initTabPalette();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
initTabPalette();
|
||||||
|
});
|
||||||
@@ -1,14 +1,295 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8">
|
||||||
<title>RGB Slider Tabs</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<title>LED Controller - Tab Mode</title>
|
||||||
</head>
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<body>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<div class="tabs"></div>
|
</head>
|
||||||
<div class="tab-content"></div>
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<header>
|
||||||
|
<h1>LED Controller - Tab Mode</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
hx-get="/tabs/create-form-fragment"
|
||||||
|
hx-target="#add-tab-modal .modal-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="document.getElementById('add-tab-modal').classList.add('active')">
|
||||||
|
+ Add Tab
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" id="edit-tab-btn">Edit Tab</button>
|
||||||
|
<button class="btn btn-danger"
|
||||||
|
hx-delete="/tabs/current"
|
||||||
|
hx-target="#tabs-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-headers='{"Accept": "text/html"}'
|
||||||
|
hx-confirm="Are you sure you want to delete this tab?">
|
||||||
|
Delete Tab
|
||||||
|
</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" id="patterns-btn">Patterns</button>
|
||||||
|
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<script type="module" src="main.js"></script>
|
<div class="main-content">
|
||||||
</body>
|
<div class="tabs-container">
|
||||||
|
<div id="tabs-list"
|
||||||
|
hx-get="/tabs/list-fragment"
|
||||||
|
hx-trigger="load, tabs-updated from:body"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Loading tabs...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-content"
|
||||||
|
class="tab-content"
|
||||||
|
hx-get="/tabs/current"
|
||||||
|
hx-trigger="load, tabs-updated from:body"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-headers='{"Accept": "text/html"}'>
|
||||||
|
<div style="padding: 2rem; text-align: center; color: #aaa;">
|
||||||
|
Select a tab to get started
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Tab Modal -->
|
||||||
|
<div id="add-tab-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Add New Tab</h2>
|
||||||
|
<form hx-post="/tabs"
|
||||||
|
hx-target="#tabs-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-headers='{"Accept": "text/html"}'
|
||||||
|
hx-on::after-request="if(event.detail.successful) { document.getElementById('add-tab-modal').classList.remove('active'); document.body.dispatchEvent(new Event('tabs-updated')); }">
|
||||||
|
<label>Tab Name:</label>
|
||||||
|
<input type="text" name="name" placeholder="Enter tab name" required>
|
||||||
|
<label>Device IDs (comma-separated):</label>
|
||||||
|
<input type="text" name="ids" placeholder="1,2,3" value="1">
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('add-tab-modal').classList.remove('active')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Tab Modal (placeholder for now) -->
|
||||||
|
<div id="edit-tab-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Edit Tab</h2>
|
||||||
|
<p>Edit functionality coming soon...</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profiles Modal -->
|
||||||
|
<div id="profiles-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Profiles</h2>
|
||||||
|
<div class="profiles-actions">
|
||||||
|
<input type="text" id="new-profile-name" placeholder="Profile name">
|
||||||
|
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
||||||
|
</div>
|
||||||
|
<div id="profiles-list" class="profiles-list"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Presets Modal -->
|
||||||
|
<div id="presets-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Presets</h2>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" id="preset-add-btn">Add</button>
|
||||||
|
</div>
|
||||||
|
<div id="presets-list" class="profiles-list"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" id="presets-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Editor Modal -->
|
||||||
|
<div id="preset-editor-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Preset</h2>
|
||||||
|
<div class="profiles-actions">
|
||||||
|
<input type="text" id="preset-name-input" placeholder="Preset name">
|
||||||
|
<select id="preset-pattern-input">
|
||||||
|
<option value="">Pattern</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label>Colors (comma-separated hex)</label>
|
||||||
|
<div class="profiles-actions">
|
||||||
|
<input type="text" id="preset-colors-input" placeholder="#FF0000,#00FF00,#0000FF">
|
||||||
|
<button class="btn btn-secondary" id="preset-add-from-palette-btn">Add from Palette</button>
|
||||||
|
</div>
|
||||||
|
<div class="profiles-actions">
|
||||||
|
<input type="number" id="preset-brightness-input" placeholder="Brightness" min="0" max="255" value="0">
|
||||||
|
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="n-params-grid">
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n1-input" id="preset-n1-label">n1:</label>
|
||||||
|
<input type="number" id="preset-n1-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n2-input" id="preset-n2-label">n2:</label>
|
||||||
|
<input type="number" id="preset-n2-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n3-input" id="preset-n3-label">n3:</label>
|
||||||
|
<input type="number" id="preset-n3-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n4-input" id="preset-n4-label">n4:</label>
|
||||||
|
<input type="number" id="preset-n4-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n5-input" id="preset-n5-label">n5:</label>
|
||||||
|
<input type="number" id="preset-n5-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n6-input" id="preset-n6-label">n6:</label>
|
||||||
|
<input type="number" id="preset-n6-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n7-input" id="preset-n7-label">n7:</label>
|
||||||
|
<input type="number" id="preset-n7-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="preset-n8-input" id="preset-n8-label">n8:</label>
|
||||||
|
<input type="number" id="preset-n8-input" min="0" max="255" value="0" class="n-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" id="preset-save-btn">Save</button>
|
||||||
|
<button class="btn btn-secondary" id="preset-clear-btn">Clear</button>
|
||||||
|
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patterns Modal -->
|
||||||
|
<div id="patterns-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Patterns</h2>
|
||||||
|
<div id="patterns-list" class="profiles-list"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color Palette Modal -->
|
||||||
|
<div id="color-palette-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Color Palette</h2>
|
||||||
|
<p class="muted-text">Profile: <span id="palette-current-profile-name">None</span></p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.7);
|
||||||
|
}
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
.modal-content label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.modal-content input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.profiles-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.profiles-actions input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.profiles-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.profiles-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.muted-text {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #d32f2f;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #3a1a1a;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="/static/color_palette.js"></script>
|
||||||
|
<script src="/static/profiles.js"></script>
|
||||||
|
<script src="/static/tab_palette.js"></script>
|
||||||
|
<script src="/static/patterns.js"></script>
|
||||||
|
<script src="/static/presets.js"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user