Web UI: grid layout, drag-and-drop reorder, and "show next" for upcoming photos
Build and push server image / build-and-push (push) Successful in 32s

Replaces the up/down-button vertical list with a responsive photo grid
(native HTML5 drag-and-drop between cards, reusing the existing
POST /api/queue/reorder endpoint -- no new server route needed). Each
card also gets a "Show next" button that jumps it straight to the front
of the queue.

Also bumps the queue lookahead from 10 to 24 photos (QUEUE_TARGET_LEN in
photo_queue.py) now that the grid has room to show more at once.
This commit is contained in:
2026-07-19 00:57:13 -04:00
parent f74085cedf
commit de41b00210
3 changed files with 121 additions and 76 deletions
+6 -5
View File
@@ -64,11 +64,12 @@ algorithm itself -- it just streams the response straight to the panel.
mount. Immich URL/API key are too if set via the web UI, but
`IMMICH_URL`/`IMMICH_API_KEY` env vars (see Setup above) always take
precedence when present.
- The upcoming queue is a bounded lookahead (10 photos), not the whole
album -- it's topped up automatically as photos are consumed
(`app/photo_queue.py`), in sequential or shuffle order per the Order
setting. Reordering only rearranges those 10; it doesn't add or remove
photos from the album.
- The upcoming queue is a bounded lookahead (`QUEUE_TARGET_LEN` in
`app/photo_queue.py`, currently 24 photos), not the whole album -- it's
topped up automatically as photos are consumed, in sequential or
shuffle order per the Order setting. Dragging photos in the web UI (or
using "Show next") only rearranges what's already in that lookahead;
it doesn't add or remove photos from the album.
- `/frame/image` and `/frame/advance` aren't authenticated yet. That's
fine on a trusted home LAN for now, but worth revisiting once the ESP32
side is wired up to send a shared device token.
+1 -1
View File
@@ -20,7 +20,7 @@ import time
from .config import FrameConfig
QUEUE_TARGET_LEN = 10
QUEUE_TARGET_LEN = 24
def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
+114 -70
View File
@@ -5,7 +5,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; }
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 16px; color: #222; }
.config-panel { max-width: 480px; }
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; }
@@ -24,58 +25,69 @@
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; }
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
.photo-card { position: relative; cursor: grab; border-radius: 6px; overflow: hidden; aspect-ratio: 1; background: #f3f4f6; border: 1px solid #e5e7eb; }
.photo-card:active { cursor: grabbing; }
.photo-card.dragging { opacity: 0.35; }
.photo-card.drag-over { outline: 3px solid #2563eb; outline-offset: -3px; }
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: rgba(0, 0, 0, 0.6); color: white; font-size: 10px; padding: 2px 6px; border-radius: 3px; }
.photo-card .show-next {
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.92); border-radius: 4px;
opacity: 0; transition: opacity 0.15s;
}
.photo-card:hover .show-next, .photo-card:focus-within .show-next { opacity: 1; }
</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 %}
<div class="config-panel">
{% 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>
<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">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
</div>
<h2 class="section">Upcoming</h2>
<ul id="upcoming-list"></ul>
<p class="sub">Drag a photo to reorder, or use "Show next" to jump it to the front.</p>
<div id="upcoming-grid" class="photo-grid"></div>
<script>
const resultEl = document.getElementById('result');
@@ -138,49 +150,81 @@
});
let upcomingItems = [];
let dragFromIndex = null;
function renderUpcoming(items) {
upcomingItems = items;
const list = document.getElementById('upcoming-list');
list.innerHTML = '';
const grid = document.getElementById('upcoming-grid');
grid.innerHTML = '';
items.forEach((item, i) => {
const li = document.createElement('li');
li.className = 'queue-item';
const card = document.createElement('div');
card.className = 'photo-card';
card.draggable = true;
const img = document.createElement('img');
img.src = item.thumbnail_url;
li.appendChild(img);
img.alt = '';
card.appendChild(img);
const spacer = document.createElement('span');
spacer.className = 'spacer';
li.appendChild(spacer);
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = String(i + 1);
card.appendChild(badge);
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.textContent = '';
upBtn.disabled = i === 0;
upBtn.addEventListener('click', () => moveItem(i, -1));
li.appendChild(upBtn);
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);
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);
card.addEventListener('dragstart', (e) => {
dragFromIndex = i;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
dragFromIndex = null;
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
card.classList.add('drag-over');
});
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', (e) => {
e.preventDefault();
card.classList.remove('drag-over');
if (dragFromIndex !== null && dragFromIndex !== i) {
moveItem(dragFromIndex, i);
}
});
list.appendChild(li);
grid.appendChild(card);
});
}
async function moveItem(index, delta) {
const newIndex = index + delta;
if (newIndex < 0 || newIndex >= upcomingItems.length) {
return;
}
function moveItem(fromIndex, toIndex) {
const items = upcomingItems.slice();
[items[index], items[newIndex]] = [items[newIndex], items[index]];
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
renderUpcoming(items);
persistOrder(items);
}
function showNext(index) {
const items = upcomingItems.slice();
const [moved] = items.splice(index, 1);
items.unshift(moved);
renderUpcoming(items);
persistOrder(items);
}
async function persistOrder(items) {
try {
const resp = await fetch('/api/queue/reorder', {
method: 'POST',