Mark whiteboard as (alpha); add the Phase 5 button-assignment UI
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 52s

Whiteboard rendering isn't fully reliable yet -- tag it (alpha)
everywhere it's user-facing (widget label, add-widget button, dialog
title, Settings' WebDAV section) via one shared WIDGET_LABELS map
(moved to common.js so both the Layout canvas and the new Configuration
tab section can use it).

Button assignments: a new "Button assignments" card on the
Configuration tab lets you assign an ordered list of (widget, action)
bindings to each physical NEXT/BACK button -- add/remove/reorder, all
autosaved. Backed by new GET/PUT /api/frames/{id}/buttons endpoints;
PUT replaces a button's whole list in one atomic, fully-validated call
rather than separate add/remove/reorder endpoints. Device-side
consumption already existed (routers/device.py's _run_button_actions);
this is the UI for what was previously only reachable via the default
migration mapping.
This commit is contained in:
2026-07-24 17:13:51 -04:00
parent 9c8a87e90d
commit 8b9f636cce
9 changed files with 454 additions and 5 deletions
+162
View File
@@ -362,3 +362,165 @@ 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, actions: [{action, label}]}], 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;
const BUTTONS = ['next', 'back'];
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 `${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 wont 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 = 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();
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();