Shows Frame.battery_percent/battery_as_of, already set by every device wake-on-battery report, plus routers/common.py's existing battery_estimate_s time-remaining estimate -- nothing new to fetch or cache. Compact (icon + percent) or detailed (+ estimate, last report age) display mode. No button actions.
349 lines
14 KiB
JavaScript
349 lines
14 KiB
JavaScript
// 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, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog,
|
||
battery: initBatteryDialog,
|
||
};
|
||
const DIALOG_CLOSE = {
|
||
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
||
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog,
|
||
battery: closeBatteryDialog,
|
||
};
|
||
|
||
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();
|