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).
90 lines
2.7 KiB
JavaScript
90 lines
2.7 KiB
JavaScript
// Hand-drawn canvas battery-history chart. Ported intact from the
|
|
// original single-page UI. Reads theme colors live so it redraws
|
|
// correctly on theme changes (see the themechange listener in
|
|
// frame_stats.js).
|
|
|
|
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(`${window.FRAME_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>';
|
|
}
|
|
}
|