Widget system Phase 4a: widget CRUD + grid placement UI
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m32s

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.
This commit is contained in:
2026-07-24 12:48:56 -04:00
parent 99069ba5fe
commit 5d4bb53b8a
11 changed files with 780 additions and 5 deletions
+15
View File
@@ -65,6 +65,21 @@ def overlaps(a: Rect, b: Rect) -> bool:
return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah
def find_open_rect(orientation: str, existing: list[Rect], w: int, h: int) -> Rect | None:
"""First w x h rect that's in-bounds and doesn't overlap any of
`existing`, scanning row-major (top-left first) -- used when creating
a widget without an explicit placement (see routers/api_widgets.py),
so adding one from a type picker doesn't require the caller to find
empty space itself first. None if no such rect fits anywhere."""
cols, rows = grid_dims(orientation)
for y in range(rows - h + 1):
for x in range(cols - w + 1):
candidate = (x, y, w, h)
if not any(overlaps(candidate, other) for other in existing):
return candidate
return None
def cell_to_pixels(orientation: str, panel_w: int, panel_h: int, rect: Rect) -> tuple[int, int, int, int]:
"""Grid rect -> pixel rect in logical (pre-rotation) canvas space --
against image_pipeline.logical_render_size(orientation)'s own
+4 -2
View File
@@ -4,7 +4,8 @@ for the panel, and serves ESP32 frames ready-to-display images.
This module is assembly only -- routes live in app/routers/:
device.py the firmware-facing /frame/* protocol (paths frozen)
api_frames.py the web UI's JSON API, /api/frames/{id}/...
frame_pages.py the per-frame Photos/Configuration/Stats pages
api_widgets.py widget CRUD + grid placement, /api/frames/{id}/widgets
frame_pages.py the per-frame Photos/Configuration/Layout/Stats pages
pages.py setup/login/claim/settings/admin
manage.py the limited manage-QR surface (/m/, /api/m/)
Storage is SQLite via models.py/db.py; migration.py imports a
@@ -30,7 +31,7 @@ from .auth import (
)
from .db import SessionLocal
from .models import Frame
from .routers import api_frames, device, frame_pages, manage, pages
from .routers import api_frames, api_widgets, device, frame_pages, manage, pages
from .routers.common import shell_context
logger = logging.getLogger(__name__)
@@ -45,6 +46,7 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(device.router)
app.include_router(api_frames.router)
app.include_router(api_widgets.router)
app.include_router(frame_pages.router)
app.include_router(pages.router)
app.include_router(manage.router)
+46 -3
View File
@@ -25,7 +25,7 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import calendar_render, gitea_releases, photo_queue, quiet_hours, weather, webdav_client
from .. import calendar_render, gitea_releases, grid, photo_queue, quiet_hours, weather, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import (
@@ -36,7 +36,15 @@ from ..image_pipeline import (
render_preview_png,
)
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, WhiteboardWidgetConfig
from ..models import (
BatteryLog,
CalendarWidgetConfig,
Frame,
FrameCalendar,
PhotoWidgetConfig,
WhiteboardWidgetConfig,
Widget,
)
from .common import (
OVERDUE_FACTOR,
battery_estimate_s,
@@ -67,6 +75,32 @@ MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
cols x rows (see grid.grid_dims) -- landscape and portrait use a
transposed grid (8x5 vs 5x8), so an existing placement is often
literally out of bounds on the new grid, not just visually wrong.
There's no sensible coordinate remap between two differently-shaped
grids, so instead: keep whichever widget was first by placement
order, resized to fill the new full panel, and delete the rest --
cascading to their own config rows and any FrameButtonAction
bindings via ondelete="CASCADE" (see models.py). The frontend is
expected to confirm this with the user before submitting an
orientation change (see frame_config.js) -- this always executes
unconditionally once called, same posture as every other
confirm-on-the-client / act-unconditionally-on-the-server action in
this codebase."""
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame_id).order_by(Widget.sort_order)
).all()
if not widgets:
return
keep, *rest = widgets
for widget in rest:
db.delete(widget)
keep.x, keep.y, keep.w, keep.h = grid.full_panel_rect(new_orientation)
@router.get("/api/frames/{frame_id}/albums")
def api_albums(frame: Frame = Depends(require_frame_view)):
url, key = immich_creds(frame)
@@ -124,6 +158,12 @@ def api_config_save(
harmless no-ops if an old cached page still POSTs them -- FastAPI
silently ignores form fields with no matching parameter.
An actual orientation *change* resets the frame's widget layout (see
_reset_widget_layout_for_new_orientation) -- widget placement is
grid-cell-relative to the panel's long/short axis, which swaps on a
landscape<->portrait change, so an old placement is usually not just
visually wrong but literally out of bounds on the new grid.
Until the widget-placement UI (a later phase) lets a frame have more
than one widget of a type, "the photo widget" / "the calendar
widget" below unambiguously means the frame's single auto-migrated
@@ -138,7 +178,10 @@ def api_config_save(
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
)
if orientation is not None:
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
new_orientation = orientation if orientation in ORIENTATIONS else "landscape"
if new_orientation != cfg.orientation:
_reset_widget_layout_for_new_orientation(db, cfg.id, new_orientation)
cfg.orientation = new_orientation
if quiet_hours_enabled is not None:
cfg.quiet_hours_enabled = quiet_hours_enabled
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
+142
View File
@@ -0,0 +1,142 @@
"""CRUD + grid placement for a frame's widgets (see models.Widget) --
backs the Layout tab's placement canvas (static/frame_widget_canvas.js).
Every mutation re-validates bounds/minimum footprint/no-overlap
server-side regardless of what the client already checked -- the
client's own checks are UX, not the source of truth (this project's
usual posture, e.g. api_frames.py's own field clamps)."""
from __future__ import annotations
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import grid
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..models import Frame, WIDGET_CONFIG_MODELS, Widget
from ..widgets import WIDGET_TYPES
router = APIRouter()
def _widget_dict(w: Widget) -> dict:
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
"sort_order": w.sort_order}
@router.get("/api/frames/{frame_id}/widgets")
def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
user = require_user_api(request, db)
cols, rows = grid.grid_dims(frame.orientation)
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
return {
"orientation": frame.orientation,
"grid": {"cols": cols, "rows": rows},
"widget_types": list(WIDGET_TYPES.keys()),
"min_footprint": grid.MIN_FOOTPRINT,
"widgets": [_widget_dict(w) for w in widgets],
"control": {
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
"you": frame.controlled_by_user_id == user.id,
},
}
def _other_rects(db: Session, frame_id: int, exclude_widget_id: int | None) -> list[grid.Rect]:
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame_id)).all()
return [(w.x, w.y, w.w, w.h) for w in widgets if w.id != exclude_widget_id]
def _validate_placement(db: Session, frame: Frame, widget_type: str, rect: grid.Rect,
exclude_widget_id: int | None = None) -> None:
if not grid.in_bounds(frame.orientation, rect):
raise HTTPException(400, "Placement is out of bounds for this frame's grid")
if not grid.meets_minimum(widget_type, rect):
min_w, min_h = grid.MIN_FOOTPRINT.get(widget_type, (1, 1))
raise HTTPException(400, f"A {widget_type} widget needs at least {min_w}x{min_h} grid cells")
for other_rect in _other_rects(db, frame.id, exclude_widget_id):
if grid.overlaps(rect, other_rect):
raise HTTPException(400, "Overlaps another widget")
class WidgetCreateRequest(BaseModel):
widget_type: str
x: int | None = None
y: int | None = None
w: int | None = None
h: int | None = None
@router.post("/api/frames/{frame_id}/widgets")
def api_widget_create(
body: WidgetCreateRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
):
if body.widget_type not in WIDGET_TYPES:
raise HTTPException(400, f"Unknown widget type: {body.widget_type}")
min_w, min_h = grid.MIN_FOOTPRINT.get(body.widget_type, (1, 1))
w, h = body.w or min_w, body.h or min_h
if body.x is None or body.y is None:
rect = grid.find_open_rect(frame.orientation, _other_rects(db, frame.id, None), w, h)
if rect is None:
raise HTTPException(400, "No open space left for a widget this size")
else:
rect = (body.x, body.y, w, h)
_validate_placement(db, frame, body.widget_type, rect)
with frame_locked(db, frame.id):
max_sort = db.scalar(select(func.max(Widget.sort_order)).where(Widget.frame_id == frame.id))
x, y, w, h = rect
widget = Widget(frame_id=frame.id, widget_type=body.widget_type, x=x, y=y, w=w, h=h,
sort_order=(max_sort or 0) + 1, created_at=time.time())
db.add(widget)
db.flush()
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id))
db.commit()
return _widget_dict(widget)
class WidgetPlacementRequest(BaseModel):
x: int
y: int
w: int
h: int
@router.patch("/api/frames/{frame_id}/widgets/{widget_id}")
def api_widget_move(
widget_id: int, body: WidgetPlacementRequest,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
rect = (body.x, body.y, body.w, body.h)
_validate_placement(db, frame, widget.widget_type, rect, exclude_widget_id=widget.id)
with frame_locked(db, frame.id):
widget.x, widget.y, widget.w, widget.h = body.x, body.y, body.w, body.h
db.commit()
return _widget_dict(widget)
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
def api_widget_delete(
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
):
"""Cascades to the widget's own config row and any FrameButtonAction
bindings that pointed at it (both ondelete="CASCADE" FKs, see
models.py) -- nothing left pointing at a widget id that no longer
exists."""
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
with frame_locked(db, frame.id):
db.delete(widget)
db.commit()
return {"status": "deleted"}
+5
View File
@@ -60,6 +60,11 @@ def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get
)
@router.get("/frames/{frame_id}/layout", response_class=HTMLResponse)
def frame_layout_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_layout.html", "layout")
def _user_available_calendars(user: User) -> list[dict]:
"""This user's full set of calendars available to add to any frame:
the single ICS subscription (if set) plus every CalDAV calendar last
+21
View File
@@ -27,10 +27,31 @@ async function saveConfig() {
}
}
// Orientation swaps the widget grid's long/short axis (see
// grid.grid_dims), so an existing widget layout is usually left with
// out-of-bounds coordinates on the new grid -- the server resets it to
// one full-panel widget when this actually changes (see
// api_frames.py's api_config_save). Warn before that happens rather
// than silently losing whatever layout was on the Layout tab.
let lastSavedOrientation = document.getElementById('orientation').value;
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const newOrientation = document.getElementById('orientation').value;
if (newOrientation !== lastSavedOrientation) {
const proceed = confirm(
"Changing orientation resets this frame's widget layout to a single " +
'full-panel widget -- any other widgets placed on the Layout tab will ' +
'be removed. Continue?'
);
if (!proceed) {
document.getElementById('orientation').value = lastSavedOrientation;
return;
}
}
try {
await saveConfig();
lastSavedOrientation = newOrientation;
showStatus(true, 'Saved.');
} catch (e) {
showStatus(false, e.message);
+209
View File
@@ -0,0 +1,209 @@
// 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();
+65
View File
@@ -322,6 +322,71 @@ button.secondary:hover { background: var(--surface-alt); }
}
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
#widget-canvas-wrap {
--grid-cols: 8;
--grid-rows: 5;
width: 100%;
max-width: 640px;
aspect-ratio: var(--grid-cols) / var(--grid-rows);
border: 1px solid var(--border);
border-radius: 10px;
background-color: var(--surface-alt);
background-image:
linear-gradient(to right, var(--border) 1px, transparent 1px),
linear-gradient(to bottom, var(--border) 1px, transparent 1px);
background-size: calc(100% / var(--grid-cols)) calc(100% / var(--grid-rows));
}
#widget-canvas { position: relative; width: 100%; height: 100%; }
.widget-box {
position: absolute;
box-sizing: border-box;
border: 2px solid var(--accent);
background: color-mix(in srgb, var(--accent) 14%, var(--surface));
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
cursor: grab;
user-select: none;
overflow: hidden;
}
.widget-box.dragging { cursor: grabbing; box-shadow: var(--shadow-hover); z-index: 2; }
.widget-box-label {
font-size: 13px;
font-weight: 600;
color: var(--text);
pointer-events: none;
}
.widget-box-remove {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
padding: 0;
margin: 0;
line-height: 1;
border-radius: 50%;
border: none;
background: var(--overlay);
color: #fff;
cursor: pointer;
}
.widget-box-remove:hover { background: var(--overlay-hover); }
.widget-box-resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
touch-action: none;
border-right: 3px solid var(--accent);
border-bottom: 3px solid var(--accent);
border-bottom-right-radius: 4px;
}
code {
background: var(--surface-alt);
color: var(--text);
+1
View File
@@ -1,4 +1,5 @@
<nav class="tabs">
<a href="/frames/{{ frame.id }}/layout" class="{% if active_tab == 'layout' %}active{% endif %}">Layout</a>
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a>
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
<a href="/frames/{{ frame.id }}/calendar"
+46
View File
@@ -0,0 +1,46 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Layout{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div id="control-banner" class="control-banner" style="display: none;">
<span id="control-holder"></span>
<button type="button" id="take-control" class="btn-inline">Take control</button>
</div>
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Widgets</h2>
<p class="sub">Drag a widget to move it, drag its bottom-right corner
to resize it -- like arranging widgets on a phone's home screen.
Widgets can't overlap. Each widget's own settings (which album,
which calendars, etc.) live on its type's own tab.</p>
<div id="widget-canvas-wrap">
<div id="widget-canvas"></div>
</div>
<p class="sub" id="widget-canvas-empty-hint" style="display: none; margin-top: 10px;">
Nothing placed yet -- add a widget below.</p>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Add a widget</h2>
<div id="add-widget-buttons" class="checkbox-row" style="gap: 10px; flex-wrap: wrap;"></div>
<p class="sub" id="add-widget-hint" style="margin-top: 8px;"></p>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_header.js"></script>
<script src="/static/frame_layout.js"></script>
{% endblock %}