Files
espresso_frame/server/app/templates/index.html
T
tfaour d395cf3bb9
Build and push server image / build-and-push (push) Successful in 35s
Add two physical buttons: factory-reset and next-photo
Factory-reset (GPIO3, hold 10s): clears stored WiFi/server config and
restarts into provisioning -- the deliberate, USB-free replacement for
the earlier reverted RST-based auto-reprovisioning idea.

Next-photo (GPIO2, tap): wakes the device and forces the server to
advance immediately via a new POST /frame/advance, instead of waiting
for the refresh interval. Both buttons arm themselves as deep-sleep GPIO
wakeup sources so a press is noticed promptly even while asleep.

Also makes GET /frame/image side-effect-free: it now only advances once
refresh_interval_s has elapsed since the current photo was set (tracked
server-side), so a device reboot for any reason just redisplays the
current photo instead of silently skipping ahead. The server maintains a
small reorderable upcoming-photos queue, viewable and rearrangeable from
the web UI.
2026-07-18 23:28:36 -04:00

222 lines
8.5 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESPresso Frame</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 40px auto; padding: 0 16px; color: #222; }
h1 { font-size: 20px; }
p.sub { color: #666; font-size: 14px; margin-top: -8px; }
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; }
input, select { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
.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: 4px; background: #2563eb; color: white; cursor: pointer; font-size: 14px; }
button:hover { background: #1d4ed8; }
button.secondary { background: #6b7280; margin-right: 8px; }
.status { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 14px; }
.status.ok { background: #dcfce7; color: #166534; }
.status.err { background: #fee2e2; color: #991b1b; }
.info-box { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 13px; background: #f3f4f6; color: #444; }
.info-box.warn { background: #fef9c3; color: #854d0e; }
code { background: #f3f4f6; padding: 2px 5px; border-radius: 3px; }
h2.section { font-size: 16px; margin-top: 28px; margin-bottom: 8px; }
.thumb { width: 160px; max-width: 100%; border-radius: 4px; display: block; }
#upcoming-list { list-style: none; padding: 0; margin: 0; }
.queue-item { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid #eee; }
.queue-item img { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; }
.queue-item .spacer { flex: 1; }
.queue-item button { margin: 0; padding: 4px 10px; font-size: 13px; background: #6b7280; }
.queue-item button:disabled { opacity: 0.35; cursor: default; }
</style>
</head>
<body>
<h1>ESPresso Frame</h1>
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
{% if cfg.immich_url %}
<div class="info-box">Immich: <code>{{ cfg.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 %}
<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>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>
<button type="button" class="secondary" id="load-albums">Load Albums</button>
<button type="submit">Save</button>
</form>
<div id="result"></div>
<h2 class="section">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
<h2 class="section">Upcoming</h2>
<ul id="upcoming-list"></ul>
<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,
refresh_interval_s: String(minutes * 60),
smart_crop_faces: String(document.getElementById('smart_crop_faces').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);
}
});
let upcomingItems = [];
function renderUpcoming(items) {
upcomingItems = items;
const list = document.getElementById('upcoming-list');
list.innerHTML = '';
items.forEach((item, i) => {
const li = document.createElement('li');
li.className = 'queue-item';
const img = document.createElement('img');
img.src = item.thumbnail_url;
li.appendChild(img);
const spacer = document.createElement('span');
spacer.className = 'spacer';
li.appendChild(spacer);
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.textContent = '↑';
upBtn.disabled = i === 0;
upBtn.addEventListener('click', () => moveItem(i, -1));
li.appendChild(upBtn);
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.textContent = '↓';
downBtn.disabled = i === items.length - 1;
downBtn.addEventListener('click', () => moveItem(i, 1));
li.appendChild(downBtn);
list.appendChild(li);
});
}
async function moveItem(index, delta) {
const newIndex = index + delta;
if (newIndex < 0 || newIndex >= upcomingItems.length) {
return;
}
const items = upcomingItems.slice();
[items[index], items[newIndex]] = [items[newIndex], items[index]];
renderUpcoming(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();
}
}
async function loadQueue() {
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 = data.current
? `<img class="thumb" src="${data.current.thumbnail_url}">`
: '<p class="sub">Nothing displayed yet.</p>';
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
loadQueue();
</script>
</body>
</html>