Redesign phase D: sidebar app shell, per-frame tabs, namespaced API
Build and push server image / build-and-push (push) Successful in 43s
Build and push server image / build-and-push (push) Successful in 43s
The web UI grows into the multi-frame world: a left sidebar lists the
user's frames (with an online dot driven by the same overdue math as
the Device panel; collapsible off-canvas with a hamburger on mobile),
and each frame gets three tabs -- Photos (album picker, now displaying,
the drag-to-reorder upcoming grid), Configuration (name/order/
orientation/refresh/quiet hours/timezone/smart crop + the firmware
card), and Stats (device telemetry, lifetime counters, battery chart).
Settings and Admin adopt the same shell. / becomes a routing hub:
first frame, empty-state onboarding page, setup/login, or the
manage-QR redirect.
The JSON API moves to /api/frames/{id}/... behind require_frame_view /
require_frame_control: any linked user (admins see all) can view; 404
for frames outside your view so ids aren't confirmed; mutations 409
with the holder's name unless you hold the soft control lock, and
POST take-control always flips it to you. Config saves are now partial
updates -- each tab posts only its own fields (checkboxes always sent
explicitly), so the split forms can't clobber each other.
All CSS moves to static/theme.css and the old 680-line inline script
block splits into static/*.js -- the Pointer Events drag-drop state
machine and the canvas battery chart ported intact, not rewritten. The
CSRF fetch wrapper now reads a <meta> tag. No build step, still vanilla.
Verified end-to-end: page/static/API suites, control-lock handoff in
both directions, partial-save field preservation, non-admin frame
isolation, and the legacy-device curl suite (still byte-identical
responses for the deployed frame).
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
|
||||
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% 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">Display settings</h2>
|
||||
<form id="config-form">
|
||||
<label>Frame name
|
||||
<input type="text" id="frame_name" maxlength="64" value="{{ frame.name }}">
|
||||
</label>
|
||||
<label>Order
|
||||
<select id="order">
|
||||
<option value="sequential" {% if frame.order == "sequential" %}selected{% endif %}>Sequential</option>
|
||||
<option value="shuffle" {% if frame.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Orientation
|
||||
<select id="orientation">
|
||||
<option value="landscape" {% if frame.orientation == "landscape" %}selected{% endif %}>Landscape</option>
|
||||
<option value="portrait" {% if frame.orientation == "portrait" %}selected{% endif %}>Portrait</option>
|
||||
<option value="landscape_flipped" {% if frame.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
|
||||
<option value="portrait_flipped" {% if frame.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Refresh interval (minutes)
|
||||
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
||||
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
|
||||
</label>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="smart_crop_faces" {% if frame.smart_crop_faces %}checked{% endif %}>
|
||||
<label for="smart_crop_faces">Center faces in crop</label>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="quiet_hours_enabled" {% if frame.quiet_hours_enabled %}checked{% endif %}>
|
||||
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
|
||||
</div>
|
||||
<label>Quiet hours start
|
||||
<input type="time" id="quiet_hours_start" value="{{ frame.quiet_hours_start }}">
|
||||
</label>
|
||||
<label>Quiet hours end
|
||||
<input type="time" id="quiet_hours_end" value="{{ frame.quiet_hours_end }}">
|
||||
</label>
|
||||
<label>Timezone
|
||||
<select id="timezone">
|
||||
{% for tz in timezones %}
|
||||
<option value="{{ tz }}" {% if tz == frame.timezone %}selected{% endif %}>{{ tz }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Quiet hours times are
|
||||
interpreted in this timezone. The device may still wake once right
|
||||
at the start of quiet hours -- it can't know ahead of time -- but
|
||||
goes right back to sleep until they end.</p>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Firmware update</h2>
|
||||
<p class="sub" id="firmware-board">
|
||||
{% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }}
|
||||
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
|
||||
</p>
|
||||
<p class="sub" id="firmware-available">
|
||||
{% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame
|
||||
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
|
||||
</p>
|
||||
<input type="file" id="firmware-file" accept=".bin">
|
||||
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
||||
|
||||
<div id="firmware-repo-display" style="margin-top: 16px; {% if not frame.firmware_update_repo_url %}display: none;{% endif %}">
|
||||
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ frame.firmware_update_repo_url }}</code>
|
||||
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
|
||||
</p>
|
||||
</div>
|
||||
<label id="firmware-repo-edit" style="{% if frame.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
|
||||
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
|
||||
value="{{ frame.firmware_update_repo_url }}">
|
||||
</label>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
|
||||
<label for="firmware_auto_update">Automatically apply updates</label>
|
||||
</div>
|
||||
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
|
||||
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
|
||||
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
|
||||
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script src="/static/frame_config.js"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user