Files
espresso_frame/server/app/static/frame_layout.js
T
tfaour 5d4bb53b8a
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m32s
Widget system Phase 4a: widget CRUD + grid placement UI
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.
2026-07-24 12:48:56 -04:00

210 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();