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">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>RGB Slider Tabs</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="tabs"></div>
|
||||
<div class="tab-content"></div>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LED Controller - Tab Mode</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<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>
|
||||
</body>
|
||||
<div class="main-content">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user