Update UI for palettes, presets, and patterns

This commit is contained in:
2026-01-16 22:31:36 +13:00
parent 9c43a0a22b
commit df37f15f73
8 changed files with 3649 additions and 11 deletions

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
View 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
View 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
View 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
View 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
View 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
View 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();
});

View File

@@ -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>