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).
127 lines
3.9 KiB
JavaScript
127 lines
3.9 KiB
JavaScript
// Photos tab: now-displaying, album picker, and the upcoming grid
|
||
// (rendering/drag logic in queue.js). window.FRAME_API is set by the
|
||
// template.
|
||
|
||
function renderControlBanner(control) {
|
||
const banner = document.getElementById('control-banner');
|
||
if (!banner) return;
|
||
if (!control || control.you) {
|
||
banner.style.display = 'none';
|
||
return;
|
||
}
|
||
banner.style.display = 'flex';
|
||
document.getElementById('control-holder').textContent = control.controller
|
||
? `${control.controller} currently has control of this frame.`
|
||
: 'Nobody has control of this frame yet.';
|
||
}
|
||
|
||
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.');
|
||
loadQueue();
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
}
|
||
|
||
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(`${window.FRAME_API}/queue`);
|
||
if (!resp.ok) {
|
||
currentEl.innerHTML =
|
||
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</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>';
|
||
}
|
||
renderControlBanner(data.control);
|
||
renderUpcoming(data.upcoming);
|
||
} catch (e) {
|
||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||
}
|
||
}
|
||
|
||
async function savePhotoSettings() {
|
||
const body = new URLSearchParams({
|
||
album_id: document.getElementById('album_id').value || '',
|
||
queue_target_len: document.getElementById('queue_target_len').value,
|
||
});
|
||
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));
|
||
}
|
||
}
|
||
|
||
document.getElementById('load-albums').addEventListener('click', async () => {
|
||
try {
|
||
const resp = await fetch(`${window.FRAME_API}/albums`);
|
||
if (!resp.ok) {
|
||
throw new Error(await apiError(resp));
|
||
}
|
||
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('photos-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
await savePhotoSettings();
|
||
showStatus(true, 'Saved.');
|
||
loadQueue();
|
||
} catch (e) {
|
||
showStatus(false, e.message);
|
||
}
|
||
});
|
||
|
||
document.getElementById('take-control').addEventListener('click', takeControl);
|
||
|
||
loadQueue();
|
||
// Slow poll: picks up real changes (new photo displayed, queue edited
|
||
// from elsewhere) without a manual refresh. Skipped mid-drag.
|
||
setInterval(loadQueue, 10000);
|