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
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:
+6
-5
@@ -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.
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user