Widget system Phase 4a: widget CRUD + grid placement UI
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,226 @@
|
||||
"""routers/api_widgets.py -- widget CRUD + grid placement. Bounds/
|
||||
minimum-footprint/no-overlap validation is server-side and re-checked
|
||||
regardless of what a client already believes is a valid placement (see
|
||||
the module's own docstring), so this is exercised at the HTTP layer, not
|
||||
just against grid.py's pure functions directly (those have no dedicated
|
||||
coverage of their own -- this file and test_migrations.py's backfill
|
||||
tests are what actually exercise them end to end).
|
||||
|
||||
Frame #1's auto-migrated widget (see migration.py's backfill) is a
|
||||
single full-panel (0, 0, 8, 5) photos widget -- every test here starts
|
||||
by shrinking or removing it to free up room, mirroring what a real user
|
||||
would do on the placement canvas before adding a second widget."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models import Frame, FrameButtonAction, Widget
|
||||
|
||||
from .conftest import csrf_headers
|
||||
|
||||
|
||||
def _widget_id(db_session, widget_type="photos") -> int:
|
||||
return db_session.query(Widget).filter_by(frame_id=1, widget_type=widget_type).one().id
|
||||
|
||||
|
||||
def _shrink_default_widget(client, db_session, w=4, h=5) -> int:
|
||||
"""Frees up the right-hand side of the grid for a second widget."""
|
||||
widget_id = _widget_id(db_session)
|
||||
resp = client.patch(f"/api/frames/1/widgets/{widget_id}",
|
||||
json={"x": 0, "y": 0, "w": w, "h": h}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
return widget_id
|
||||
|
||||
|
||||
def test_list_widgets_returns_grid_and_the_default_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
resp = client.get("/api/frames/1/widgets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["orientation"] == "landscape"
|
||||
assert data["grid"] == {"cols": 8, "rows": 5}
|
||||
assert len(data["widgets"]) == 1
|
||||
assert data["widgets"][0]["widget_type"] == "photos"
|
||||
assert data["widgets"][0]["w"] == 8 and data["widgets"][0]["h"] == 5
|
||||
|
||||
|
||||
def test_create_with_no_room_left_is_rejected(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
resp = client.post("/api/frames/1/widgets", json={"widget_type": "photos"}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
assert "no open space" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_create_auto_places_in_freed_up_space(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
_shrink_default_widget(client, db_session, w=4, h=5)
|
||||
|
||||
resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
created = resp.json()
|
||||
assert created["widget_type"] == "whiteboard"
|
||||
assert created["x"] >= 4 # lands in the freed right-hand region, not overlapping the shrunk photos widget
|
||||
assert (created["w"], created["h"]) == (2, 2) # grid.MIN_FOOTPRINT["whiteboard"]
|
||||
|
||||
widget = db_session.get(Widget, created["id"])
|
||||
assert widget is not None and widget.frame_id == 1
|
||||
# A default config row was created alongside it (see WIDGET_CONFIG_MODELS).
|
||||
from app.models import WhiteboardWidgetConfig
|
||||
assert db_session.get(WhiteboardWidgetConfig, widget.id) is not None
|
||||
|
||||
|
||||
def test_create_with_explicit_placement_validates_bounds(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
_shrink_default_widget(client, db_session, w=4, h=5)
|
||||
|
||||
resp = client.post("/api/frames/1/widgets",
|
||||
json={"widget_type": "photos", "x": 6, "y": 0, "w": 4, "h": 2},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
assert "out of bounds" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_create_below_minimum_footprint_is_rejected(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
_shrink_default_widget(client, db_session, w=4, h=5)
|
||||
|
||||
resp = client.post("/api/frames/1/widgets",
|
||||
json={"widget_type": "calendar", "x": 4, "y": 0, "w": 2, "h": 1},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
assert "at least" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_create_overlapping_existing_widget_is_rejected(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
# Default widget still covers the full 8x5 grid -- any explicit placement overlaps it.
|
||||
resp = client.post("/api/frames/1/widgets",
|
||||
json={"widget_type": "whiteboard", "x": 0, "y": 0, "w": 2, "h": 2},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
assert "overlaps" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_create_unknown_widget_type_is_rejected(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
resp = client.post("/api/frames/1/widgets", json={"widget_type": "video"}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
assert "unknown widget type" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_move_widget_to_a_valid_rect(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _widget_id(db_session)
|
||||
resp = client.patch(f"/api/frames/1/widgets/{widget_id}",
|
||||
json={"x": 1, "y": 1, "w": 3, "h": 2}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
widget = db_session.get(Widget, widget_id)
|
||||
assert (widget.x, widget.y, widget.w, widget.h) == (1, 1, 3, 2)
|
||||
|
||||
|
||||
def test_move_rejects_overlap_with_another_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
photos_id = _shrink_default_widget(client, db_session, w=4, h=5)
|
||||
create_resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
|
||||
headers=csrf_headers(client))
|
||||
whiteboard_id = create_resp.json()["id"]
|
||||
|
||||
# Try to move photos back over the whiteboard widget's space.
|
||||
resp = client.patch(f"/api/frames/1/widgets/{photos_id}",
|
||||
json={"x": 0, "y": 0, "w": 8, "h": 5}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
assert "overlaps" in resp.json()["detail"].lower()
|
||||
# Original placement is untouched after the rejected move.
|
||||
widget = db_session.get(Widget, photos_id)
|
||||
assert (widget.x, widget.y, widget.w, widget.h) == (0, 0, 4, 5)
|
||||
assert whiteboard_id # sanity: the other widget really was created
|
||||
|
||||
|
||||
def test_move_unknown_widget_404s(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
resp = client.patch("/api/frames/1/widgets/999999",
|
||||
json={"x": 0, "y": 0, "w": 1, "h": 1}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_widget_and_cascades_button_actions(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _widget_id(db_session)
|
||||
db_session.add(FrameButtonAction(frame_id=1, button="next", widget_id=widget_id, action="advance", sort_order=0))
|
||||
db_session.commit()
|
||||
|
||||
resp = client.delete(f"/api/frames/1/widgets/{widget_id}", headers=csrf_headers(client))
|
||||
assert resp.status_code == 200
|
||||
|
||||
assert db_session.get(Widget, widget_id) is None
|
||||
remaining = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
|
||||
assert remaining == []
|
||||
|
||||
|
||||
def test_delete_unknown_widget_404s(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
resp = client.delete("/api/frames/1/widgets/999999", headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_widgets_scoped_to_their_own_frame(client, db_session):
|
||||
"""A widget id from a different frame must 404, not silently operate
|
||||
cross-frame -- same posture as photo_widget_config_or_404 and every
|
||||
other frame-scoped lookup in this codebase."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
other_frame = Frame(name="Second frame", manage_token="tok-2", device_id="dev-2", device_token="dtok-2")
|
||||
db_session.add(other_frame)
|
||||
db_session.commit()
|
||||
from app.models import Widget as W
|
||||
other_widget = W(frame_id=other_frame.id, widget_type="photos", x=0, y=0, w=8, h=5, sort_order=0,
|
||||
created_at=0)
|
||||
db_session.add(other_widget)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.patch(f"/api/frames/1/widgets/{other_widget.id}",
|
||||
json={"x": 0, "y": 0, "w": 1, "h": 1}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- orientation change resets the widget layout (grid.grid_dims transposes) ---
|
||||
|
||||
def test_orientation_change_resets_multiple_widgets_to_one_full_panel_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
photos_id = _shrink_default_widget(client, db_session, w=4, h=5)
|
||||
create_resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
|
||||
headers=csrf_headers(client))
|
||||
whiteboard_id = create_resp.json()["id"]
|
||||
assert db_session.query(Widget).filter_by(frame_id=1).count() == 2
|
||||
|
||||
resp = client.post("/api/frames/1/config", data={"orientation": "portrait"}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
remaining = db_session.query(Widget).filter_by(frame_id=1).all()
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0].id == photos_id # first by sort_order survives
|
||||
assert (remaining[0].x, remaining[0].y, remaining[0].w, remaining[0].h) == (0, 0, 5, 8) # full portrait panel
|
||||
assert db_session.get(Widget, whiteboard_id) is None
|
||||
|
||||
frame = db_session.get(Frame, 1)
|
||||
assert frame.orientation == "portrait"
|
||||
|
||||
|
||||
def test_orientation_unchanged_leaves_widget_layout_alone(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
_shrink_default_widget(client, db_session, w=4, h=5)
|
||||
|
||||
resp = client.post("/api/frames/1/config", data={"orientation": "landscape"}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
widget = db_session.query(Widget).filter_by(frame_id=1).one()
|
||||
assert (widget.x, widget.y, widget.w, widget.h) == (0, 0, 4, 5) # untouched -- orientation didn't actually change
|
||||
|
||||
|
||||
def test_orientation_change_with_no_widgets_is_a_no_op(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _widget_id(db_session)
|
||||
client.delete(f"/api/frames/1/widgets/{widget_id}", headers=csrf_headers(client))
|
||||
assert db_session.query(Widget).filter_by(frame_id=1).count() == 0
|
||||
|
||||
resp = client.post("/api/frames/1/config", data={"orientation": "portrait"}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert db_session.query(Widget).filter_by(frame_id=1).count() == 0
|
||||
Reference in New Issue
Block a user