Files
espresso_frame/server/app/static/frame_layout.js
T
tfaour 569bf733e9
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 2m0s
Build and push server image / deploy (push) Successful in 54s
Show save/error status inside the open widget dialog, not behind it
showStatus() always wrote to the page-level #result div, which sits
behind a <dialog>'s backdrop -- a save inside a widget's gear-icon
dialog produced a message the user couldn't see without closing the
dialog first. It now prefers a .dialog-result element inside whichever
<dialog> is currently open, falling back to #result everywhere else.

The dialog is a flex column with its body scrolling independently so
.dialog-result stays pinned as a visible footer regardless of scroll
position -- otherwise a save message on a long form (e.g. the calendar
dialog) could land off-screen below the fold with no visible feedback
at all.
2026-07-24 14:48:50 -04:00

322 lines
12 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.
//
// Widget/canvas geometry is computed and applied in *pixels* from JS,
// not CSS percentages/aspect-ratio -- aspect-ratio isn't supported on
// every mobile browser this app gets viewed from, and a percentage
// height on the widget boxes silently collapses to 0 against an
// indeterminate-height ancestor on those browsers (the canvas would
// render with no visible size at all, which is exactly what happened
// before this was pixel-based).
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);
// Cached each time the canvas is (re)laid out (see layoutCanvas) so drag
// math doesn't re-measure the DOM on every pointermove.
let canvasMetrics = { width: 0, height: 0, cellW: 0, cellH: 0 };
function layoutCanvas() {
if (!gridState) return;
const wrap = document.getElementById('widget-canvas-wrap');
const canvas = document.getElementById('widget-canvas');
const cols = gridState.grid.cols, rows = gridState.grid.rows;
// wrap's own width comes from ordinary CSS (100% of the card, capped
// at max-width) -- only its height is JS-driven, from that measured
// width, to keep the grid's aspect ratio without relying on the CSS
// aspect-ratio property.
const width = wrap.getBoundingClientRect().width;
const height = width * (rows / cols);
wrap.style.height = height + 'px';
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
const cellW = width / cols, cellH = height / rows;
canvasMetrics = { width, height, cellW, cellH };
wrap.style.backgroundImage =
`linear-gradient(to right, var(--border) 1px, transparent 1px),` +
`linear-gradient(to bottom, var(--border) 1px, transparent 1px)`;
wrap.style.backgroundSize = `${cellW}px ${cellH}px`;
for (const box of canvas.children) {
positionBox(box, box._rect);
}
}
function positionBox(box, rect) {
box._rect = rect;
const { cellW, cellH } = canvasMetrics;
box.style.left = (rect.x * cellW) + 'px';
box.style.top = (rect.y * cellH) + 'px';
box.style.width = (rect.w * cellW) + 'px';
box.style.height = (rect.h * cellH) + 'px';
}
function startDrag(e, widget, box, isResize) {
e.preventDefault();
box.setPointerCapture(e.pointerId);
const { cellW, cellH } = canvasMetrics;
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');
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;
box._rect = 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 settingsBtn = document.createElement('button');
settingsBtn.type = 'button';
settingsBtn.className = 'widget-box-settings';
settingsBtn.textContent = '⚙';
settingsBtn.title = `${WIDGET_LABELS[widget.widget_type] || widget.widget_type} settings`;
settingsBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); openWidgetDialog(widget); });
box.appendChild(settingsBtn);
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);
}
layoutCanvas();
}
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);
}
}
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(layoutCanvas, 100);
});
// --- gear-icon dialog: each widget's own settings, fetched as an HTML
// fragment (routers/frame_pages.py's widget_dialog) and injected into a
// single shared <dialog>, rather than a separate page per widget type --
// a frame can now have several widgets of the same type, so "the
// Calendar tab" stopped meaning anything unambiguous.
// widget_dialog_{photos,calendar,whiteboard}.js each define an
// init<Type>Dialog()/close<Type>Dialog() pair (loaded unconditionally by
// frame_layout.html, since which one runs depends on which widget's gear
// icon was clicked).
const DIALOG_INIT = { photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog };
const DIALOG_CLOSE = { photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog };
let openDialogWidgetType = null;
async function openWidgetDialog(widget) {
const dialogEl = document.getElementById('widget-dialog');
const bodyEl = document.getElementById('widget-dialog-body');
bodyEl.innerHTML = '<p class="sub">Loading...</p>';
dialogEl.querySelector('.dialog-result').innerHTML = ''; // clear any message left over from a previous dialog
openDialogWidgetType = widget.widget_type;
dialogEl.showModal();
try {
const resp = await fetch(`/frames/${window.FRAME_ID}/widgets/${widget.id}/dialog`);
if (!resp.ok) throw new Error(await apiError(resp));
bodyEl.innerHTML = await resp.text();
// Every dialog script's fetch calls use window.FRAME_API as their
// base -- repointing it at this specific widget (instead of the
// frame-level window.FRAME_BASE_API) is what makes the SAME
// widget_dialog_photos.js/queue.js/etc. code work correctly no
// matter which widget's dialog is currently open. Restored on close.
window.FRAME_API = `${window.FRAME_BASE_API}/widgets/${widget.id}`;
const init = DIALOG_INIT[widget.widget_type];
if (init) init();
} catch (e) {
bodyEl.innerHTML = `<p class="sub">Could not load: ${e.message}</p>`;
}
}
document.getElementById('widget-dialog-close').addEventListener('click', () => {
document.getElementById('widget-dialog').close();
});
// Native <dialog> doesn't close on backdrop click by default -- a click
// that lands outside the dialog's own box (but is still technically
// "on" the dialog element, since the backdrop is part of it) counts as
// a backdrop click.
document.getElementById('widget-dialog').addEventListener('click', (e) => {
const dialogEl = e.currentTarget;
if (e.target !== dialogEl) return; // click landed on dialog content, not the backdrop
const rect = dialogEl.getBoundingClientRect();
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
if (!inside) dialogEl.close();
});
document.getElementById('widget-dialog').addEventListener('close', () => {
const close = DIALOG_CLOSE[openDialogWidgetType];
if (close) close();
openDialogWidgetType = null;
window.FRAME_API = window.FRAME_BASE_API;
document.getElementById('widget-dialog-body').innerHTML = '';
});
loadWidgets();