Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.
Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.
API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.
UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.
Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
242 lines
8.0 KiB
JavaScript
242 lines
8.0 KiB
JavaScript
// 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>/widgets/<widget_id>' (set
|
||
// by frame_layout.js when the photos dialog opens), and a loadQueue()
|
||
// global (widget_dialog_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();
|
||
}
|
||
}
|