Update tab UI, presets interactions, and help
Refine tab presets selection and editing, add per-tab removal, improve layout, and provide an in-app help modal.
This commit is contained in:
27
src/static/help.js
Normal file
27
src/static/help.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const helpBtn = document.getElementById('help-btn');
|
||||||
|
const helpModal = document.getElementById('help-modal');
|
||||||
|
const helpCloseBtn = document.getElementById('help-close-btn');
|
||||||
|
|
||||||
|
if (helpBtn && helpModal) {
|
||||||
|
helpBtn.addEventListener('click', () => {
|
||||||
|
helpModal.classList.add('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (helpCloseBtn && helpModal) {
|
||||||
|
helpCloseBtn.addEventListener('click', () => {
|
||||||
|
helpModal.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (helpModal) {
|
||||||
|
helpModal.addEventListener('click', (event) => {
|
||||||
|
if (event.target === helpModal) {
|
||||||
|
helpModal.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@@ -1,3 +1,85 @@
|
|||||||
|
// Shared WebSocket for ESPNow messages (presets + selects)
|
||||||
|
let espnowSocket = null;
|
||||||
|
let espnowSocketReady = false;
|
||||||
|
let espnowPendingMessages = [];
|
||||||
|
|
||||||
|
const getEspnowSocket = () => {
|
||||||
|
if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return espnowSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = `ws://${window.location.host}/ws`;
|
||||||
|
espnowSocket = new WebSocket(wsUrl);
|
||||||
|
espnowSocketReady = false;
|
||||||
|
|
||||||
|
espnowSocket.onopen = () => {
|
||||||
|
espnowSocketReady = true;
|
||||||
|
// Flush any queued messages
|
||||||
|
espnowPendingMessages.forEach((msg) => {
|
||||||
|
try {
|
||||||
|
espnowSocket.send(msg);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send queued ESPNow message:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
espnowPendingMessages = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
espnowSocket.onclose = () => {
|
||||||
|
espnowSocketReady = false;
|
||||||
|
espnowSocket = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
espnowSocket.onerror = (err) => {
|
||||||
|
console.error('ESPNow WebSocket error:', err);
|
||||||
|
};
|
||||||
|
|
||||||
|
return espnowSocket;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendEspnowMessage = (obj) => {
|
||||||
|
const json = JSON.stringify(obj);
|
||||||
|
const ws = getEspnowSocket();
|
||||||
|
if (espnowSocketReady && ws.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
ws.send(json);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send ESPNow message:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Queue until connection is open
|
||||||
|
espnowPendingMessages.push(json);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send a select message for a preset to all device names in the current tab.
|
||||||
|
const sendSelectForCurrentTabDevices = (presetName, sectionEl) => {
|
||||||
|
const section = sectionEl || document.querySelector('.presets-section[data-tab-id]');
|
||||||
|
if (!section || !presetName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const namesAttr = section.getAttribute('data-device-names');
|
||||||
|
const deviceNames = namesAttr
|
||||||
|
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!deviceNames.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = {};
|
||||||
|
deviceNames.forEach((name) => {
|
||||||
|
select[name] = [presetName];
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
v: '1',
|
||||||
|
select,
|
||||||
|
};
|
||||||
|
|
||||||
|
sendEspnowMessage(message);
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const presetsButton = document.getElementById('presets-btn');
|
const presetsButton = document.getElementById('presets-btn');
|
||||||
const presetsModal = document.getElementById('presets-modal');
|
const presetsModal = document.getElementById('presets-modal');
|
||||||
@@ -16,12 +98,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const presetSaveButton = document.getElementById('preset-save-btn');
|
const presetSaveButton = document.getElementById('preset-save-btn');
|
||||||
const presetClearButton = document.getElementById('preset-clear-btn');
|
const presetClearButton = document.getElementById('preset-clear-btn');
|
||||||
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
|
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
|
||||||
|
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-tab-btn');
|
||||||
|
|
||||||
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton || !presetClearButton) {
|
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton || !presetClearButton) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentEditId = null;
|
let currentEditId = null;
|
||||||
|
let currentEditTabId = null;
|
||||||
let cachedPresets = {};
|
let cachedPresets = {};
|
||||||
let cachedPatterns = {};
|
let cachedPatterns = {};
|
||||||
let currentPresetColors = []; // Track colors for the current preset
|
let currentPresetColors = []; // Track colors for the current preset
|
||||||
@@ -324,6 +408,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
const clearForm = () => {
|
const clearForm = () => {
|
||||||
currentEditId = null;
|
currentEditId = null;
|
||||||
|
currentEditTabId = null;
|
||||||
currentPresetColors = [];
|
currentPresetColors = [];
|
||||||
setFormValues({
|
setFormValues({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -374,47 +459,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
name: presetNameInput ? presetNameInput.value.trim() : '',
|
name: presetNameInput ? presetNameInput.value.trim() : '',
|
||||||
pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
|
pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
|
||||||
colors: currentPresetColors || [],
|
colors: currentPresetColors || [],
|
||||||
|
// Use canonical field names expected by the device / API
|
||||||
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
|
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
|
||||||
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
|
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get pattern config to map n keys to their descriptive names
|
// Always store numeric parameters as n1..n8.
|
||||||
const patternName = presetPatternInput ? presetPatternInput.value.trim() : '';
|
|
||||||
const patternConfig = cachedPatterns && cachedPatterns[patternName];
|
|
||||||
|
|
||||||
// Substitute n keys with their values from pattern.json
|
|
||||||
if (patternConfig && typeof patternConfig === 'object') {
|
|
||||||
// Build a mapping: n1 -> "Step Rate", etc. (n keys are now keys, labels are values)
|
|
||||||
const nToLabel = {};
|
|
||||||
Object.entries(patternConfig).forEach(([nKey, label]) => {
|
|
||||||
if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') {
|
|
||||||
nToLabel[nKey] = label;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add n values using their descriptive names as keys
|
|
||||||
for (let i = 1; i <= 8; i++) {
|
for (let i = 1; i <= 8; i++) {
|
||||||
const nKey = `n${i}`;
|
const nKey = `n${i}`;
|
||||||
const value = getNumberInput(`preset-${nKey}-input`);
|
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
|
||||||
const label = nToLabel[nKey];
|
|
||||||
if (label) {
|
|
||||||
// Use the descriptive label as the key
|
|
||||||
payload[label] = value;
|
|
||||||
} else {
|
|
||||||
// Keep n key if no mapping found
|
|
||||||
payload[nKey] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No pattern config, use n keys directly
|
|
||||||
payload.n1 = getNumberInput('preset-n1-input');
|
|
||||||
payload.n2 = getNumberInput('preset-n2-input');
|
|
||||||
payload.n3 = getNumberInput('preset-n3-input');
|
|
||||||
payload.n4 = getNumberInput('preset-n4-input');
|
|
||||||
payload.n5 = getNumberInput('preset-n5-input');
|
|
||||||
payload.n6 = getNumberInput('preset-n6-input');
|
|
||||||
payload.n7 = getNumberInput('preset-n7-input');
|
|
||||||
payload.n8 = getNumberInput('preset-n8-input');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
@@ -540,6 +593,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
openEditor();
|
openEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sendButton = document.createElement('button');
|
||||||
|
sendButton.className = 'btn btn-primary btn-small';
|
||||||
|
sendButton.textContent = 'Send';
|
||||||
|
sendButton.title = 'Send this preset via ESPNow';
|
||||||
|
sendButton.addEventListener('click', () => {
|
||||||
|
// Just send the definition; selection happens when user clicks the preset.
|
||||||
|
sendPresetViaEspNow(presetId, preset || {});
|
||||||
|
});
|
||||||
|
|
||||||
const deleteButton = document.createElement('button');
|
const deleteButton = document.createElement('button');
|
||||||
deleteButton.className = 'btn btn-danger btn-small';
|
deleteButton.className = 'btn btn-danger btn-small';
|
||||||
deleteButton.textContent = 'Delete';
|
deleteButton.textContent = 'Delete';
|
||||||
@@ -569,6 +631,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
row.appendChild(details);
|
row.appendChild(details);
|
||||||
row.appendChild(editButton);
|
row.appendChild(editButton);
|
||||||
|
row.appendChild(sendButton);
|
||||||
row.appendChild(deleteButton);
|
row.appendChild(deleteButton);
|
||||||
presetsList.appendChild(row);
|
presetsList.appendChild(row);
|
||||||
});
|
});
|
||||||
@@ -906,6 +969,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
modalList.addEventListener('click', handlePick);
|
modalList.addEventListener('click', handlePick);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const presetSendButton = document.getElementById('preset-send-btn');
|
||||||
|
|
||||||
|
if (presetSendButton) {
|
||||||
|
presetSendButton.addEventListener('click', () => {
|
||||||
|
const payload = buildPresetPayload();
|
||||||
|
if (!payload.name) {
|
||||||
|
alert('Preset name is required to send.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Send current editor values and select on all devices in the current tab (if any)
|
||||||
|
const section = document.querySelector('.presets-section[data-tab-id]');
|
||||||
|
const namesAttr = section && section.getAttribute('data-device-names');
|
||||||
|
const deviceNames = namesAttr
|
||||||
|
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
sendPresetViaEspNow(payload.name, payload, deviceNames);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
presetSaveButton.addEventListener('click', async () => {
|
presetSaveButton.addEventListener('click', async () => {
|
||||||
const payload = buildPresetPayload();
|
const payload = buildPresetPayload();
|
||||||
if (!payload.name) {
|
if (!payload.name) {
|
||||||
@@ -923,6 +1006,36 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to save preset');
|
throw new Error('Failed to save preset');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine device names from current tab (if any)
|
||||||
|
let deviceNames = [];
|
||||||
|
const section = document.querySelector('.presets-section[data-tab-id]');
|
||||||
|
if (section) {
|
||||||
|
const namesAttr = section.getAttribute('data-device-names');
|
||||||
|
deviceNames = namesAttr
|
||||||
|
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use saved preset from server response for sending
|
||||||
|
const saved = await response.json().catch(() => null);
|
||||||
|
if (saved && typeof saved === 'object') {
|
||||||
|
if (currentEditId) {
|
||||||
|
// PUT returns the preset object directly
|
||||||
|
sendPresetViaEspNow(payload.name, saved, deviceNames);
|
||||||
|
} else {
|
||||||
|
// POST returns { id: preset }
|
||||||
|
const entries = Object.entries(saved);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
const [newId, presetData] = entries[0];
|
||||||
|
sendPresetViaEspNow(newId, presetData, deviceNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: send what we just built
|
||||||
|
sendPresetViaEspNow(payload.name, payload, deviceNames);
|
||||||
|
}
|
||||||
|
|
||||||
await loadPresets();
|
await loadPresets();
|
||||||
clearForm();
|
clearForm();
|
||||||
closeEditor();
|
closeEditor();
|
||||||
@@ -943,13 +1056,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Listen for edit preset events from tab preset buttons
|
// Listen for edit preset events from tab preset buttons
|
||||||
document.addEventListener('editPreset', async (event) => {
|
document.addEventListener('editPreset', async (event) => {
|
||||||
const { presetId, preset } = event.detail;
|
const { presetId, preset, tabId } = event.detail;
|
||||||
currentEditId = presetId;
|
currentEditId = presetId;
|
||||||
|
currentEditTabId = tabId || null;
|
||||||
await loadPatterns();
|
await loadPatterns();
|
||||||
setFormValues(preset);
|
setFormValues(preset);
|
||||||
openEditor();
|
openEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (presetRemoveFromTabButton) {
|
||||||
|
presetRemoveFromTabButton.addEventListener('click', async () => {
|
||||||
|
if (!currentEditId) {
|
||||||
|
alert('No preset loaded to remove.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await removePresetFromTab(currentEditTabId, currentEditId);
|
||||||
|
closeEditor();
|
||||||
|
} catch (e) {
|
||||||
|
// removePresetFromTab already logs and alerts on error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
presetsModal.addEventListener('click', (event) => {
|
presetsModal.addEventListener('click', (event) => {
|
||||||
if (event.target === presetsModal) {
|
if (event.target === presetsModal) {
|
||||||
closeModal();
|
closeModal();
|
||||||
@@ -967,10 +1096,169 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
clearForm();
|
clearForm();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build an ESPNow preset message for a single preset and optionally include a select
|
||||||
|
// for the given device names, then send it via WebSocket.
|
||||||
|
const sendPresetViaEspNow = (presetId, preset, deviceNames) => {
|
||||||
|
try {
|
||||||
|
const presetName = preset.name || presetId;
|
||||||
|
if (!presetName) {
|
||||||
|
alert('Preset has no name and cannot be sent.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = Array.isArray(preset.colors) && preset.colors.length
|
||||||
|
? preset.colors
|
||||||
|
: ['#FFFFFF'];
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
v: '1',
|
||||||
|
presets: {
|
||||||
|
[presetName]: {
|
||||||
|
pattern: preset.pattern || 'off',
|
||||||
|
colors,
|
||||||
|
delay: typeof preset.delay === 'number' ? preset.delay : 100,
|
||||||
|
brightness: typeof preset.brightness === 'number'
|
||||||
|
? preset.brightness
|
||||||
|
: (typeof preset.br === 'number' ? preset.br : 127),
|
||||||
|
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
|
||||||
|
n1: typeof preset.n1 === 'number' ? preset.n1 : 0,
|
||||||
|
n2: typeof preset.n2 === 'number' ? preset.n2 : 0,
|
||||||
|
n3: typeof preset.n3 === 'number' ? preset.n3 : 0,
|
||||||
|
n4: typeof preset.n4 === 'number' ? preset.n4 : 0,
|
||||||
|
n5: typeof preset.n5 === 'number' ? preset.n5 : 0,
|
||||||
|
n6: typeof preset.n6 === 'number' ? preset.n6 : 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optionally include a select section for specific devices
|
||||||
|
if (Array.isArray(deviceNames) && deviceNames.length > 0) {
|
||||||
|
const select = {};
|
||||||
|
deviceNames.forEach((name) => {
|
||||||
|
if (name) {
|
||||||
|
select[name] = [presetName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(select).length > 0) {
|
||||||
|
message.select = select;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEspnowMessage(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send preset via ESPNow:', error);
|
||||||
|
alert('Failed to send preset via ESPNow.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose for other scripts (tabs.js) so they can reuse the shared WebSocket.
|
||||||
|
try {
|
||||||
|
window.sendPresetViaEspNow = sendPresetViaEspNow;
|
||||||
|
} catch (e) {
|
||||||
|
// window may not exist in some environments; ignore.
|
||||||
|
}
|
||||||
|
|
||||||
// Store selected preset per tab
|
// Store selected preset per tab
|
||||||
const selectedPresets = {};
|
const selectedPresets = {};
|
||||||
// Track if we're currently dragging a preset
|
// Track if we're currently dragging a preset
|
||||||
let isDraggingPreset = false;
|
let isDraggingPreset = false;
|
||||||
|
// Context menu for tab presets
|
||||||
|
let presetContextMenu = null;
|
||||||
|
let presetContextTarget = null;
|
||||||
|
|
||||||
|
const ensurePresetContextMenu = () => {
|
||||||
|
if (presetContextMenu) {
|
||||||
|
return presetContextMenu;
|
||||||
|
}
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.id = 'preset-context-menu';
|
||||||
|
menu.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2000;
|
||||||
|
background: #2e2e2e;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
min-width: 160px;
|
||||||
|
display: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const addItem = (label, action) => {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.type = 'button';
|
||||||
|
item.textContent = label;
|
||||||
|
item.dataset.action = action;
|
||||||
|
item.style.cssText = `
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #eee;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
`;
|
||||||
|
if (action === 'remove') {
|
||||||
|
// Visually emphasize and align remove to the right
|
||||||
|
item.style.textAlign = 'right';
|
||||||
|
item.style.color = '#ff8080';
|
||||||
|
}
|
||||||
|
item.addEventListener('mouseover', () => {
|
||||||
|
item.style.backgroundColor = '#3a3a3a';
|
||||||
|
});
|
||||||
|
item.addEventListener('mouseout', () => {
|
||||||
|
item.style.backgroundColor = 'transparent';
|
||||||
|
});
|
||||||
|
menu.appendChild(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
addItem('Edit preset…', 'edit');
|
||||||
|
addItem('Remove', 'remove');
|
||||||
|
|
||||||
|
menu.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('button[data-action]');
|
||||||
|
if (!btn || !presetContextTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { tabId, presetId } = presetContextTarget;
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
hidePresetContextMenu();
|
||||||
|
if (action === 'edit') {
|
||||||
|
await editPresetFromTab(presetId);
|
||||||
|
} else if (action === 'remove') {
|
||||||
|
await removePresetFromTab(tabId, presetId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
presetContextMenu = menu;
|
||||||
|
|
||||||
|
// Hide on outside click
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!presetContextMenu) return;
|
||||||
|
if (e.target.closest('#preset-context-menu')) return;
|
||||||
|
hidePresetContextMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
return menu;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPresetContextMenu = (x, y, tabId, presetId, preset) => {
|
||||||
|
const menu = ensurePresetContextMenu();
|
||||||
|
presetContextTarget = { tabId, presetId, preset };
|
||||||
|
menu.style.left = `${x}px`;
|
||||||
|
menu.style.top = `${y}px`;
|
||||||
|
menu.style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hidePresetContextMenu = () => {
|
||||||
|
if (presetContextMenu) {
|
||||||
|
presetContextMenu.style.display = 'none';
|
||||||
|
}
|
||||||
|
presetContextTarget = null;
|
||||||
|
};
|
||||||
|
|
||||||
// Function to convert 2D grid to flat array (for backward compatibility)
|
// Function to convert 2D grid to flat array (for backward compatibility)
|
||||||
const gridToArray = (presetsGrid) => {
|
const gridToArray = (presetsGrid) => {
|
||||||
@@ -1217,8 +1505,8 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
|||||||
presetInfo.appendChild(presetDetails);
|
presetInfo.appendChild(presetDetails);
|
||||||
button.appendChild(presetInfo);
|
button.appendChild(presetInfo);
|
||||||
|
|
||||||
|
// Left-click selects preset, right-click opens editor
|
||||||
button.addEventListener('click', (e) => {
|
button.addEventListener('click', (e) => {
|
||||||
// Don't trigger click if we just finished dragging
|
|
||||||
if (isDraggingPreset) {
|
if (isDraggingPreset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1237,23 +1525,22 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
|||||||
// Store selected preset for this tab
|
// Store selected preset for this tab
|
||||||
selectedPresets[tabId] = presetId;
|
selectedPresets[tabId] = presetId;
|
||||||
|
|
||||||
// Apply preset to tab - you may want to implement this
|
// Build and send a select message via WebSocket for all device names in this tab.
|
||||||
console.log('Apply preset', presetId, 'to tab', tabId);
|
const presetName = preset.name || presetId;
|
||||||
|
const section = button.closest('.presets-section');
|
||||||
|
sendSelectForCurrentTabDevices(presetName, section);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create edit button
|
button.addEventListener('contextmenu', async (e) => {
|
||||||
const editButton = document.createElement('button');
|
e.preventDefault();
|
||||||
editButton.className = 'btn btn-secondary btn-small';
|
if (isDraggingPreset) {
|
||||||
editButton.textContent = '✎';
|
return;
|
||||||
editButton.title = 'Edit preset';
|
}
|
||||||
editButton.style.cssText = 'min-width: 32px; height: 32px; padding: 0; font-size: 1rem; line-height: 1;';
|
// Right-click: directly open the preset editor using data we already have
|
||||||
editButton.addEventListener('click', async (e) => {
|
await editPresetFromTab(presetId, tabId, preset);
|
||||||
e.stopPropagation();
|
|
||||||
await editPresetFromTab(presetId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
wrapper.appendChild(button);
|
wrapper.appendChild(button);
|
||||||
wrapper.appendChild(editButton);
|
|
||||||
|
|
||||||
// Add drag event handlers
|
// Add drag event handlers
|
||||||
wrapper.addEventListener('dragstart', (e) => {
|
wrapper.addEventListener('dragstart', (e) => {
|
||||||
@@ -1278,20 +1565,23 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
|||||||
return wrapper;
|
return wrapper;
|
||||||
};
|
};
|
||||||
|
|
||||||
const editPresetFromTab = async (presetId) => {
|
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
|
||||||
try {
|
try {
|
||||||
// Load the preset data
|
let preset = existingPreset;
|
||||||
|
if (!preset) {
|
||||||
|
// Fallback: load the preset data from the server if we weren't given it
|
||||||
const response = await fetch(`/presets/${presetId}`, {
|
const response = await fetch(`/presets/${presetId}`, {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to load preset');
|
throw new Error('Failed to load preset');
|
||||||
}
|
}
|
||||||
const preset = await response.json();
|
preset = await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// Dispatch a custom event to trigger the edit in the DOMContentLoaded scope
|
// Dispatch a custom event to trigger the edit in the DOMContentLoaded scope
|
||||||
const editEvent = new CustomEvent('editPreset', {
|
const editEvent = new CustomEvent('editPreset', {
|
||||||
detail: { presetId, preset }
|
detail: { presetId, preset, tabId }
|
||||||
});
|
});
|
||||||
document.dispatchEvent(editEvent);
|
document.dispatchEvent(editEvent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1300,7 +1590,9 @@ const editPresetFromTab = async (presetId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removePresetFromTab = async (presetId, tabId) => {
|
// Remove a preset from a specific tab (does not delete the preset itself)
|
||||||
|
// Expected call style: removePresetFromTab(tabId, presetId)
|
||||||
|
const removePresetFromTab = async (tabId, presetId) => {
|
||||||
if (!tabId) {
|
if (!tabId) {
|
||||||
// Try to get tab ID from the left-panel
|
// Try to get tab ID from the left-panel
|
||||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||||
@@ -1331,37 +1623,39 @@ const removePresetFromTab = async (presetId, tabId) => {
|
|||||||
}
|
}
|
||||||
const tabData = await tabResponse.json();
|
const tabData = await tabResponse.json();
|
||||||
|
|
||||||
// Remove preset from tab's presets array
|
// Normalize to flat array
|
||||||
const presets = tabData.presets || [];
|
let flat = [];
|
||||||
const index = presets.indexOf(presetId);
|
if (Array.isArray(tabData.presets_flat)) {
|
||||||
if (index !== -1) {
|
flat = tabData.presets_flat.slice();
|
||||||
presets.splice(index, 1);
|
} else if (Array.isArray(tabData.presets)) {
|
||||||
|
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||||||
|
flat = tabData.presets.slice();
|
||||||
|
} else if (Array.isArray(tabData.presets[0])) {
|
||||||
|
flat = tabData.presets.flat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeLen = flat.length;
|
||||||
|
flat = flat.filter(id => String(id) !== String(presetId));
|
||||||
|
if (flat.length === beforeLen) {
|
||||||
|
alert('Preset is not in this tab.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newGrid = arrayToGrid(flat, 3);
|
||||||
|
tabData.presets = newGrid;
|
||||||
|
tabData.presets_flat = flat;
|
||||||
|
|
||||||
// Update tab
|
|
||||||
const updateResponse = await fetch(`/tabs/${tabId}`, {
|
const updateResponse = await fetch(`/tabs/${tabId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...tabData, presets }),
|
body: JSON.stringify(tabData),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updateResponse.ok) {
|
if (!updateResponse.ok) {
|
||||||
throw new Error('Failed to update tab');
|
throw new Error('Failed to update tab presets');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload the tab content to show the updated preset list
|
await renderTabPresets(tabId);
|
||||||
if (window.htmx) {
|
|
||||||
htmx.ajax('GET', `/tabs/${tabId}/content-fragment`, {
|
|
||||||
target: '#tab-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
// The htmx:afterSwap event listener will call renderTabPresets
|
|
||||||
} else {
|
|
||||||
// Fallback: reload the page
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert('Preset is not in this tab.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove preset from tab:', error);
|
console.error('Failed to remove preset from tab:', error);
|
||||||
alert('Failed to remove preset from tab.');
|
alert('Failed to remove preset from tab.');
|
||||||
|
|||||||
@@ -121,10 +121,9 @@ header h1 {
|
|||||||
|
|
||||||
.tab-content {
|
.tab-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
padding: 1rem;
|
padding: 0.5rem 1rem 1rem;
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-panel {
|
.left-panel {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
|
|||||||
html += `
|
html += `
|
||||||
<button class="tab-button ${activeClass}"
|
<button class="tab-button ${activeClass}"
|
||||||
data-tab-id="${tabId}"
|
data-tab-id="${tabId}"
|
||||||
|
title="Click to select, right-click to edit"
|
||||||
onclick="selectTab('${tabId}')">
|
onclick="selectTab('${tabId}')">
|
||||||
${tabName}
|
${tabName}
|
||||||
</button>
|
</button>
|
||||||
@@ -241,11 +242,13 @@ async function loadTabContent(tabId) {
|
|||||||
|
|
||||||
// Render tab content (presets section)
|
// Render tab content (presets section)
|
||||||
const tabName = tab.name || `Tab ${tabId}`;
|
const tabName = tab.name || `Tab ${tabId}`;
|
||||||
|
const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : '';
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="presets-section" data-tab-id="${tabId}">
|
<div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}">
|
||||||
<h3>Presets</h3>
|
<h3>Presets for ${tabName}</h3>
|
||||||
<div class="profiles-actions" style="margin-bottom: 1rem;">
|
<div class="profiles-actions" style="margin-bottom: 1rem;">
|
||||||
<button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button>
|
<button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button>
|
||||||
|
<button class="btn btn-secondary" id="send-tab-presets-btn">Send Presets</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="presets-list-tab" class="presets-list">
|
<div id="presets-list-tab" class="presets-list">
|
||||||
<!-- Presets will be loaded here by presets.js -->
|
<!-- Presets will be loaded here by presets.js -->
|
||||||
@@ -253,6 +256,14 @@ async function loadTabContent(tabId) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Wire up "Send Presets" button for this tab
|
||||||
|
const sendBtn = container.querySelector('#send-tab-presets-btn');
|
||||||
|
if (sendBtn) {
|
||||||
|
sendBtn.addEventListener('click', () => {
|
||||||
|
sendTabPresets(tabId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger presets loading if the function exists
|
// Trigger presets loading if the function exists
|
||||||
if (typeof renderTabPresets === 'function') {
|
if (typeof renderTabPresets === 'function') {
|
||||||
renderTabPresets(tabId);
|
renderTabPresets(tabId);
|
||||||
@@ -263,6 +274,65 @@ async function loadTabContent(tabId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send all presets used by a tab via the /presets/send HTTP endpoint.
|
||||||
|
async function sendTabPresets(tabId) {
|
||||||
|
try {
|
||||||
|
// Load tab data to determine which presets are used
|
||||||
|
const tabResponse = await fetch(`/tabs/${tabId}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!tabResponse.ok) {
|
||||||
|
alert('Failed to load tab to send presets.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tabData = await tabResponse.json();
|
||||||
|
|
||||||
|
// Extract preset IDs from tab (supports grid, flat, and legacy formats)
|
||||||
|
let presetIds = [];
|
||||||
|
if (Array.isArray(tabData.presets_flat)) {
|
||||||
|
presetIds = tabData.presets_flat;
|
||||||
|
} else if (Array.isArray(tabData.presets)) {
|
||||||
|
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||||||
|
// Flat array of IDs
|
||||||
|
presetIds = tabData.presets;
|
||||||
|
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
||||||
|
// 2D grid
|
||||||
|
presetIds = tabData.presets.flat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
presetIds = (presetIds || []).filter(Boolean);
|
||||||
|
|
||||||
|
if (!presetIds.length) {
|
||||||
|
alert('This tab has no presets to send.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call server-side ESPNow sender with just the IDs; it handles chunking.
|
||||||
|
const response = await fetch('/presets/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ preset_ids: presetIds }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const msg = (data && data.error) || 'Failed to send presets.';
|
||||||
|
alert(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
|
||||||
|
const messages = typeof data.messages_sent === 'number' ? data.messages_sent : '?';
|
||||||
|
alert(`Sent ${sent} preset(s) in ${messages} ESPNow message(s).`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send tab presets:', error);
|
||||||
|
alert('Failed to send tab presets.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Open edit tab modal
|
// Open edit tab modal
|
||||||
function openEditTabModal(tabId, tab) {
|
function openEditTabModal(tabId, tab) {
|
||||||
const modal = document.getElementById('edit-tab-modal');
|
const modal = document.getElementById('edit-tab-modal');
|
||||||
@@ -360,29 +430,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const newTabIdsInput = document.getElementById('new-tab-ids');
|
const newTabIdsInput = document.getElementById('new-tab-ids');
|
||||||
const createTabButton = document.getElementById('create-tab-btn');
|
const createTabButton = document.getElementById('create-tab-btn');
|
||||||
|
|
||||||
// Set up edit tab button in header
|
|
||||||
const editTabBtn = document.getElementById('edit-tab-btn');
|
|
||||||
if (editTabBtn) {
|
|
||||||
editTabBtn.addEventListener('click', async () => {
|
|
||||||
if (!currentTabId) {
|
|
||||||
alert('No tab selected. Please select a tab first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/tabs/${currentTabId}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const tab = await response.json();
|
|
||||||
openEditTabModal(currentTabId, tab);
|
|
||||||
} else {
|
|
||||||
alert('Failed to load tab for editing');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load tab:', error);
|
|
||||||
alert('Failed to load tab for editing');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tabsButton && tabsModal) {
|
if (tabsButton && tabsModal) {
|
||||||
tabsButton.addEventListener('click', () => {
|
tabsButton.addEventListener('click', () => {
|
||||||
tabsModal.classList.add('active');
|
tabsModal.classList.add('active');
|
||||||
@@ -404,6 +451,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Right-click on a tab button in the main header bar to edit that tab
|
||||||
|
document.addEventListener('contextmenu', async (event) => {
|
||||||
|
const btn = event.target.closest('.tab-button');
|
||||||
|
if (!btn || !btn.dataset.tabId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const tabId = btn.dataset.tabId;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/tabs/${tabId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const tab = await response.json();
|
||||||
|
openEditTabModal(tabId, tab);
|
||||||
|
} else {
|
||||||
|
alert('Failed to load tab for editing');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tab:', error);
|
||||||
|
alert('Failed to load tab for editing');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Set up create tab
|
// Set up create tab
|
||||||
const createTabHandler = async () => {
|
const createTabHandler = async () => {
|
||||||
if (!newTabNameInput) return;
|
if (!newTabNameInput) return;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<button class="btn btn-secondary" id="presets-btn">Presets</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="patterns-btn">Patterns</button>
|
||||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||||
|
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -153,7 +154,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" id="preset-save-btn">Save</button>
|
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
|
||||||
|
<button class="btn btn-primary" id="preset-save-btn">Save & Send</button>
|
||||||
|
<button class="btn btn-danger" id="preset-remove-from-tab-btn">Remove from Tab</button>
|
||||||
<button class="btn btn-secondary" id="preset-clear-btn">Clear</button>
|
<button class="btn btn-secondary" id="preset-clear-btn">Clear</button>
|
||||||
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
|
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,6 +190,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Modal -->
|
||||||
|
<div id="help-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Help</h2>
|
||||||
|
<p class="muted-text">How to use the LED controller UI.</p>
|
||||||
|
|
||||||
|
<h3>Tabs & devices</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
|
||||||
|
<li><strong>Edit tab</strong>: right-click a tab button, or click <strong>Edit</strong> in the Tabs modal.</li>
|
||||||
|
<li><strong>Send all presets</strong>: use the <strong>Send Presets</strong> button in the tab header to push every preset used in that tab to all devices.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Presets in a tab</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li>
|
||||||
|
<li><strong>Edit preset</strong>: right-click a preset tile and choose <strong>Edit preset…</strong>.</li>
|
||||||
|
<li><strong>Remove from tab</strong>: right-click a preset tile and choose <strong>Remove from this tab</strong> (the preset itself is not deleted, only its link from this tab).</li>
|
||||||
|
<li><strong>Reorder presets</strong>: drag preset tiles to change their order; the new layout is saved automatically.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Presets, profiles & colors</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Presets</strong>: use the <strong>Presets</strong> button in the header to create and manage reusable presets.</li>
|
||||||
|
<li><strong>Profiles</strong>: use <strong>Profiles</strong> to save and recall groups of settings.</li>
|
||||||
|
<li><strong>Color Palette</strong>: use <strong>Color Palette</strong> to build a reusable set of colors you can pull into presets.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" id="help-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.modal {
|
.modal {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -322,11 +359,27 @@
|
|||||||
/* Ensure presets list uses grid layout */
|
/* Ensure presets list uses grid layout */
|
||||||
#presets-list-tab {
|
#presets-list-tab {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* Help modal readability */
|
||||||
|
#help-modal .modal-content h2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
#help-modal .modal-content ul {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
padding-left: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
#help-modal .modal-content li {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script src="/static/tabs.js"></script>
|
<script src="/static/tabs.js"></script>
|
||||||
|
<script src="/static/help.js"></script>
|
||||||
<script src="/static/color_palette.js"></script>
|
<script src="/static/color_palette.js"></script>
|
||||||
<script src="/static/profiles.js"></script>
|
<script src="/static/profiles.js"></script>
|
||||||
<script src="/static/tab_palette.js"></script>
|
<script src="/static/tab_palette.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user