Build and push server image / build-and-push (push) Successful in 42s
Adds "Upcoming photos to show" to the config UI (queue_target_len, 5-50, default 20, replacing the hardcoded QUEUE_TARGET_LEN constant). Lowering it trims the queue immediately on next page load rather than waiting for enough advances to consume the excess naturally; raising it tops back up the same way, via a new photo_queue.sync_queue_length() called from GET /api/queue.
274 lines
11 KiB
HTML
274 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-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: 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; }
|
|
input, select { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
|
|
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
|
.checkbox-row input { width: auto; margin-top: 0; }
|
|
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
|
button { margin-top: 20px; padding: 10px 16px; border: none; border-radius: 4px; background: #2563eb; color: white; cursor: pointer; font-size: 14px; }
|
|
button:hover { background: #1d4ed8; }
|
|
button.secondary { background: #6b7280; margin-right: 8px; }
|
|
.status { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 14px; }
|
|
.status.ok { background: #dcfce7; color: #166534; }
|
|
.status.err { background: #fee2e2; color: #991b1b; }
|
|
.info-box { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 13px; background: #f3f4f6; color: #444; }
|
|
.info-box.warn { background: #fef9c3; color: #854d0e; }
|
|
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; }
|
|
|
|
.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>
|
|
|
|
<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>
|
|
<label>Upcoming photos to show
|
|
<select id="queue_target_len">
|
|
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
|
|
<option value="{{ n }}" {% if cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
<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>
|
|
</div>
|
|
|
|
<h2 class="section">Upcoming</h2>
|
|
<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');
|
|
|
|
function showStatus(ok, message) {
|
|
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
|
|
}
|
|
|
|
async function saveConfig() {
|
|
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
|
const body = new URLSearchParams({
|
|
album_id: document.getElementById('album_id').value || '',
|
|
order: document.getElementById('order').value,
|
|
refresh_interval_s: String(minutes * 60),
|
|
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
|
|
queue_target_len: document.getElementById('queue_target_len').value,
|
|
});
|
|
const resp = await fetch('/api/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body,
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(await resp.text());
|
|
}
|
|
}
|
|
|
|
document.getElementById('load-albums').addEventListener('click', async () => {
|
|
try {
|
|
await saveConfig();
|
|
|
|
const resp = await fetch('/api/albums');
|
|
if (!resp.ok) {
|
|
throw new Error(await resp.text());
|
|
}
|
|
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('config-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
await saveConfig();
|
|
showStatus(true, 'Saved.');
|
|
loadQueue();
|
|
} catch (e) {
|
|
showStatus(false, e.message);
|
|
}
|
|
});
|
|
|
|
let upcomingItems = [];
|
|
let dragFromIndex = 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.draggable = true;
|
|
|
|
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 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('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);
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
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',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ queue: items.map((item) => item.id) }),
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(await resp.text());
|
|
}
|
|
} catch (e) {
|
|
showStatus(false, e.message);
|
|
loadQueue();
|
|
}
|
|
}
|
|
|
|
async function loadQueue() {
|
|
const currentEl = document.getElementById('current-thumb');
|
|
try {
|
|
const resp = await fetch('/api/queue');
|
|
if (!resp.ok) {
|
|
currentEl.innerHTML = '<p class="sub">Not available yet -- configure Immich and an album first.</p>';
|
|
renderUpcoming([]);
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
currentEl.innerHTML = data.current
|
|
? `<img class="thumb" src="${data.current.thumbnail_url}">`
|
|
: '<p class="sub">Nothing displayed yet.</p>';
|
|
renderUpcoming(data.upcoming);
|
|
} catch (e) {
|
|
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
|
}
|
|
}
|
|
|
|
loadQueue();
|
|
</script>
|
|
</body>
|
|
</html>
|