From 5d4bb53b8ac5c509a53f53320c65f16de2487844 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Fri, 24 Jul 2026 12:48:56 -0400 Subject: [PATCH] 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. --- server/app/grid.py | 15 ++ server/app/main.py | 6 +- server/app/routers/api_frames.py | 49 +++++- server/app/routers/api_widgets.py | 142 ++++++++++++++++ server/app/routers/frame_pages.py | 5 + server/app/static/frame_config.js | 21 +++ server/app/static/frame_layout.js | 209 +++++++++++++++++++++++ server/app/static/theme.css | 65 +++++++ server/app/templates/_frame_tabs.html | 1 + server/app/templates/frame_layout.html | 46 +++++ server/tests/test_widget_placement.py | 226 +++++++++++++++++++++++++ 11 files changed, 780 insertions(+), 5 deletions(-) create mode 100644 server/app/routers/api_widgets.py create mode 100644 server/app/static/frame_layout.js create mode 100644 server/app/templates/frame_layout.html create mode 100644 server/tests/test_widget_placement.py diff --git a/server/app/grid.py b/server/app/grid.py index 60dca69..6fc2e7c 100644 --- a/server/app/grid.py +++ b/server/app/grid.py @@ -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 diff --git a/server/app/main.py b/server/app/main.py index 5e00bd3..be94d9e 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -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) diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index 69b4bdb..e60c092 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -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): diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py new file mode 100644 index 0000000..0b1733a --- /dev/null +++ b/server/app/routers/api_widgets.py @@ -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"} diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index 3039453..3312c86 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -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 diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js index 783fbf1..8e9b390 100644 --- a/server/app/static/frame_config.js +++ b/server/app/static/frame_config.js @@ -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); diff --git a/server/app/static/frame_layout.js b/server/app/static/frame_layout.js new file mode 100644 index 0000000..912425a --- /dev/null +++ b/server/app/static/frame_layout.js @@ -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(); diff --git a/server/app/static/theme.css b/server/app/static/theme.css index 3d521bb..657db13 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -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); diff --git a/server/app/templates/_frame_tabs.html b/server/app/templates/_frame_tabs.html index bd949e0..feaa5ea 100644 --- a/server/app/templates/_frame_tabs.html +++ b/server/app/templates/_frame_tabs.html @@ -1,4 +1,5 @@