Widget system Phase 4a: widget CRUD + grid placement UI
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m32s

Adds the actual "Android home screen" placement experience: a new
Layout tab with a pointer-driven canvas for dragging/resizing widgets
and adding new ones from a type picker. Backed by a new
routers/api_widgets.py (create/move/delete), which re-validates
bounds, minimum footprint, and no-overlap server-side regardless of
what the client already checked. A widget added without an explicit
position lands in the first open space that fits it
(grid.find_open_rect), so users don't have to hunt for empty space
themselves.

Also fixes a real latent bug this surfaced: changing a frame's
orientation swaps the widget grid's long/short axis, which left
existing widget placements out of bounds on the new grid with no
render-time safeguard. Orientation changes now reset the layout to a
single full-panel widget (keeping the first widget's type, dropping
the rest), with a client-side confirm before it happens.
This commit is contained in:
2026-07-24 12:48:56 -04:00
parent 99069ba5fe
commit 5d4bb53b8a
11 changed files with 780 additions and 5 deletions
+21
View File
@@ -27,10 +27,31 @@ async function saveConfig() {
}
}
// 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);
+209
View File
@@ -0,0 +1,209 @@
// Layout tab: drag/resize placement canvas for arranging widgets on the
// panel, like placing widgets on an Android home screen. Pointer events
// (not native HTML5 drag-and-drop, which has known touch
// inconsistencies) drive move/resize; every mutation is re-validated
// server-side (see routers/api_widgets.py) regardless of what this file
// already checked, so after any move/resize/add/remove this just
// reloads the canvas from the server's actual state rather than trusting
// an optimistic update -- simplest way to guarantee the canvas never
// drifts from what a rejected request left in place.
let gridState = null; // last-loaded GET .../widgets response
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard' };
function renderControlBanner(control) {
const banner = document.getElementById('control-banner');
if (!banner) return;
if (!control || control.you) {
banner.style.display = 'none';
return;
}
banner.style.display = 'flex';
document.getElementById('control-holder').textContent = control.controller
? `${control.controller} currently has control of this frame.`
: 'Nobody has control of this frame yet.';
}
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.');
loadWidgets();
} catch (e) {
showStatus(false, e.message);
}
}
document.getElementById('take-control').addEventListener('click', takeControl);
function cellSizePx() {
const rect = document.getElementById('widget-canvas').getBoundingClientRect();
return { cellW: rect.width / gridState.grid.cols, cellH: rect.height / gridState.grid.rows };
}
function positionBox(box, rect) {
const cols = gridState.grid.cols, rows = gridState.grid.rows;
box.style.left = (rect.x / cols * 100) + '%';
box.style.top = (rect.y / rows * 100) + '%';
box.style.width = (rect.w / cols * 100) + '%';
box.style.height = (rect.h / rows * 100) + '%';
}
function startDrag(e, widget, box, isResize) {
e.preventDefault();
box.setPointerCapture(e.pointerId);
const { cellW, cellH } = cellSizePx();
const startX = e.clientX, startY = e.clientY;
const orig = { x: widget.x, y: widget.y, w: widget.w, h: widget.h };
const cols = gridState.grid.cols, rows = gridState.grid.rows;
const minFootprint = gridState.min_footprint[widget.widget_type] || [1, 1];
let pending = null;
box.classList.add('dragging');
function onMove(ev) {
const dxCells = Math.round((ev.clientX - startX) / cellW);
const dyCells = Math.round((ev.clientY - startY) / cellH);
const next = { ...orig };
if (isResize) {
next.w = Math.max(minFootprint[0], Math.min(cols - orig.x, orig.w + dxCells));
next.h = Math.max(minFootprint[1], Math.min(rows - orig.y, orig.h + dyCells));
} else {
next.x = Math.max(0, Math.min(cols - orig.w, orig.x + dxCells));
next.y = Math.max(0, Math.min(rows - orig.h, orig.y + dyCells));
}
pending = next;
positionBox(box, next);
}
function onUp() {
box.removeEventListener('pointermove', onMove);
box.removeEventListener('pointerup', onUp);
box.classList.remove('dragging');
if (pending && (pending.x !== orig.x || pending.y !== orig.y || pending.w !== orig.w || pending.h !== orig.h)) {
moveWidget(widget.id, pending);
}
}
box.addEventListener('pointermove', onMove);
box.addEventListener('pointerup', onUp);
}
async function moveWidget(id, rect) {
try {
const resp = await fetch(`${window.FRAME_API}/widgets/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rect),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
} catch (e) {
showStatus(false, e.message);
} finally {
// Reload either way: reverts the box to its real position if the
// move was rejected (e.g. it would've overlapped another widget),
// confirms it otherwise. Simpler and more robust than trying to
// separately handle "revert on failure" vs. "confirm on success".
loadWidgets();
}
}
async function removeWidget(id) {
if (!confirm('Remove this widget? Its own settings will be lost.')) return;
try {
const resp = await fetch(`${window.FRAME_API}/widgets/${id}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Removed.');
} catch (e) {
showStatus(false, e.message);
} finally {
loadWidgets();
}
}
async function addWidget(widgetType) {
try {
const resp = await fetch(`${window.FRAME_API}/widgets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ widget_type: widgetType }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, `${WIDGET_LABELS[widgetType] || widgetType} widget added.`);
} catch (e) {
showStatus(false, e.message);
} finally {
loadWidgets();
}
}
function renderCanvas() {
const canvas = document.getElementById('widget-canvas');
const wrap = document.getElementById('widget-canvas-wrap');
wrap.style.setProperty('--grid-cols', gridState.grid.cols);
wrap.style.setProperty('--grid-rows', gridState.grid.rows);
canvas.innerHTML = '';
document.getElementById('widget-canvas-empty-hint').style.display = gridState.widgets.length ? 'none' : '';
for (const widget of gridState.widgets) {
const box = document.createElement('div');
box.className = 'widget-box';
box.dataset.widgetType = widget.widget_type;
positionBox(box, widget);
const label = document.createElement('span');
label.className = 'widget-box-label';
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
box.appendChild(label);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'widget-box-remove';
removeBtn.textContent = '×';
removeBtn.title = 'Remove this widget';
removeBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
removeBtn.addEventListener('click', (e) => { e.stopPropagation(); removeWidget(widget.id); });
box.appendChild(removeBtn);
const handle = document.createElement('div');
handle.className = 'widget-box-resize-handle';
handle.addEventListener('pointerdown', (e) => { e.stopPropagation(); startDrag(e, widget, box, true); });
box.appendChild(handle);
box.addEventListener('pointerdown', (e) => startDrag(e, widget, box, false));
canvas.appendChild(box);
}
}
function renderAddButtons() {
const container = document.getElementById('add-widget-buttons');
container.innerHTML = '';
for (const type of gridState.widget_types) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'secondary';
btn.textContent = `+ ${WIDGET_LABELS[type] || type}`;
btn.addEventListener('click', () => addWidget(type));
container.appendChild(btn);
}
const hint = document.getElementById('add-widget-hint');
hint.textContent = 'A new widget is placed in the first open space that fits it -- drag it afterward to reposition.';
}
async function loadWidgets() {
try {
const resp = await fetch(`${window.FRAME_API}/widgets`);
if (!resp.ok) throw new Error(await apiError(resp));
gridState = await resp.json();
renderCanvas();
renderAddButtons();
renderControlBanner(gridState.control);
} catch (e) {
showStatus(false, e.message);
}
}
loadWidgets();
+65
View File
@@ -322,6 +322,71 @@ button.secondary:hover { background: var(--surface-alt); }
}
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
#widget-canvas-wrap {
--grid-cols: 8;
--grid-rows: 5;
width: 100%;
max-width: 640px;
aspect-ratio: var(--grid-cols) / var(--grid-rows);
border: 1px solid var(--border);
border-radius: 10px;
background-color: var(--surface-alt);
background-image:
linear-gradient(to right, var(--border) 1px, transparent 1px),
linear-gradient(to bottom, var(--border) 1px, transparent 1px);
background-size: calc(100% / var(--grid-cols)) calc(100% / var(--grid-rows));
}
#widget-canvas { position: relative; width: 100%; height: 100%; }
.widget-box {
position: absolute;
box-sizing: border-box;
border: 2px solid var(--accent);
background: color-mix(in srgb, var(--accent) 14%, var(--surface));
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
cursor: grab;
user-select: none;
overflow: hidden;
}
.widget-box.dragging { cursor: grabbing; box-shadow: var(--shadow-hover); z-index: 2; }
.widget-box-label {
font-size: 13px;
font-weight: 600;
color: var(--text);
pointer-events: none;
}
.widget-box-remove {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
padding: 0;
margin: 0;
line-height: 1;
border-radius: 50%;
border: none;
background: var(--overlay);
color: #fff;
cursor: pointer;
}
.widget-box-remove:hover { background: var(--overlay-hover); }
.widget-box-resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
touch-action: none;
border-right: 3px solid var(--accent);
border-bottom: 3px solid var(--accent);
border-bottom-right-radius: 4px;
}
code {
background: var(--surface-alt);
color: var(--text);