Files
espresso_frame/server/app/static/frame_layout.js
T
Thomas Faour b5c52004c8
Build and push server image / test (push) Successful in 24s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s
Split the tasks feature out of the calendar widget into its own widget type
Task lists used to be a week-view-only sub-feature bolted onto calendar
widgets (CalendarWidgetConfig.tasks_*), so a task list could only exist
tied to a calendar's view and only inside its footprint. Tasks are now
a standalone widget type (TaskWidgetConfig, app/widgets/tasks.py) that
can be placed and sized independently, same as photos/calendar/
whiteboard -- no separate "enabled" flag either, since being on the
grid at all is the on/off switch, matching every other widget type.

Migration 17 creates task_widget_configs, extracts any existing
calendar widget's configured task source into a new sibling tasks
widget (auto-placed in open grid space, source dropped+logged if truly
none is left), then drops calendar_widget_configs' now-dead tasks_*
columns in the same migration -- this project's usual same-migration-
drop convention. Also handles the rarer case of a database jumping
straight from before the widget system existed to after this split in
one boot, via the legacy Frame.calendar_tasks_* columns.

Verified live in the browser at desktop and mobile widths: adding a
Tasks widget, its own dialog (task-list source picker + preview), and
confirming the calendar widget's dialog no longer mentions tasks at
all. Full test suite (180 tests, including new coverage for the widget
render/actions, the migration's data-extraction path, and the
permission-boundary shape for tasks-source) passes.
2026-07-25 02:11:45 +00:00

347 lines
13 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
// WIDGET_LABELS comes from common.js (shared with frame_config.js's
// button-assignment UI).
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 clearAllWidgets() {
const count = gridState ? gridState.widgets.length : 0;
if (!count) return;
const noun = count === 1 ? 'widget' : 'widgets';
if (!confirm(`Remove all ${count} ${noun} from this frame and start over? Their settings will be lost.`)) return;
try {
const resp = await fetch(`${window.FRAME_API}/widgets`, { method: 'DELETE' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Cleared.');
} catch (e) {
showStatus(false, e.message);
} finally {
loadWidgets();
}
}
document.getElementById('clear-all-widgets').addEventListener('click', clearAllWidgets);
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' : '';
document.getElementById('clear-all-widgets').disabled = !gridState.widgets.length;
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,tasks}.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,
tasks: initTasksDialog,
};
const DIALOG_CLOSE = {
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
tasks: closeTasksDialog,
};
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();