Files
espresso_frame/server/app/static/queue.js
T
tfaour 8ac3fc0de3
Build and push server image / build-and-push (push) Successful in 43s
Redesign phase D: sidebar app shell, per-frame tabs, namespaced API
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).
2026-07-21 23:56:18 -04:00

241 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The upcoming-photos grid: rendering plus drag-to-reorder. Ported
// intact from the original single-page UI -- the Pointer Events state
// machine below (hold-to-arm on touch so page scrolling still works) is
// battle-tested; treat changes with suspicion.
//
// Expects window.FRAME_API = '/api/frames/<id>' set by the page, and a
// loadQueue() global (frame_photos.js) to refetch authoritative state.
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.
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(`${window.FRAME_API}/queue/promote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
} 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(`${window.FRAME_API}/queue/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue();
}
async function persistOrder(items) {
try {
const resp = await fetch(`${window.FRAME_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 apiError(resp));
}
} catch (e) {
showStatus(false, e.message);
loadQueue();
}
}