Files
espresso_frame/server/app/static/frame_config.js
T
tfaour 5f4f8f2ea7
Build and push server image / test (push) Successful in 44s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m27s
Add a curated theme system for "modern" style widgets, inspired by Tesserae
Frame.theme (7 presets in app/theme_tokens.py) drives font family,
corner radius, drop shadow, and an accent hue for every modern-style
widget's header/accent region. Rich accent colors (not just the 6 flat
panel inks) are approximated via denser Bayer stippling confined to just
that region (html_render.ordered_dither_regions), so icon/text content
elsewhere stays exactly as crisp as it is today -- verified directly
against real Chromium renders, both in unit tests and via run-server.
"classic" is a byte-identical no-visual-change default: weather's header
keeps its original fixed blue gradient, tasks/calendar keep their flat
THEME_* ink.

Themes are purely stylistic -- battery's charge-level color, calendar/
tasks' per-owner event chips, and text's own per-widget font choice are
never touched.
2026-07-31 10:34:28 +00:00

461 lines
18 KiB
JavaScript

// Configuration tab: frame-wide settings (orientation, quiet hours,
// palette/color/contrast/dither, firmware, battery alerts) + take
// control. Frame name lives in the page header now (frame_header.js);
// every per-widget setting (album, calendar view/inclusion, whiteboard
// source) lives in its own widget's gear-icon dialog instead (see
// static/frame_layout.js) -- this tab never touches those.
// window.FRAME_API is set by the template. Checkboxes are always sent
// explicitly as "true"/"false" -- the server treats absent fields as
// "leave unchanged", so a checkbox must never be simply omitted.
async function saveConfig() {
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
const body = new URLSearchParams({
orientation: document.getElementById('orientation').value,
refresh_interval_s: String(minutes * 60),
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',
});
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
}
// 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);
}
});
// Hold-for-global-action (see app/global_actions.py) -- a frame-wide
// setting, not per-widget, so it shares api_config_save/the /config
// endpoint rather than getting its own -- just a separate card/form on
// this page for a distinct-enough concern.
document.getElementById('hold-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const seconds = parseInt(document.getElementById('hold_duration_s').value, 10) || 3;
const body = new URLSearchParams({
hold_duration_ms: String(seconds * 1000),
next_hold_action: document.getElementById('next_hold_action').value,
back_hold_action: document.getElementById('back_hold_action').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
} catch (err) {
showStatus(false, err.message);
}
});
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.');
loadControl();
} catch (e) {
showStatus(false, e.message);
}
}
async function loadControl() {
const banner = document.getElementById('control-banner');
try {
const resp = await fetch(`${window.FRAME_API}/status`);
if (!resp.ok) return; // unconfigured frame: control still works via 409s
const data = await resp.json();
if (data.control && !data.control.you) {
banner.style.display = 'flex';
document.getElementById('control-holder').textContent = data.control.controller
? `${data.control.controller} currently has control of this frame.`
: 'Nobody has control of this frame yet.';
} else {
banner.style.display = 'none';
}
} catch (e) { /* banner is best-effort */ }
}
document.getElementById('take-control').addEventListener('click', takeControl);
// ---- Advanced configuration: color palette(s) ----
//
// Hex field and R/G/B number fields are kept in sync live, both
// directions -- editing either updates the other plus the preview
// swatch. Hex stays the field actually read at save time (it's what
// the server already validates as #rrggbb); the R/G/B fields are purely
// an alternate, more precise way to arrive at the same value than
// eyeballing a color-picker swatch.
//
// Parameterized by classPrefix ("palette" for the main one, "photo-
// palette" for the photos-only one added alongside it) rather than
// duplicated wholesale -- there are exactly two real instances of this,
// not a speculative future one, and the two would otherwise be ~90
// near-identical lines apart.
function paletteHexInputs(classPrefix) {
return Array.from(document.querySelectorAll(`.${classPrefix}-hex`))
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
}
function hexFromRgb(r, g, b) {
const clamp = (v) => Math.max(0, Math.min(255, Math.round(Number(v) || 0)));
return '#' + [r, g, b].map((v) => clamp(v).toString(16).padStart(2, '0')).join('');
}
function rgbFromHex(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec((hex || '').trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function paletteFieldsFor(classPrefix, index) {
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
return {
hex: at(`${classPrefix}-hex`), r: at(`${classPrefix}-r`), g: at(`${classPrefix}-g`), b: at(`${classPrefix}-b`),
swatch: at(`${classPrefix}-swatch-preview`),
};
}
function syncPaletteFromHex(classPrefix, index) {
const f = paletteFieldsFor(classPrefix, index);
const rgb = rgbFromHex(f.hex.value);
if (!rgb) return;
[f.r.value, f.g.value, f.b.value] = rgb;
f.swatch.style.background = f.hex.value;
}
function syncPaletteFromRgb(classPrefix, index) {
const f = paletteFieldsFor(classPrefix, index);
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
f.hex.value = hex;
f.swatch.style.background = hex;
}
function wirePaletteInputs(classPrefix) {
const count = paletteHexInputs(classPrefix).length;
for (let i = 0; i < count; i++) {
const f = paletteFieldsFor(classPrefix, i);
f.hex.addEventListener('input', () => syncPaletteFromHex(classPrefix, i));
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(classPrefix, i)));
}
}
wirePaletteInputs('palette');
wirePaletteInputs('photo-palette');
// Sliders: live numeric readout next to each, no save until the button
// below is clicked.
['color_boost', 'contrast_boost', 'dither_strength', 'photo_dither_strength'].forEach((id) => {
const input = document.getElementById(id);
const readout = document.getElementById(`${id}_value`);
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
});
async function savePalette(classPrefix, paletteFormKey, extra) {
const body = new URLSearchParams(extra || {});
for (const input of paletteHexInputs(classPrefix)) {
body.append(paletteFormKey, input.value);
}
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadPreview();
} catch (e) {
showStatus(false, e.message);
}
}
document.getElementById('palette-save').addEventListener('click', () => {
savePalette('palette', 'palette', {
color_boost: document.getElementById('color_boost').value,
contrast_boost: document.getElementById('contrast_boost').value,
dither_strength: document.getElementById('dither_strength').value,
});
});
document.getElementById('palette-reset').addEventListener('click', () => {
const inputs = paletteHexInputs('palette');
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
inputs[i].value = hex;
syncPaletteFromHex('palette', i);
});
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
document.getElementById(id).value = '1';
document.getElementById(`${id}_value`).textContent = '1.00';
});
savePalette('palette', 'palette', { palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
});
// Fills the table with a community-measured starting point (see the
// card's own explanatory text) -- doesn't save by itself, same as
// editing the hex/RGB fields by hand; the user still clicks Save (or
// Reset) to commit or discard it.
document.getElementById('palette-load-calibrated').addEventListener('click', () => {
const inputs = paletteHexInputs('palette');
window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => {
inputs[i].value = hex;
syncPaletteFromHex('palette', i);
});
});
// ---- Photos configuration: its own separate palette/dithering ----
document.getElementById('photo-palette-save').addEventListener('click', () => {
savePalette('photo-palette', 'photo_palette', {
photo_dither_strength: document.getElementById('photo_dither_strength').value,
});
});
document.getElementById('photo-palette-reset').addEventListener('click', () => {
const inputs = paletteHexInputs('photo-palette');
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
inputs[i].value = hex;
syncPaletteFromHex('photo-palette', i);
});
document.getElementById('photo_dither_strength').value = '1';
document.getElementById('photo_dither_strength_value').textContent = '1.00';
savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' });
});
// ---- Theme ----
document.getElementById('theme-save').addEventListener('click', async () => {
const body = new URLSearchParams({ theme: document.getElementById('theme-select').value });
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadPreview();
} catch (e) {
showStatus(false, e.message);
}
});
// ---- Preview: current photo vs. how it renders with saved settings ----
// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API,
// set by the template) rather than window.FRAME_API -- palette/color/
// contrast/dither are frame-level, but "the current photo" to preview
// them against is necessarily one specific photo widget's. Null (no
// photo widget on this frame) means the template didn't render the
// preview section at all -- nothing to wire up.
function loadPreview() {
if (!window.PHOTO_WIDGET_PREVIEW_API) return;
const bust = Date.now(); // avoid a stale cached image after settings change
document.getElementById('preview-original').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/original?_=${bust}`;
document.getElementById('preview-rendered').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/rendered?_=${bust}`;
}
const previewRefreshBtn = document.getElementById('preview-refresh');
if (previewRefreshBtn) previewRefreshBtn.addEventListener('click', loadPreview);
loadPreview();
// ---- Battery alerts card ----
document.getElementById('battery-alert-save').addEventListener('click', async () => {
const raw = document.getElementById('battery_alert_threshold_pct').value.trim();
const body = new URLSearchParams({
battery_alert_threshold_pct: raw === '' ? '-1' : raw,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
} catch (e) {
showStatus(false, e.message);
}
});
// ---- Firmware card ----
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(`${window.FRAME_API}/firmware`, { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(await apiError(resp));
}
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);
}
});
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 {
const body = new URLSearchParams({
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(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
loadFirmwareCheck();
} 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(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''), { method: 'POST' });
if (!resp.ok) {
if (force) {
showStatus(false, await apiError(resp));
}
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}` +
(data.running_version ? ` (currently running v${data.running_version}).` : '.');
btn.style.display = 'inline-block';
} else if (data.latest_version && data.running_version && data.running_version !== data.latest_version) {
// Already staged (or auto-applied) but the frame hasn't woken up
// and picked it up yet -- not "up to date" until it actually has.
statusEl.textContent = `v${data.latest_version} staged -- applies next time the frame wakes ` +
`(currently running v${data.running_version}).`;
btn.style.display = 'none';
} 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; 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(`${window.FRAME_API}/firmware/apply-latest`, { method: 'POST' });
if (!resp.ok) {
throw new Error(await apiError(resp));
}
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;
}
});
loadControl();
loadFirmwareCheck();
// The server throttles actual Gitea API calls itself, so this poll is
// cheap either way.
setInterval(loadFirmwareCheck, 60000);