Add a "Clear all" button to the Layout tab's widget canvas
Build and push server image / test (push) Successful in 23s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 54s

One request (DELETE /api/frames/{id}/widgets) removes every widget on
the frame in a single locked transaction, cascading their configs and
button-action bindings the same way single-widget delete already does.
Gated behind confirm() like the existing per-widget remove button, and
disabled when there's nothing to clear. Covered by the same owner/
unrelated-user/linked-but-not-controlling permission shape used
elsewhere (test_widget_placement.py).

Also fixes a real, pre-existing mobile bug this surfaced: the Layout
page's .layout grid used a bare `1fr` track on the <860px breakpoint
instead of `minmax(0, 1fr)` like the desktop rule already does, so a
wide enough descendant (previously nothing hit this; the new title-row
button did) would force the whole page into horizontal scroll on phone
widths. Verified before/after with the run-server driver's new
`viewport` command.
This commit is contained in:
Thomas Faour
2026-07-25 01:32:25 +00:00
parent 0e35735a2a
commit 9f3f4b6f62
6 changed files with 110 additions and 3 deletions
+13
View File
@@ -222,6 +222,19 @@ def api_widget_delete(
return {"status": "deleted"}
@router.delete("/api/frames/{frame_id}/widgets")
def api_widgets_clear(frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)):
"""The Layout tab's "Clear all" button -- same per-widget cascade as
api_widget_delete, just every widget on this frame in one locked
transaction instead of one request per widget."""
with frame_locked(db, frame.id):
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
for widget in widgets:
db.delete(widget)
db.commit()
return {"status": "cleared", "count": len(widgets)}
# --- Per-widget-type config save (the gear-icon dialog's Save button) --------
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/config")
+18
View File
@@ -160,6 +160,23 @@ async function removeWidget(id) {
}
}
async function clearAllWidgets() {
const count = gridState ? gridState.widgets.length : 0;
if (!count) return;
const noun = count === 1 ? 'widget' : 'widgets';
if (!confirm(`Remove all ${count} ${noun} from this frame and start over? Their settings will be lost.`)) return;
try {
const resp = await fetch(`${window.FRAME_API}/widgets`, { method: 'DELETE' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Cleared.');
} catch (e) {
showStatus(false, e.message);
} finally {
loadWidgets();
}
}
document.getElementById('clear-all-widgets').addEventListener('click', clearAllWidgets);
async function addWidget(widgetType) {
try {
const resp = await fetch(`${window.FRAME_API}/widgets`, {
@@ -180,6 +197,7 @@ function renderCanvas() {
const canvas = document.getElementById('widget-canvas');
canvas.innerHTML = '';
document.getElementById('widget-canvas-empty-hint').style.display = gridState.widgets.length ? 'none' : '';
document.getElementById('clear-all-widgets').disabled = !gridState.widgets.length;
for (const widget of gridState.widgets) {
const box = document.createElement('div');
+9 -1
View File
@@ -167,6 +167,8 @@ h2.card-title, summary.card-title {
summary.card-title { cursor: pointer; margin-bottom: 0; }
details.card[open] summary.card-title { margin-bottom: 14px; }
details.card .sub { margin-top: 8px; }
.card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
.card-title-row .card-title { margin-bottom: 0; }
.palette-table-wrap { overflow-x: auto; margin-top: 14px; }
.palette-table { width: 100%; border-collapse: collapse; font-size: 13px; }
@@ -312,6 +314,7 @@ button {
}
button:hover { background: var(--accent-hover); }
button:active { transform: translateY(1px); }
button:disabled { opacity: 0.5; cursor: default; pointer-events: none; }
button.btn-inline {
margin-top: 0;
padding: 3px 10px;
@@ -514,7 +517,12 @@ code {
}
.main-col, .side-col { display: flex; flex-direction: column; }
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
/* minmax(0, 1fr), not bare 1fr -- bare 1fr is minmax(auto, 1fr), whose
"auto" minimum lets the track (and everything in it) grow to fit its
widest descendant's min-content size instead of actually shrinking
to the viewport, causing page-wide horizontal overflow/scroll on
narrow screens. Same fix the desktop rule above already applies. */
.layout { grid-template-columns: minmax(0, 1fr); }
}
/* ------------------------------------------------------------------ */
/* App shell: left sidebar (frame list + account nav) + main content. */
+4 -1
View File
@@ -15,7 +15,10 @@
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Widgets</h2>
<div class="card-title-row">
<h2 class="card-title">Widgets</h2>
<button type="button" id="clear-all-widgets" class="secondary btn-inline">Clear all</button>
</div>
<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. Click a widget's gear icon for its own