Two widgets of the same type (e.g. two Photos widgets) both showed up
as plain "Photos" with no way to tell which was which. The buttons API
now includes each widget's grid placement plus the frame's grid dims;
the UI derives a rough position ("top-left", "right", etc.) from that
and only appends a number+position suffix when a type actually
repeats on the frame, leaving the common single-widget-per-type case
unchanged.
566 lines
21 KiB
JavaScript
566 lines
21 KiB
JavaScript
// Configuration tab: frame-wide settings (orientation, quiet hours,
|
||
// palette/color/contrast/dither, firmware, battery alerts) + take
|
||
// control. Frame name lives in the page header now (frame_header.js);
|
||
// every per-widget setting (album, calendar view/inclusion, whiteboard
|
||
// source) lives in its own widget's gear-icon dialog instead (see
|
||
// static/frame_layout.js) -- this tab never touches those.
|
||
// window.FRAME_API is set by the template. Checkboxes are always sent
|
||
// explicitly as "true"/"false" -- the server treats absent fields as
|
||
// "leave unchanged", so a checkbox must never be simply omitted.
|
||
|
||
async function saveConfig() {
|
||
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
||
const body = new URLSearchParams({
|
||
orientation: document.getElementById('orientation').value,
|
||
refresh_interval_s: String(minutes * 60),
|
||
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
|
||
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
||
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
|
||
timezone: document.getElementById('timezone').value || 'UTC',
|
||
});
|
||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body,
|
||
});
|
||
if (!resp.ok) {
|
||
throw new Error(await apiError(resp));
|
||
}
|
||
}
|
||
|
||
// Orientation swaps the widget grid's long/short axis (see
|
||
// grid.grid_dims), so an existing widget layout is usually left with
|
||
// out-of-bounds coordinates on the new grid -- the server resets it to
|
||
// one full-panel widget when this actually changes (see
|
||
// api_frames.py's api_config_save). Warn before that happens rather
|
||
// than silently losing whatever layout was on the Layout tab.
|
||
let lastSavedOrientation = document.getElementById('orientation').value;
|
||
|
||
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const newOrientation = document.getElementById('orientation').value;
|
||
if (newOrientation !== lastSavedOrientation) {
|
||
const proceed = confirm(
|
||
"Changing orientation resets this frame's widget layout to a single " +
|
||
'full-panel widget -- any other widgets placed on the Layout tab will ' +
|
||
'be removed. Continue?'
|
||
);
|
||
if (!proceed) {
|
||
document.getElementById('orientation').value = lastSavedOrientation;
|
||
return;
|
||
}
|
||
}
|
||
try {
|
||
await saveConfig();
|
||
lastSavedOrientation = newOrientation;
|
||
showStatus(true, 'Saved.');
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
});
|
||
|
||
async function takeControl() {
|
||
try {
|
||
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
||
if (!resp.ok) throw new Error(await apiError(resp));
|
||
showStatus(true, 'You have control now.');
|
||
loadControl();
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
}
|
||
|
||
async function loadControl() {
|
||
const banner = document.getElementById('control-banner');
|
||
try {
|
||
const resp = await fetch(`${window.FRAME_API}/status`);
|
||
if (!resp.ok) return; // unconfigured frame: control still works via 409s
|
||
const data = await resp.json();
|
||
if (data.control && !data.control.you) {
|
||
banner.style.display = 'flex';
|
||
document.getElementById('control-holder').textContent = data.control.controller
|
||
? `${data.control.controller} currently has control of this frame.`
|
||
: 'Nobody has control of this frame yet.';
|
||
} else {
|
||
banner.style.display = 'none';
|
||
}
|
||
} catch (e) { /* banner is best-effort */ }
|
||
}
|
||
|
||
document.getElementById('take-control').addEventListener('click', takeControl);
|
||
|
||
// ---- Advanced configuration: color palette ----
|
||
//
|
||
// Hex field and R/G/B number fields are kept in sync live, both
|
||
// directions -- editing either updates the other plus the preview
|
||
// swatch. Hex stays the field actually read at save time (it's what
|
||
// the server already validates as #rrggbb); the R/G/B fields are purely
|
||
// an alternate, more precise way to arrive at the same value than
|
||
// eyeballing a color-picker swatch.
|
||
|
||
function paletteHexInputs() {
|
||
return Array.from(document.querySelectorAll('.palette-hex'))
|
||
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
|
||
}
|
||
|
||
function hexFromRgb(r, g, b) {
|
||
const clamp = (v) => Math.max(0, Math.min(255, Math.round(Number(v) || 0)));
|
||
return '#' + [r, g, b].map((v) => clamp(v).toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
function rgbFromHex(hex) {
|
||
const m = /^#?([0-9a-f]{6})$/i.exec((hex || '').trim());
|
||
if (!m) return null;
|
||
const n = parseInt(m[1], 16);
|
||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||
}
|
||
|
||
function paletteFieldsFor(index) {
|
||
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
|
||
return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') };
|
||
}
|
||
|
||
function syncPaletteFromHex(index) {
|
||
const f = paletteFieldsFor(index);
|
||
const rgb = rgbFromHex(f.hex.value);
|
||
if (!rgb) return;
|
||
[f.r.value, f.g.value, f.b.value] = rgb;
|
||
f.swatch.style.background = f.hex.value;
|
||
}
|
||
|
||
function syncPaletteFromRgb(index) {
|
||
const f = paletteFieldsFor(index);
|
||
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
|
||
f.hex.value = hex;
|
||
f.swatch.style.background = hex;
|
||
}
|
||
|
||
const palettePickerCount = paletteHexInputs().length;
|
||
for (let i = 0; i < palettePickerCount; i++) {
|
||
const f = paletteFieldsFor(i);
|
||
f.hex.addEventListener('input', () => syncPaletteFromHex(i));
|
||
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i)));
|
||
}
|
||
|
||
// Sliders: live numeric readout next to each, no save until the button
|
||
// below is clicked.
|
||
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
||
const input = document.getElementById(id);
|
||
const readout = document.getElementById(`${id}_value`);
|
||
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
|
||
});
|
||
|
||
async function savePalette(extra) {
|
||
const body = new URLSearchParams(extra || {});
|
||
for (const input of paletteHexInputs()) {
|
||
body.append('palette', input.value);
|
||
}
|
||
try {
|
||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body,
|
||
});
|
||
if (!resp.ok) throw new Error(await apiError(resp));
|
||
showStatus(true, 'Saved.');
|
||
loadPreview();
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
}
|
||
|
||
document.getElementById('palette-save').addEventListener('click', () => {
|
||
savePalette({
|
||
color_boost: document.getElementById('color_boost').value,
|
||
contrast_boost: document.getElementById('contrast_boost').value,
|
||
dither_strength: document.getElementById('dither_strength').value,
|
||
});
|
||
});
|
||
|
||
document.getElementById('palette-reset').addEventListener('click', () => {
|
||
const inputs = paletteHexInputs();
|
||
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
|
||
inputs[i].value = hex;
|
||
syncPaletteFromHex(i);
|
||
});
|
||
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
||
document.getElementById(id).value = '1';
|
||
document.getElementById(`${id}_value`).textContent = '1.00';
|
||
});
|
||
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
|
||
});
|
||
|
||
// ---- Preview: current photo vs. how it renders with saved settings ----
|
||
// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API,
|
||
// set by the template) rather than window.FRAME_API -- palette/color/
|
||
// contrast/dither are frame-level, but "the current photo" to preview
|
||
// them against is necessarily one specific photo widget's. Null (no
|
||
// photo widget on this frame) means the template didn't render the
|
||
// preview section at all -- nothing to wire up.
|
||
|
||
function loadPreview() {
|
||
if (!window.PHOTO_WIDGET_PREVIEW_API) return;
|
||
const bust = Date.now(); // avoid a stale cached image after settings change
|
||
document.getElementById('preview-original').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/original?_=${bust}`;
|
||
document.getElementById('preview-rendered').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/rendered?_=${bust}`;
|
||
}
|
||
|
||
const previewRefreshBtn = document.getElementById('preview-refresh');
|
||
if (previewRefreshBtn) previewRefreshBtn.addEventListener('click', loadPreview);
|
||
loadPreview();
|
||
|
||
// ---- Battery alerts card ----
|
||
|
||
document.getElementById('battery-alert-save').addEventListener('click', async () => {
|
||
const raw = document.getElementById('battery_alert_threshold_pct').value.trim();
|
||
const body = new URLSearchParams({
|
||
battery_alert_threshold_pct: raw === '' ? '-1' : raw,
|
||
});
|
||
try {
|
||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body,
|
||
});
|
||
if (!resp.ok) throw new Error(await apiError(resp));
|
||
showStatus(true, 'Saved.');
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
});
|
||
|
||
// ---- Firmware card ----
|
||
|
||
document.getElementById('firmware-upload').addEventListener('click', async () => {
|
||
const input = document.getElementById('firmware-file');
|
||
if (!input.files.length) {
|
||
showStatus(false, 'Pick a firmware .bin first.');
|
||
return;
|
||
}
|
||
const form = new FormData();
|
||
form.append('file', input.files[0]);
|
||
try {
|
||
const resp = await fetch(`${window.FRAME_API}/firmware`, { method: 'POST', body: form });
|
||
if (!resp.ok) {
|
||
throw new Error(await apiError(resp));
|
||
}
|
||
const result = await resp.json();
|
||
document.getElementById('firmware-available').textContent =
|
||
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
|
||
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
});
|
||
|
||
function showRepoDisplayMode(url) {
|
||
document.getElementById('firmware-repo-text').textContent = url;
|
||
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
|
||
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
|
||
}
|
||
|
||
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
|
||
document.getElementById('firmware-repo-display').style.display = 'none';
|
||
document.getElementById('firmware-repo-edit').style.display = 'block';
|
||
document.getElementById('firmware_update_repo_url').focus();
|
||
});
|
||
|
||
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
|
||
try {
|
||
const body = new URLSearchParams({
|
||
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
|
||
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
|
||
});
|
||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body,
|
||
});
|
||
if (!resp.ok) throw new Error(await apiError(resp));
|
||
showStatus(true, 'Saved.');
|
||
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
|
||
loadFirmwareCheck();
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
});
|
||
|
||
async function loadFirmwareCheck(force) {
|
||
const statusEl = document.getElementById('firmware-gitea-status');
|
||
const btn = document.getElementById('firmware-update-btn');
|
||
const boardEl = document.getElementById('firmware-board');
|
||
try {
|
||
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''), { method: 'POST' });
|
||
if (!resp.ok) {
|
||
if (force) {
|
||
showStatus(false, await apiError(resp));
|
||
}
|
||
return;
|
||
}
|
||
const data = await resp.json();
|
||
if (data.board) {
|
||
boardEl.textContent = `Detected board: ${data.board}`;
|
||
}
|
||
if (!data.enabled) {
|
||
statusEl.style.display = 'none';
|
||
btn.style.display = 'none';
|
||
if (force) {
|
||
showStatus(false, 'No Gitea repo URL configured.');
|
||
}
|
||
return;
|
||
}
|
||
statusEl.style.display = 'block';
|
||
if (!data.board) {
|
||
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
|
||
btn.style.display = 'none';
|
||
} else if (data.update_available) {
|
||
statusEl.textContent = `Update available: v${data.latest_version}.`;
|
||
btn.style.display = 'inline-block';
|
||
} else if (data.latest_version) {
|
||
statusEl.textContent = `Up to date (v${data.latest_version}).`;
|
||
btn.style.display = 'none';
|
||
} else {
|
||
statusEl.textContent = 'No releases found yet.';
|
||
btn.style.display = 'none';
|
||
}
|
||
if (force) {
|
||
showStatus(true, 'Checked.');
|
||
}
|
||
} catch (e) {
|
||
// A failed passive poll is silent; an explicit "Check now" click
|
||
// still surfaces the error.
|
||
if (force) {
|
||
showStatus(false, e.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
document.getElementById('firmware-check-now').addEventListener('click', () => loadFirmwareCheck(true));
|
||
|
||
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
|
||
const btn = document.getElementById('firmware-update-btn');
|
||
btn.disabled = true;
|
||
try {
|
||
const resp = await fetch(`${window.FRAME_API}/firmware/apply-latest`, { method: 'POST' });
|
||
if (!resp.ok) {
|
||
throw new Error(await apiError(resp));
|
||
}
|
||
const result = await resp.json();
|
||
document.getElementById('firmware-available').textContent =
|
||
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
|
||
showStatus(true, `Firmware v${result.version} staged from Gitea.`);
|
||
loadFirmwareCheck();
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
loadControl();
|
||
loadFirmwareCheck();
|
||
// The server throttles actual Gitea API calls itself, so this poll is
|
||
// cheap either way.
|
||
setInterval(loadFirmwareCheck, 60000);
|
||
|
||
// --- Button assignments -----------------------------------------------
|
||
// {widgets: [{id, widget_type, x, y, w, h, actions: [{action, label}]}],
|
||
// grid: {cols, rows}, next: [...], back: [...]} -- see api_frames.py's
|
||
// api_buttons_get. Each button's list is edited client-side (add/
|
||
// remove/reorder) then PUT as a whole -- simpler than separate reorder/
|
||
// add/remove endpoints for what's normally a handful of entries, and
|
||
// this file already has the full list in hand after any edit.
|
||
let buttonsData = null;
|
||
let widgetNames = {}; // widget id -> disambiguated display name, see buildWidgetNames
|
||
const BUTTONS = ['next', 'back'];
|
||
|
||
// "top-left"/"bottom"/"center" etc. from a widget's grid rect vs the
|
||
// frame's grid dims -- the same rough position you'd read off the
|
||
// Layout canvas by eye, used to tell apart two widgets of the same type
|
||
// that would otherwise both just say "Photos".
|
||
function widgetPositionLabel(w, grid) {
|
||
const cx = w.x + w.w / 2;
|
||
const cy = w.y + w.h / 2;
|
||
const horiz = cx < grid.cols / 2 ? 'left' : (cx > grid.cols / 2 ? 'right' : '');
|
||
const vert = cy < grid.rows / 2 ? 'top' : (cy > grid.rows / 2 ? 'bottom' : '');
|
||
if (!horiz && !vert) return 'center';
|
||
if (!vert) return horiz;
|
||
if (!horiz) return vert;
|
||
return `${vert}-${horiz}`;
|
||
}
|
||
|
||
// A single widget of a given type keeps the plain type name ("Photos")
|
||
// -- the common case, no need to clutter it. Only widgets sharing a
|
||
// type with another widget on the same frame get a number + position
|
||
// suffix, numbered in reading order (top-to-bottom, left-to-right).
|
||
function buildWidgetNames(widgets, grid) {
|
||
const byType = {};
|
||
widgets.forEach((w) => { (byType[w.widget_type] = byType[w.widget_type] || []).push(w); });
|
||
const names = {};
|
||
Object.values(byType).forEach((group) => {
|
||
if (group.length === 1) {
|
||
names[group[0].id] = WIDGET_LABELS[group[0].widget_type] || group[0].widget_type;
|
||
return;
|
||
}
|
||
const ordered = [...group].sort((a, b) => (a.y - b.y) || (a.x - b.x));
|
||
ordered.forEach((w, i) => {
|
||
const base = WIDGET_LABELS[w.widget_type] || w.widget_type;
|
||
names[w.id] = `${base} ${i + 1} (${widgetPositionLabel(w, grid)})`;
|
||
});
|
||
});
|
||
return names;
|
||
}
|
||
|
||
function widgetActionLabel(widgetId, action) {
|
||
const w = buttonsData.widgets.find((w) => w.id === widgetId);
|
||
if (!w) return `(deleted widget): ${action}`;
|
||
const found = w.actions.find((a) => a.action === action);
|
||
const actionLabel = found ? found.label : action;
|
||
return `${widgetNames[widgetId] || WIDGET_LABELS[w.widget_type] || w.widget_type}: ${actionLabel}`;
|
||
}
|
||
|
||
function renderButtonList(button) {
|
||
const list = document.getElementById(`button-actions-${button}`);
|
||
const rows = buttonsData[button];
|
||
list.innerHTML = '';
|
||
if (!rows.length) {
|
||
list.innerHTML = '<li class="sub">Nothing assigned -- this button won’t do anything.</li>';
|
||
return;
|
||
}
|
||
rows.forEach((row, i) => {
|
||
const li = document.createElement('li');
|
||
li.className = 'button-action-row';
|
||
|
||
const span = document.createElement('span');
|
||
span.textContent = widgetActionLabel(row.widget_id, row.action);
|
||
|
||
const controls = document.createElement('span');
|
||
controls.className = 'button-action-controls';
|
||
|
||
const up = document.createElement('button');
|
||
up.type = 'button';
|
||
up.className = 'icon-btn';
|
||
up.textContent = '↑';
|
||
up.title = 'Move up';
|
||
up.disabled = i === 0;
|
||
up.addEventListener('click', () => moveButtonAction(button, i, -1));
|
||
|
||
const down = document.createElement('button');
|
||
down.type = 'button';
|
||
down.className = 'icon-btn';
|
||
down.textContent = '↓';
|
||
down.title = 'Move down';
|
||
down.disabled = i === rows.length - 1;
|
||
down.addEventListener('click', () => moveButtonAction(button, i, 1));
|
||
|
||
const remove = document.createElement('button');
|
||
remove.type = 'button';
|
||
remove.className = 'icon-btn';
|
||
remove.textContent = '×';
|
||
remove.title = 'Remove';
|
||
remove.addEventListener('click', () => removeButtonAction(button, i));
|
||
|
||
controls.appendChild(up);
|
||
controls.appendChild(down);
|
||
controls.appendChild(remove);
|
||
li.appendChild(span);
|
||
li.appendChild(controls);
|
||
list.appendChild(li);
|
||
});
|
||
}
|
||
|
||
function populateActionSelect(button) {
|
||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
||
const actionSel = document.getElementById(`button-add-action-${button}`);
|
||
actionSel.innerHTML = '';
|
||
const w = buttonsData.widgets.find((w) => String(w.id) === widgetSel.value);
|
||
if (!w) return;
|
||
w.actions.forEach((a) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = a.action;
|
||
opt.textContent = a.label;
|
||
actionSel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function populateWidgetSelect(button) {
|
||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
||
widgetSel.innerHTML = '';
|
||
buttonsData.widgets.forEach((w) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = w.id;
|
||
opt.textContent = widgetNames[w.id] || WIDGET_LABELS[w.widget_type] || w.widget_type;
|
||
widgetSel.appendChild(opt);
|
||
});
|
||
populateActionSelect(button);
|
||
}
|
||
|
||
async function saveButtonActions(button) {
|
||
try {
|
||
const resp = await fetch(`${window.FRAME_BASE_API}/buttons/${button}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
actions: buttonsData[button].map((r) => ({ widget_id: r.widget_id, action: r.action })),
|
||
}),
|
||
});
|
||
if (!resp.ok) throw new Error(await apiError(resp));
|
||
showStatus(true, 'Button assignments saved.');
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
await loadButtons(); // resync with server truth rather than leave a stale edit on screen
|
||
}
|
||
}
|
||
|
||
function moveButtonAction(button, index, delta) {
|
||
const rows = buttonsData[button];
|
||
const target = index + delta;
|
||
if (target < 0 || target >= rows.length) return;
|
||
[rows[index], rows[target]] = [rows[target], rows[index]];
|
||
renderButtonList(button);
|
||
saveButtonActions(button);
|
||
}
|
||
|
||
function removeButtonAction(button, index) {
|
||
buttonsData[button].splice(index, 1);
|
||
renderButtonList(button);
|
||
saveButtonActions(button);
|
||
}
|
||
|
||
function addButtonAction(button) {
|
||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
||
const actionSel = document.getElementById(`button-add-action-${button}`);
|
||
if (!widgetSel.value || !actionSel.value) return;
|
||
buttonsData[button].push({ widget_id: Number(widgetSel.value), action: actionSel.value });
|
||
renderButtonList(button);
|
||
saveButtonActions(button);
|
||
}
|
||
|
||
async function loadButtons() {
|
||
try {
|
||
const resp = await fetch(`${window.FRAME_BASE_API}/buttons`);
|
||
if (!resp.ok) throw new Error(await apiError(resp));
|
||
buttonsData = await resp.json();
|
||
widgetNames = buildWidgetNames(buttonsData.widgets, buttonsData.grid);
|
||
document.getElementById('button-assign-groups').style.display =
|
||
buttonsData.widgets.length ? '' : 'none';
|
||
document.getElementById('button-assign-empty-hint').style.display =
|
||
buttonsData.widgets.length ? 'none' : '';
|
||
BUTTONS.forEach((button) => {
|
||
renderButtonList(button);
|
||
populateWidgetSelect(button);
|
||
});
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
}
|
||
|
||
BUTTONS.forEach((button) => {
|
||
document.getElementById(`button-add-widget-${button}`)
|
||
.addEventListener('change', () => populateActionSelect(button));
|
||
document.getElementById(`button-add-${button}`)
|
||
.addEventListener('click', () => addButtonAction(button));
|
||
});
|
||
|
||
loadButtons();
|