Redesign phase D: sidebar app shell, per-frame tabs, namespaced API
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:
2026-07-21 23:56:18 -04:00
parent 683e3881b1
commit 8ac3fc0de3
25 changed files with 2147 additions and 1612 deletions
+5
View File
@@ -0,0 +1,5 @@
<nav class="tabs">
<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 }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
</nav>
+3 -4
View File
@@ -1,8 +1,7 @@
{% extends "base.html" %}
{% extends "app_base.html" %}
{% block subtitle %}
<p class="sub">Administration</p>
{% endblock %}
{% block title %}Admin{% endblock %}
{% block page_title %}Administration{% endblock %}
{% block content %}
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}ESPresso Frame{% endblock %}</title>
{% if csrf_token %}<meta name="csrf-token" content="{{ csrf_token }}">{% endif %}
<script>
// Applied before first paint so there's no flash of the wrong theme.
(function () {
try {
var stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') {
document.documentElement.setAttribute('data-theme', stored);
}
} catch (e) { /* localStorage unavailable (private mode, etc.) */ }
})();
</script>
<link rel="stylesheet" href="/static/theme.css">
{% block extra_head %}{% endblock %}
</head>
<body>
<div class="shell">
<div class="sidebar-backdrop"></div>
<aside class="sidebar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<h1>ESPresso Frame</h1>
</div>
<div class="sidebar-section">Frames</div>
{% for f in sidebar_frames %}
<a class="nav-item {% if active_frame and active_frame.id == f.id %}active{% endif %} {% if f.recently_seen %}online{% endif %}"
href="/frames/{{ f.id }}">
<span class="frame-dot" aria-hidden="true"></span>
{{ f.name or ("Frame " ~ f.id) }}
{% if f.owner_user_id is none %}<span class="nav-sub">unclaimed</span>{% endif %}
</a>
{% endfor %}
{% if not sidebar_frames %}
<p class="sub" style="padding: 0 10px;">No frames yet.</p>
{% endif %}
<div class="sidebar-footer">
<a class="nav-item {% if active_nav == 'settings' %}active{% endif %}" href="/settings">Settings</a>
{% if user.is_admin %}
<a class="nav-item {% if active_nav == 'admin' %}active{% endif %}" href="/admin">Admin</a>
{% endif %}
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="linklike">Log out</button>
</form>
</div>
</aside>
<div class="main-wrap" style="flex: 1; min-width: 0;">
<div class="mobile-bar">
<button type="button" id="sidebar-toggle" class="icon-btn" title="Menu" aria-label="Open menu"></button>
<div class="brand"><span aria-hidden="true"></span><h1>ESPresso Frame</h1></div>
<div class="spacer"></div>
</div>
<main class="main">
<div class="page-head">
<h1>{% block page_title %}{% endblock %}</h1>
<div class="head-actions">
{% block head_actions %}{% endblock %}
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
</div>
</div>
{% block tabs %}{% endblock %}
{% block content %}{% endblock %}
</main>
</div>
</div>
<script src="/static/common.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+4 -386
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}ESPresso Frame{% endblock %}</title>
{% if csrf_token %}<meta name="csrf-token" content="{{ csrf_token }}">{% endif %}
<script>
// Applied before first paint so there's no flash of the wrong theme.
(function () {
@@ -15,327 +16,7 @@
} catch (e) { /* localStorage unavailable (private mode, etc.) */ }
})();
</script>
<style>
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--surface-alt: #f0f1f4;
--border: #e3e5e9;
--text: #16181d;
--text-muted: #666d7a;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--focus-ring: rgba(37, 99, 235, 0.35);
--success-bg: #dcfce7;
--success-text: #166534;
--danger-bg: #fee2e2;
--danger-text: #991b1b;
--warn-bg: #fef9c3;
--warn-text: #854d0e;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
--shadow-hover: 0 8px 20px rgba(16, 24, 40, 0.10);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(153, 27, 27, 0.85);
--color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1016;
--surface: #161a22;
--surface-alt: #1d222b;
--border: #2a2f3a;
--text: #e8eaed;
--text-muted: #9aa2b1;
--accent: #4c8dff;
--accent-hover: #6ea1ff;
--focus-ring: rgba(76, 141, 255, 0.4);
--success-bg: rgba(34, 197, 94, 0.16);
--success-text: #4ade80;
--danger-bg: rgba(239, 68, 68, 0.16);
--danger-text: #f87171;
--warn-bg: rgba(234, 179, 8, 0.16);
--warn-text: #fbbf24;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(248, 113, 113, 0.35);
--color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0d1016;
--surface: #161a22;
--surface-alt: #1d222b;
--border: #2a2f3a;
--text: #e8eaed;
--text-muted: #9aa2b1;
--accent: #4c8dff;
--accent-hover: #6ea1ff;
--focus-ring: rgba(76, 141, 255, 0.4);
--success-bg: rgba(34, 197, 94, 0.16);
--success-text: #4ade80;
--danger-bg: rgba(239, 68, 68, 0.16);
--danger-text: #f87171;
--warn-bg: rgba(234, 179, 8, 0.16);
--warn-text: #fbbf24;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(248, 113, 113, 0.35);
--color-scheme: dark;
}
* { box-sizing: border-box; }
html {
color-scheme: var(--color-scheme);
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
transition: background-color .15s ease, color .15s ease;
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 28px 20px 72px;
}
.page.page-narrow {
max-width: 420px;
padding-top: 88px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.brand { display: flex; align-items: flex-start; gap: 12px; }
.brand-mark {
font-size: 26px;
line-height: 1;
margin-top: 2px;
}
h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
p.sub { color: var(--text-muted); font-size: 13.5px; margin: 5px 0 0; line-height: 1.5; }
.icon-btn {
flex: none;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
box-shadow: var(--shadow);
transition: background-color .15s ease, border-color .15s ease, transform .1s ease;
}
.icon-btn:hover { background: var(--surface-alt); }
.icon-btn:active { transform: scale(0.94); }
.topbar-actions { display: flex; align-items: center; gap: 14px; }
.topnav { display: flex; align-items: center; gap: 14px; font-size: 13.5px; }
.topnav a { color: var(--text-muted); text-decoration: none; }
.topnav a:hover { color: var(--text); }
.inline-form { display: inline; margin: 0; }
button.linklike {
background: none;
border: none;
padding: 0;
margin: 0;
color: var(--text-muted);
font-size: 13.5px;
font-weight: 400;
cursor: pointer;
box-shadow: none;
}
button.linklike:hover { color: var(--text); background: none; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.admin-table th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border); }
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
.admin-actions form { margin: 4px 0 0; }
.admin-actions details summary { cursor: pointer; color: var(--text-muted); font-size: 13px; }
.admin-actions input[type="password"] { margin-top: 6px; }
.admin-frame { border-bottom: 1px solid var(--border); padding: 10px 0; }
.admin-frame:last-child { border-bottom: none; }
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
h2.card-title, summary.card-title {
font-size: 14.5px;
font-weight: 650;
margin: 0 0 14px;
letter-spacing: 0.01em;
text-transform: uppercase;
color: var(--text-muted);
}
summary.card-title { cursor: pointer; margin-bottom: 0; }
details.card[open] summary.card-title { margin-bottom: 14px; }
details.card .sub { margin-top: 8px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 22px 22px;
box-shadow: var(--shadow);
}
.card + .card { margin-top: 20px; }
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
label:first-child { margin-top: 0; }
input, select {
width: 100%;
padding: 9px 10px;
box-sizing: border-box;
margin-top: 5px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
background: var(--bg);
color: var(--text);
font-family: inherit;
transition: border-color .15s ease, box-shadow .15s ease;
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
.checkbox-row input { width: auto; margin-top: 0; }
.checkbox-row label { margin-top: 0; font-weight: normal; }
button {
margin-top: 20px;
padding: 10px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: white;
cursor: pointer;
font-size: 14px;
font-weight: 600;
font-family: inherit;
transition: background-color .15s ease, transform .1s ease;
}
button:hover { background: var(--accent-hover); }
button:active { transform: translateY(1px); }
button.btn-inline {
margin-top: 0;
padding: 3px 10px;
font-size: 12px;
vertical-align: middle;
}
button.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
margin-right: 8px;
}
button.secondary:hover { background: var(--surface-alt); }
.status { margin-top: 16px; padding: 10px 12px; border-radius: 8px; font-size: 14px; }
.status.ok { background: var(--success-bg); color: var(--success-text); }
.status.err { background: var(--danger-bg); color: var(--danger-text); }
.info-box {
margin-bottom: 20px;
padding: 12px 14px;
border-radius: 10px;
font-size: 13px;
background: var(--surface-alt);
color: var(--text-muted);
border: 1px solid var(--border);
}
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
code {
background: var(--surface-alt);
color: var(--text);
padding: 2px 5px;
border-radius: 4px;
font-size: 0.92em;
}
.info-box code, .info-box.warn code { background: rgba(127, 127, 127, 0.18); color: inherit; }
.thumb { width: 160px; max-width: 100%; border-radius: 8px; display: block; }
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
.photo-card {
position: relative; cursor: grab; border-radius: 8px; overflow: hidden; aspect-ratio: 1;
background: var(--surface-alt); border: 1px solid var(--border);
box-shadow: var(--shadow);
transition: box-shadow .15s ease, transform .15s ease;
/* pan-y (not none): lets a normal touch-scroll of the page work
when you touch a card without meaning to drag it. Dragging on
touch instead requires a brief hold first (see the JS below),
which switches this to "none" for the rest of that touch --
only once we're sure it's a deliberate drag, not a scroll. */
touch-action: pan-y;
/* Without this, a press-and-drag gesture also triggers the
browser's native text/content selection (the blue highlight) --
distracting, and on some browsers it fights the pointer-based
drag tracking below closely enough to break it outright. */
user-select: none;
-webkit-user-select: none;
}
.photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); }
.photo-card:active { cursor: grabbing; }
.photo-card.drag-armed {
box-shadow: 0 0 0 3px var(--focus-ring) inset;
transform: scale(0.97);
}
/* No transform transition here -- the JS drives transform on every
pointermove to track the finger 1:1, and the .15s base transition
would otherwise make it visibly lag behind a fast swipe. */
.photo-card.dragging {
z-index: 20;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
transition: box-shadow .15s ease;
pointer-events: none; /* so elementFromPoint hits the card underneath, not this one */
cursor: grabbing;
}
.photo-card.drag-over { outline: 3px solid var(--accent); outline-offset: -3px; }
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: var(--overlay); color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; }
.photo-card .remove-btn, .thumb-wrap .remove-btn {
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; margin: 0; padding: 0;
line-height: 20px; text-align: center; font-size: 13px; border-radius: 50%;
background: var(--overlay); color: white;
}
.photo-card .remove-btn:hover, .thumb-wrap .remove-btn:hover { background: var(--overlay-hover); }
.photo-card .show-next {
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px;
}
.thumb-wrap { position: relative; display: inline-block; }
.layout {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: 20px;
align-items: start;
}
.main-col, .side-col { display: flex; flex-direction: column; }
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
}
</style>
<link rel="stylesheet" href="/static/theme.css">
{% block extra_head %}{% endblock %}
</head>
<body>
@@ -348,76 +29,13 @@
{% block subtitle %}{% endblock %}
</div>
</div>
<div class="topbar-actions">
{% if user %}
<nav class="topnav">
<a href="/">Home</a>
<a href="/settings">Settings</a>
{% if user.is_admin %}<a href="/admin">Admin</a>{% endif %}
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="linklike">Log out</button>
</form>
</nav>
{% endif %}
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
</div>
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
</header>
{% block content %}{% endblock %}
</div>
{% if csrf_token %}
<script>
// Session-cookie auth needs CSRF proof on mutating requests. Rather
// than touching every fetch() call site in the page scripts, wrap
// fetch once: same-origin non-GET requests automatically carry the
// per-session token. (Legacy shared-token access renders without a
// csrf_token, so this block doesn't exist there at all.)
(function () {
var CSRF = {{ csrf_token | tojson }};
var origFetch = window.fetch;
window.fetch = function (input, init) {
init = init || {};
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
var url = typeof input === 'string' ? input : (input && input.url) || '';
var sameOrigin = url.indexOf('://') === -1 || url.indexOf(location.origin) === 0;
if (sameOrigin && method !== 'GET' && method !== 'HEAD') {
init.headers = new Headers(init.headers || (input && input.headers) || {});
init.headers.set('X-CSRF-Token', CSRF);
}
return origFetch.call(this, input, init);
};
})();
</script>
{% endif %}
<script>
// Shared theme toggle: explicit choice wins over the OS preference and
// is remembered; with no explicit choice, the CSS above falls back to
// prefers-color-scheme on its own.
(function () {
var btn = document.getElementById('theme-toggle');
if (!btn) return;
function currentTheme() {
var stored = null;
try { stored = localStorage.getItem('theme'); } catch (e) { /* ignore */ }
if (stored === 'light' || stored === 'dark') return stored;
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}
function apply(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('theme', theme); } catch (e) { /* ignore */ }
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: theme } }));
}
btn.addEventListener('click', function () {
apply(currentTheme() === 'dark' ? 'light' : 'dark');
});
})();
</script>
<script src="/static/common.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+111
View File
@@ -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 %}
+60
View File
@@ -0,0 +1,60 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Photos{% 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">Album</h2>
<form id="photos-form">
<label>Album
<select id="album_id">
{% if frame.album_id %}<option value="{{ frame.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %}
</select>
</label>
<label>Upcoming photos to show
<select id="queue_target_len">
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
<option value="{{ n }}" {% if frame.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="secondary" id="load-albums">Load Albums</button>
<button type="submit">Save</button>
</form>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
</section>
</div>
</div>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Upcoming</h2>
<p class="sub">Drag a photo to reorder (on touch, hold briefly first so a
normal scroll still works), "Show next" to jump it to the front, or
the &times; to remove it from rotation entirely.</p>
<div id="upcoming-grid" class="photo-grid"></div>
</section>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/queue.js"></script>
<script src="/static/frame_photos.js"></script>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Battery history</h2>
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Lifetime stats</h2>
<div id="stats-box"><p class="sub">Loading...</p></div>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Device</h2>
<div id="device-status"><p class="sub">Loading...</p></div>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/battery_chart.js"></script>
<script src="/static/frame_stats.js"></script>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "app_base.html" %}
{% block title %}ESPresso Frame{% endblock %}
{% block page_title %}Welcome{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">No frames yet</h2>
<p class="sub">Set up a frame and it'll appear in the sidebar:</p>
<p class="sub">1. Power the frame on -- it opens a WiFi network named
<code>ESPRESSO_XXXXXX</code> and shows join instructions on its panel.</p>
<p class="sub">2. Join that network and fill in your WiFi details plus this
server's address.</p>
<p class="sub">3. Your browser lands on this server's claim page and links
the frame to your account automatically.</p>
<p class="sub" style="margin-top: 12px;">Already provisioned? Ask whoever
claimed it (or an admin) to link your account, or scan the frame's
on-panel manage QR.</p>
</section>
{% endblock %}
-828
View File
@@ -1,828 +0,0 @@
{% extends "base.html" %}
{% block subtitle %}
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
{% endblock %}
{% block content %}
{% if immich_url %}
<div class="info-box">Immich: <code>{{ immich_url }}</code> (API key configured). Set via
<code>IMMICH_URL</code>/<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> -- see
<code>docker-compose.yml.example</code>.</div>
{% else %}
<div class="info-box warn">Immich isn't configured yet. Set <code>IMMICH_URL</code> and
<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> (copy
<code>docker-compose.yml.example</code>) and restart the server.</div>
{% endif %}
<div class="layout">
<div class="main-col">
<section class="card config-panel">
<h2 class="card-title">Settings</h2>
<form id="config-form">
<label>Album
<select id="album_id">
{% if cfg.album_id %}<option value="{{ cfg.album_id }}" selected>(current selection -- reload to rename)</option>{% endif %}
</select>
</label>
<label>Order
<select id="order">
<option value="sequential" {% if cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select>
</label>
<label>Orientation
<select id="orientation">
<option value="landscape" {% if cfg.orientation == "landscape" %}selected{% endif %}>Landscape</option>
<option value="portrait" {% if cfg.orientation == "portrait" %}selected{% endif %}>Portrait</option>
<option value="landscape_flipped" {% if cfg.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
<option value="portrait_flipped" {% if cfg.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="{{ (cfg.refresh_interval_s // 60) or 60 }}" required>
</label>
<div class="checkbox-row">
<input type="checkbox" id="smart_crop_faces" {% if cfg.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 cfg.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="{{ cfg.quiet_hours_start }}">
</label>
<label>Quiet hours end
<input type="time" id="quiet_hours_end" value="{{ cfg.quiet_hours_end }}">
</label>
<label>Timezone
<select id="timezone">
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz == cfg.timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 8px;">Quiet hours times above 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>
<label>Upcoming photos to show
<select id="queue_target_len">
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
<option value="{{ n }}" {% if cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="secondary" id="load-albums">Load Albums</button>
<button type="submit">Save</button>
</form>
<div id="result"></div>
</section>
<details class="card">
<summary class="card-title">Stats</summary>
<div id="stats-box"><p class="sub">Loading...</p></div>
</details>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Device</h2>
<div id="device-status"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Battery history</h2>
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Firmware update</h2>
<p class="sub" id="firmware-board">
{% if cfg.device_board_variant %}Detected board: {{ cfg.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 cfg.firmware_available_version %}Uploaded: v{{ cfg.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 cfg.firmware_update_repo_url %}display: none;{% endif %}">
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ cfg.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 cfg.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="{{ cfg.firmware_update_repo_url }}">
</label>
<div class="checkbox-row">
<input type="checkbox" id="firmware_auto_update" {% if cfg.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>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Upcoming</h2>
<p class="sub">Drag a photo to reorder (on touch, hold briefly first so a
normal scroll still works), "Show next" to jump it to the front, or
the &times; to remove it from rotation entirely.</p>
<div id="upcoming-grid" class="photo-grid"></div>
</section>
{% endblock %}
{% block scripts %}
<script>
const resultEl = document.getElementById('result');
function showStatus(ok, message) {
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
}
async function saveConfig() {
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
const body = new URLSearchParams({
album_id: document.getElementById('album_id').value || '',
order: document.getElementById('order').value,
orientation: document.getElementById('orientation').value,
refresh_interval_s: String(minutes * 60),
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
queue_target_len: document.getElementById('queue_target_len').value,
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
timezone: document.getElementById('timezone').value || 'UTC',
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
});
const resp = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) {
throw new Error(await resp.text());
}
}
document.getElementById('load-albums').addEventListener('click', async () => {
try {
await saveConfig();
const resp = await fetch('/api/albums');
if (!resp.ok) {
throw new Error(await resp.text());
}
const albums = await resp.json();
const select = document.getElementById('album_id');
select.innerHTML = '';
for (const a of albums) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.name} (${a.count})`;
select.appendChild(opt);
}
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await saveConfig();
showStatus(true, 'Saved.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
function showRepoDisplayMode(url) {
document.getElementById('firmware-repo-text').textContent = url;
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
}
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
document.getElementById('firmware-repo-display').style.display = 'none';
document.getElementById('firmware-repo-edit').style.display = 'block';
document.getElementById('firmware_update_repo_url').focus();
});
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
try {
await saveConfig();
showStatus(true, 'Saved.');
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
}
});
let upcomingItems = [];
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
// code drives mouse, touch, and pen -- native drag-and-drop is
// mouse-only by spec and never fires at all on phones/tablets.
//
// On touch specifically, a card only "arms" for dragging after a
// brief hold (DRAG_HOLD_MS) with the finger roughly stationary --
// .photo-card's touch-action stays "pan-y" (native scroll allowed)
// the whole time up to that point, so a normal touch-and-swipe to
// scroll the page still works even though it starts on a card. Once
// armed, touch-action switches to "none" for the rest of that touch
// so drag tracking gets every pointermove reliably. Mouse skips the
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
const DRAG_HOLD_MS = 250;
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
function vibrate(ms) {
// Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so
// it's a harmless no-op everywhere else rather than needing a
// feature check at every call site.
try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ }
}
function clearDragOverStyling() {
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
}
function endDrag(card) {
if (dragState && dragState.holdTimer) {
clearTimeout(dragState.holdTimer);
}
card.style.touchAction = '';
card.style.transform = '';
card.classList.remove('dragging', 'drag-armed');
clearDragOverStyling();
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
vibrate(15);
moveItem(dragState.fromIndex, dragState.toIndex);
}
dragState = null;
}
function renderUpcoming(items) {
upcomingItems = items;
const grid = document.getElementById('upcoming-grid');
grid.innerHTML = '';
items.forEach((item, i) => {
const card = document.createElement('div');
card.className = 'photo-card';
card.dataset.index = String(i);
const img = document.createElement('img');
img.src = item.thumbnail_url;
img.alt = '';
card.appendChild(img);
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = String(i + 1);
card.appendChild(badge);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeAsset(item.id);
});
card.appendChild(removeBtn);
const nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'show-next';
nextBtn.textContent = 'Show next';
nextBtn.addEventListener('click', (e) => {
e.stopPropagation();
showNext(i);
});
card.appendChild(nextBtn);
card.addEventListener('pointerdown', (e) => {
if (e.target.closest('.show-next') || e.target.closest('.remove-btn')) {
return; // let the button's own click handler run, don't start a drag
}
if (e.pointerType === 'mouse' && e.button !== 0) {
return; // left button only
}
dragState = {
pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false,
armed: e.pointerType !== 'touch', startX: e.clientX, startY: e.clientY, holdTimer: null,
};
if (e.pointerType === 'touch') {
dragState.holdTimer = setTimeout(() => {
if (dragState && dragState.pointerId === e.pointerId && !dragState.armed) {
dragState.armed = true;
card.classList.add('drag-armed');
card.style.touchAction = 'none';
vibrate(10);
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
}
}, DRAG_HOLD_MS);
} else {
card.setPointerCapture(e.pointerId);
}
});
card.addEventListener('pointermove', (e) => {
if (!dragState || dragState.pointerId !== e.pointerId) {
return;
}
if (!dragState.armed) {
// Still deciding whether this is a hold-to-drag or a scroll --
// moving this much before the hold timer fires means scroll;
// bail out and let the browser's native pan-y handle it.
const dx0 = e.clientX - dragState.startX;
const dy0 = e.clientY - dragState.startY;
if (Math.hypot(dx0, dy0) > HOLD_CANCEL_THRESHOLD_PX) {
clearTimeout(dragState.holdTimer);
dragState = null;
}
return;
}
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
if (!dragState.moved) {
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
return;
}
dragState.moved = true;
card.classList.remove('drag-armed');
card.classList.add('dragging');
}
// Follows the finger 1:1 -- the actual "pick it up and carry it"
// feedback that was missing before (the card used to just fade
// in place while a static outline highlighted the drop target).
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`;
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
clearDragOverStyling();
if (overCard && overCard !== card) {
overCard.classList.add('drag-over');
dragState.toIndex = Number(overCard.dataset.index);
} else {
dragState.toIndex = undefined;
}
});
card.addEventListener('pointerup', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
card.addEventListener('pointercancel', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
grid.appendChild(card);
});
}
function moveItem(fromIndex, toIndex) {
const items = upcomingItems.slice();
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
renderUpcoming(items);
persistOrder(items);
}
async function showNext(index) {
const assetId = upcomingItems[index].id;
try {
const resp = await fetch('/api/queue/promote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue(); // always refetch the authoritative order rather than guessing locally
}
async function removeAsset(assetId) {
if (!confirm('Remove this photo from the rotation? It stays in Immich -- this frame just won\'t show it again.')) {
return;
}
try {
const resp = await fetch('/api/queue/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue();
}
async function persistOrder(items) {
try {
const resp = await fetch('/api/queue/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queue: items.map((item) => item.id) }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
loadQueue();
}
}
function formatDuration(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function renderDeviceStatus(device) {
const el = document.getElementById('device-status');
el.innerHTML = '';
if (!device || !device.last_seen) {
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
return;
}
const now = Date.now() / 1000;
const rows = [];
const ago = formatDuration(Math.max(0, now - device.last_seen));
rows.push([`Last seen`, `${ago} ago`, device.overdue]);
if (device.firmware_version) {
let fw = `v${device.firmware_version}`;
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
fw += ` (v${device.firmware_available} waiting)`;
}
rows.push(['Firmware', fw, false]);
}
if (device.battery) {
rows.push(['Battery', `${device.battery.percent}%`, false]);
}
if (device.on_battery_since) {
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
}
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
}
for (const [label, value, alert] of rows) {
const p = document.createElement('p');
p.className = 'sub';
if (alert) {
p.style.color = 'var(--danger-text)';
p.style.fontWeight = '600';
}
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
document.getElementById('firmware-upload').addEventListener('click', async () => {
const input = document.getElementById('firmware-file');
if (!input.files.length) {
showStatus(false, 'Pick a firmware .bin first.');
return;
}
const form = new FormData();
form.append('file', input.files[0]);
try {
const resp = await fetch('/api/firmware', { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(await resp.text());
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
} catch (e) {
showStatus(false, e.message);
}
});
async function loadFirmwareCheck(force) {
const statusEl = document.getElementById('firmware-gitea-status');
const btn = document.getElementById('firmware-update-btn');
const boardEl = document.getElementById('firmware-board');
try {
const resp = await fetch('/api/firmware/check' + (force ? '?force=true' : ''));
if (!resp.ok) {
if (force) {
showStatus(false, await resp.text());
}
return;
}
const data = await resp.json();
if (data.board) {
boardEl.textContent = `Detected board: ${data.board}`;
}
if (!data.enabled) {
statusEl.style.display = 'none';
btn.style.display = 'none';
if (force) {
showStatus(false, 'No Gitea repo URL configured.');
}
return;
}
statusEl.style.display = 'block';
if (!data.board) {
statusEl.textContent = "Waiting for the frame to check in before it can look up the right build.";
btn.style.display = 'none';
} else if (data.update_available) {
statusEl.textContent = `Update available: v${data.latest_version}.`;
btn.style.display = 'inline-block';
} else if (data.latest_version) {
statusEl.textContent = `Up to date (v${data.latest_version}).`;
btn.style.display = 'none';
} else {
statusEl.textContent = 'No releases found yet.';
btn.style.display = 'none';
}
if (force) {
showStatus(true, 'Checked.');
}
} catch (e) {
// A failed passive poll is silent -- the manual upload path still
// works regardless, and this just retries on the next poll. An
// explicit "Check now" click still surfaces the error.
if (force) {
showStatus(false, e.message);
}
}
}
document.getElementById('firmware-check-now').addEventListener('click', () => loadFirmwareCheck(true));
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
const btn = document.getElementById('firmware-update-btn');
btn.disabled = true;
try {
const resp = await fetch('/api/firmware/apply-latest', { method: 'POST' });
if (!resp.ok) {
throw new Error(await resp.text());
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} staged from Gitea.`);
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
} finally {
btn.disabled = false;
}
});
let lastDevice = null;
async function loadQueue() {
if (dragState) {
return; // don't yank the grid out from under an in-progress drag
}
const currentEl = document.getElementById('current-thumb');
try {
const resp = await fetch('/api/queue');
if (!resp.ok) {
currentEl.innerHTML = '<p class="sub">Not available yet -- configure Immich and an album first.</p>';
renderUpcoming([]);
return;
}
const data = await resp.json();
currentEl.innerHTML = '';
if (data.current) {
const wrap = document.createElement('div');
wrap.className = 'thumb-wrap';
const img = document.createElement('img');
img.className = 'thumb';
img.src = data.current.thumbnail_url;
img.alt = '';
wrap.appendChild(img);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
wrap.appendChild(removeBtn);
currentEl.appendChild(wrap);
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
lastDevice = data.device;
renderDeviceStatus(data.device);
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
// Reads resolved colors from CSS custom properties rather than
// hardcoding hex values, so the chart matches the current theme
// (light/dark) without needing its own separate palette.
function themeColor(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
let lastBatteryLog = null;
function drawBatteryChart(log) {
lastBatteryLog = log;
const wrap = document.getElementById('battery-chart-wrap');
if (!log || log.length < 2) {
wrap.innerHTML = '<p class="sub">Not enough data yet.</p>';
return;
}
wrap.innerHTML = '';
const width = wrap.clientWidth || 440;
const height = 180;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.style.display = 'block';
canvas.style.border = `1px solid ${themeColor('--border')}`;
canvas.style.borderRadius = '8px';
wrap.appendChild(canvas);
const ctx = canvas.getContext('2d');
const gridColor = themeColor('--border');
const mutedColor = themeColor('--text-muted');
const accentColor = themeColor('--accent');
const pad = { left: 28, right: 8, top: 10, bottom: 20 };
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
const times = log.map((p) => p[0]);
const minT = Math.min(...times);
const maxT = Math.max(...times);
const spanT = Math.max(1, maxT - minT);
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
ctx.strokeStyle = gridColor;
ctx.fillStyle = mutedColor;
ctx.font = '10px system-ui, sans-serif';
ctx.lineWidth = 1;
ctx.textAlign = 'left';
[0, 25, 50, 75, 100].forEach((pct) => {
const yy = y(pct);
ctx.beginPath();
ctx.moveTo(pad.left, yy);
ctx.lineTo(width - pad.right, yy);
ctx.stroke();
ctx.fillText(String(pct), 2, yy + 3);
});
ctx.strokeStyle = accentColor;
ctx.lineWidth = 1.5;
ctx.beginPath();
log.forEach((p, i) => {
const px = x(p[0]);
const py = y(p[1]);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
ctx.fillStyle = mutedColor;
ctx.textAlign = 'left';
ctx.fillText(fmt(minT), pad.left, height - 4);
ctx.textAlign = 'right';
ctx.fillText(fmt(maxT), width - pad.right, height - 4);
}
async function loadBatteryLog() {
const wrap = document.getElementById('battery-chart-wrap');
try {
const resp = await fetch('/api/battery-log');
if (!resp.ok) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
const data = await resp.json();
drawBatteryChart(data.log);
} catch (e) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
}
}
function renderStats(stats) {
const el = document.getElementById('stats-box');
el.innerHTML = '';
const now = Date.now() / 1000;
const rows = [
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
['Wake cycles', stats.device_wakes],
['Photos displayed', stats.photos_displayed],
['Photos removed from rotation', stats.photos_removed],
['Battery reports received', stats.battery_reports],
['Battery recharge cycles', stats.recharge_cycles],
['OTA updates applied', stats.ota_updates_applied],
['Settings saved', stats.config_saves],
];
for (const [label, value] of rows) {
const p = document.createElement('p');
p.className = 'sub';
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
async function loadStats() {
const el = document.getElementById('stats-box');
try {
const resp = await fetch('/api/stats');
if (!resp.ok) {
el.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
renderStats(await resp.json());
} catch (e) {
el.innerHTML = '<p class="sub">Could not load.</p>';
}
}
loadQueue();
loadBatteryLog();
loadStats();
loadFirmwareCheck();
// Redraw the canvas chart (and the "Last seen" alert color) with the
// new theme's colors as soon as the toggle in the header is used --
// canvas pixels don't repaint themselves the way CSS does.
document.addEventListener('themechange', () => {
if (lastBatteryLog) {
drawBatteryChart(lastBatteryLog);
}
if (lastDevice) {
renderDeviceStatus(lastDevice);
}
});
// Fast tick: re-renders "Last seen"/"On battery for" etc. from the
// already-fetched device data every second, so they count up smoothly
// (1s ago, 5s ago, 1m ago...) without hitting the server that often.
setInterval(() => {
if (lastDevice) {
renderDeviceStatus(lastDevice);
}
}, 1000);
// Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere, battery report, firmware version) without a manual
// refresh. Skipped mid-drag (see loadQueue above).
setInterval(loadQueue, 10000);
// Separate, slower poll for the Gitea release check -- cheap either
// way since the server itself throttles actual Gitea API calls to
// once per gitea_releases.UPDATE_CHECK_INTERVAL_S.
setInterval(loadFirmwareCheck, 60000);
</script>
{% endblock %}
+3 -6
View File
@@ -1,10 +1,7 @@
{% extends "base.html" %}
{% extends "app_base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">Your account</p>
{% endblock %}
{% block title %}Settings{% endblock %}
{% block page_title %}Your account{% endblock %}
{% block content %}
{% if saved %}<div class="status ok">Saved.</div>{% endif %}