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.
210 lines
7.5 KiB
JavaScript
210 lines
7.5 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.
|
||
|
||
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();
|