Mark whiteboard as (alpha); add the Phase 5 button-assignment UI
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:
@@ -22,7 +22,8 @@ import time
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import gitea_releases, grid, quiet_hours
|
||||
@@ -30,7 +31,8 @@ from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||
from ..db import frame_locked, get_db
|
||||
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, Frame, Widget
|
||||
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
|
||||
from ..widgets import WIDGET_TYPES
|
||||
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
|
||||
from .device import render_frame_preview_png
|
||||
|
||||
@@ -249,6 +251,86 @@ def api_frame_preview(
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
BUTTONS = ("next", "back")
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/buttons")
|
||||
def api_buttons_get(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""Everything the button-assignment UI needs in one call: every
|
||||
widget on the frame with the actions its type supports (see
|
||||
app/widgets/*.py's ACTIONS/ACTION_LABELS), plus each button's current
|
||||
ordered list of (widget, action) bindings."""
|
||||
widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
widget_options = [
|
||||
{
|
||||
"id": w.id,
|
||||
"widget_type": w.widget_type,
|
||||
"actions": [
|
||||
{"action": action, "label": label}
|
||||
for action, label in getattr(WIDGET_TYPES.get(w.widget_type), "ACTION_LABELS", {}).items()
|
||||
],
|
||||
}
|
||||
for w in widgets
|
||||
]
|
||||
result = {"widgets": widget_options}
|
||||
for button in BUTTONS:
|
||||
rows = db.scalars(
|
||||
select(FrameButtonAction)
|
||||
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
|
||||
.order_by(FrameButtonAction.sort_order)
|
||||
).all()
|
||||
result[button] = [{"id": r.id, "widget_id": r.widget_id, "action": r.action} for r in rows]
|
||||
return result
|
||||
|
||||
|
||||
class ButtonActionItem(BaseModel):
|
||||
widget_id: int
|
||||
action: str
|
||||
|
||||
|
||||
class ButtonActionsRequest(BaseModel):
|
||||
actions: list[ButtonActionItem]
|
||||
|
||||
|
||||
@router.put("/api/frames/{frame_id}/buttons/{button}")
|
||||
def api_buttons_save(
|
||||
button: str, body: ButtonActionsRequest,
|
||||
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
||||
):
|
||||
"""Replaces the whole ordered action list for one button in a single
|
||||
call -- simpler and more atomic than separate add/remove/reorder
|
||||
endpoints for what's normally a list of one to a handful of entries,
|
||||
and the UI always has the full list in hand anyway (see
|
||||
static/frame_config.js)."""
|
||||
if button not in BUTTONS:
|
||||
raise HTTPException(404, "No such button")
|
||||
widgets_by_id = {w.id: w for w in db.scalars(select(Widget).where(Widget.frame_id == frame.id))}
|
||||
for item in body.actions:
|
||||
widget = widgets_by_id.get(item.widget_id)
|
||||
if widget is None:
|
||||
raise HTTPException(400, f"No such widget: {item.widget_id}")
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
if module is None or item.action not in module.ACTIONS:
|
||||
raise HTTPException(
|
||||
400, f"{widget.widget_type} widgets don't support the {item.action!r} action"
|
||||
)
|
||||
with frame_locked(db, frame.id):
|
||||
db.execute(
|
||||
delete(FrameButtonAction).where(
|
||||
FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button
|
||||
)
|
||||
)
|
||||
for i, item in enumerate(body.actions):
|
||||
db.add(FrameButtonAction(
|
||||
frame_id=frame.id, button=button, widget_id=item.widget_id, action=item.action,
|
||||
sort_order=i, created_at=time.time(),
|
||||
))
|
||||
db.commit()
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/battery-log")
|
||||
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
rows = db.execute(
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// Shared display names for widget_type, everywhere one shows up in the
|
||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)' };
|
||||
|
||||
function showStatus(ok, message) {
|
||||
// While a <dialog> is open, its own .dialog-result container gets the
|
||||
// message instead of the page-level #result -- otherwise it lands
|
||||
|
||||
@@ -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 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 = 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();
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
let gridState = null; // last-loaded GET .../widgets response
|
||||
|
||||
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard' };
|
||||
// WIDGET_LABELS comes from common.js (shared with frame_config.js's
|
||||
// button-assignment UI).
|
||||
|
||||
function renderControlBanner(control) {
|
||||
const banner = document.getElementById('control-banner');
|
||||
|
||||
@@ -257,6 +257,24 @@ input:focus, select:focus {
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.button-assign-label { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 8px; }
|
||||
.button-action-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.button-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
.button-action-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
||||
.button-action-controls .icon-btn { padding: 3px 6px; font-size: 13px; }
|
||||
.button-action-controls .icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.button-action-add { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.button-action-add select { width: auto; margin-top: 0; }
|
||||
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
||||
.checkbox-row input { width: auto; margin-top: 0; }
|
||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h2 class="dialog-title">Whiteboard widget</h2>
|
||||
<h2 class="dialog-title">Whiteboard widget (alpha)</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Whiteboard source</h2>
|
||||
|
||||
@@ -53,6 +53,39 @@
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Button assignments</h2>
|
||||
<p class="sub">What the frame's physical NEXT and BACK buttons do --
|
||||
assign one or more widget actions to each, in the order they
|
||||
should run. A button with several actions runs all of them, in
|
||||
order, then the panel redraws once.</p>
|
||||
<p class="sub" id="button-assign-empty-hint" style="display: none;">
|
||||
Add a widget on the Layout tab first -- there's nothing to assign
|
||||
a button to yet.</p>
|
||||
|
||||
<div id="button-assign-groups">
|
||||
<div class="button-assign-group">
|
||||
<h3 class="button-assign-label">NEXT button</h3>
|
||||
<ul class="button-action-list" id="button-actions-next"></ul>
|
||||
<div class="button-action-add">
|
||||
<select id="button-add-widget-next"></select>
|
||||
<select id="button-add-action-next"></select>
|
||||
<button type="button" class="secondary btn-inline" id="button-add-next">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-assign-group" style="margin-top: 20px;">
|
||||
<h3 class="button-assign-label">BACK button</h3>
|
||||
<ul class="button-action-list" id="button-actions-back"></ul>
|
||||
<div class="button-action-add">
|
||||
<select id="button-add-widget-back"></select>
|
||||
<select id="button-add-action-back"></select>
|
||||
<button type="button" class="secondary btn-inline" id="button-add-back">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
you're linked to from that frame's Calendar tab, so a frame only
|
||||
shows calendars people have actually chosen to share with it.</p>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Whiteboard (WebDAV)</h2>
|
||||
<h2 class="card-title" style="margin-top: 24px;">Whiteboard (WebDAV) (alpha)</h2>
|
||||
<p class="sub">Credentials for whiteboard frame mode -- fetching a
|
||||
specific file (e.g. a Nextcloud Whiteboard board) over WebDAV.
|
||||
Any WebDAV server works, not just Nextcloud.</p>
|
||||
|
||||
Reference in New Issue
Block a user