fix(dashboard): use data-auth-src for thumbnail images in modals and views (#182)

* fix(dashboard): use data-auth-src for thumbnail images in modals and views

Plain <img src> tags can't send the Bearer token, causing 403 on
/api/content/:id/thumbnail. Extracted loadAuthImage/hydrateAuthImages
from content-library.js into utils.js and applied the data-auth-src
pattern to playlists, device-detail, and widgets views.

Closes thumbnail rendering in:
- Playlist items list and add-item modal
- Device assignment list and assign-content modal
- Widget content picker, logo, and background images

* fix(dashboard): add requestAnimationFrame fallback for auth image hydration

The IntersectionObserver callback fires asynchronously and may miss
images on first render when the DOM layout isn't settled yet. Add a
rAF fallback that manually loads any still-unloaded images visible
within the viewport (same 300px margin as the observer).

* fix(dashboard): load visible auth images synchronously, not via observer

getBoundingClientRect() forces layout synchronously so visible images
load immediately. IntersectionObserver is now only used for lazy-
loading off-screen images. This eliminates the async timing gap on
first render where neither the observer callback nor rAF would fire.

* fix(dashboard): load all auth images immediately, skip visibility check

Simplifies hydrateAuthImages to load every img[data-auth-src] directly.
loadAuthImage deletes the attribute so observer double-fire is safe.
This eliminates any possible IntersectionObserver/BoundingClientRect
timing issues on first render.

* debug: add console logs to trace auth image hydration flow

* fix(dashboard): hydrate auth images in device detail initial load

loadDevice() renders the playlist tab with data-auth-src images
but never called hydrateAuthImages. Only the playlist-switch path
(line 1022) had the hydrate call. Added hydrateAuthImages to the
initial contentEl.innerHTML render so thumbnails load on first view.

* chore: remove debug logs, final clean version
This commit is contained in:
Fabian Mendoza 2026-07-14 14:15:30 -04:00 committed by GitHub
parent b127ff5014
commit 00e8300af7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 64 additions and 41 deletions

View file

@ -70,3 +70,43 @@ export function livenessBadge(data, opts = {}) {
export function isPlatformAdmin(user) {
return !!(user && (user.role === 'superadmin' || user.role === 'platform_admin'));
}
// Lazy-load authenticated images. A plain <img> can't send the Bearer token,
// and thumbnail/file endpoints require auth — a just-uploaded item's thumbnail
// 403's without it. We fetch with the token and swap in an object URL.
// IntersectionObserver keeps it lazy; the object URL is revoked after load.
let _authImgObserver = null;
export function loadAuthImage(img) {
const url = img.dataset.authSrc;
if (!url) return;
delete img.dataset.authSrc;
fetch(url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })
.then(r => (r.ok ? r.blob() : Promise.reject(r.status)))
.then(blob => {
const obj = URL.createObjectURL(blob);
img.addEventListener('load', () => URL.revokeObjectURL(obj), { once: true });
img.src = obj;
})
.catch(() => { img.style.opacity = '0.25'; });
}
export function hydrateAuthImages(root) {
const imgs = root.querySelectorAll('img[data-auth-src]');
if (!imgs.length) return;
// Load all images immediately; IntersectionObserver is used below
// only for images that are off-screen (lazy loading).
if (typeof IntersectionObserver === 'undefined') {
imgs.forEach(loadAuthImage);
return;
}
if (!_authImgObserver) {
_authImgObserver = new IntersectionObserver((entries, obs) => {
for (const e of entries) if (e.isIntersecting) { obs.unobserve(e.target); loadAuthImage(e.target); }
}, { rootMargin: '300px' });
}
// Load every image now — the observer will also fire for them but
// loadAuthImage is idempotent (deletes data-auth-src on first call).
imgs.forEach(img => { loadAuthImage(img); _authImgObserver.observe(img); });
}

View file

@ -1,6 +1,6 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { esc, hydrateAuthImages } from '../utils.js';
import { t } from '../i18n.js';
function formatFileSize(bytes) {
@ -34,36 +34,6 @@ function toLocalDatetimeInput(epochSec) {
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
// Lazy-load authenticated thumbnails/previews. A plain <img> can't send the
// Bearer token, and the content thumbnail/file endpoints require auth (or a
// playlist/widget reference) - so a just-uploaded item's thumbnail 403'd. We fetch
// with the token and swap in an object URL. IntersectionObserver keeps it lazy so
// we stay under the /api/content rate limit; the object URL is revoked after load.
let _authImgObserver = null;
function loadAuthImage(img) {
const url = img.dataset.authSrc;
if (!url) return;
delete img.dataset.authSrc;
fetch(url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })
.then(r => (r.ok ? r.blob() : Promise.reject(r.status)))
.then(blob => {
const obj = URL.createObjectURL(blob);
img.addEventListener('load', () => URL.revokeObjectURL(obj), { once: true });
img.src = obj;
})
.catch(() => { img.style.opacity = '0.25'; });
}
function hydrateAuthImages(root) {
const imgs = root.querySelectorAll('img[data-auth-src]');
if (typeof IntersectionObserver === 'undefined') { imgs.forEach(loadAuthImage); return; }
if (!_authImgObserver) {
_authImgObserver = new IntersectionObserver((entries, obs) => {
for (const e of entries) if (e.isIntersecting) { obs.unobserve(e.target); loadAuthImage(e.target); }
}, { rootMargin: '300px' });
}
imgs.forEach(img => _authImgObserver.observe(img));
}
export function render(container) {
container.innerHTML = `
<div class="page-header">

View file

@ -1,7 +1,7 @@
import { api } from '../api.js';
import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendSwipe, sendKey, sendCommand } from '../socket.js';
import { showToast } from '../components/toast.js';
import { esc, livenessBadge } from '../utils.js';
import { esc, livenessBadge, hydrateAuthImages } from '../utils.js';
import { t, tn } from '../i18n.js';
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
@ -614,6 +614,9 @@ async function loadDevice(deviceId, activeTab = null) {
<div style="font-size:10px;color:var(--text-muted);margin-top:4px">${t('device.terminal.push_apk_hint')}</div>
</div>` : ''}
`;
// Hydrate authenticated thumbnail images in the playlist tab
const pc = document.getElementById('playlistContainer');
if (pc) hydrateAuthImages(pc);
// Global key/command handlers for remote
window._sendKey = (keycode) => {
@ -719,7 +722,7 @@ function renderPlaylist(assignments) {
${{clock:'&#128339;',weather:'&#9925;',rss:'&#128240;',text:'&#128221;',webpage:'&#127760;',social:'&#128172;'}[a.widget_type] || '&#9881;'}
</div>`
: a.thumbnail_path
? `<img class="playlist-item-thumb" src="/api/content/${a.content_id}/thumbnail" alt="">`
? `<img class="playlist-item-thumb" data-auth-src="/api/content/${a.content_id}/thumbnail" alt="">`
: `<div class="playlist-item-thumb" style="display:flex;align-items:center;justify-content:center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
@ -1019,7 +1022,9 @@ function setupActions(device) {
await api.assignPlaylistToDevice(newPlaylistId, device.id);
device.playlist_id = newPlaylistId;
const assignments = await api.getAssignments(device.id);
document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments);
const pc = document.getElementById('playlistContainer');
pc.innerHTML = renderPlaylist(assignments);
hydrateAuthImages(pc);
attachRemoveHandlers(device);
showToast(t('device.toast.playlist_changed'));
} catch (err) {
@ -1406,7 +1411,7 @@ async function setupPlaylistActions(device) {
${content.map(c => `
<div class="assign-content-item" data-content-id="${c.id}" data-type="content">
${c.thumbnail_path
? `<img src="/api/content/${c.id}/thumbnail" alt="">`
? `<img data-auth-src="/api/content/${c.id}/thumbnail" alt="">`
: c.remote_url
? `<div style="aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;background:var(--bg-primary)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="1.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
@ -1449,6 +1454,7 @@ async function setupPlaylistActions(device) {
</div>
`;
document.body.appendChild(modal);
hydrateAuthImages(modal);
// Tab switching
modal.querySelectorAll('.assign-tab').forEach(tab => {

View file

@ -1,6 +1,6 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { esc, hydrateAuthImages } from '../utils.js';
import { t, tn } from '../i18n.js';
function formatDate(ts) {
@ -373,7 +373,7 @@ function renderItems(items) {
<div style="color:var(--text-muted);font-size:12px;min-width:24px;text-align:center;user-select:none">${i + 1}</div>
<div style="width:48px;height:36px;border-radius:4px;overflow:hidden;background:var(--bg-input);flex-shrink:0;display:flex;align-items:center;justify-content:center">
${item.thumbnail_path
? `<img src="/api/content/${esc(item.content_id)}/thumbnail" style="width:100%;height:100%;object-fit:cover">`
? `<img data-auth-src="/api/content/${esc(item.content_id)}/thumbnail" style="width:100%;height:100%;object-fit:cover">`
: `<div style="color:var(--text-muted);opacity:0.5">${getTypeIcon(item)}</div>`
}
</div>
@ -411,6 +411,7 @@ function renderItems(items) {
</div>
</div>
`).join('');
hydrateAuthImages(itemsEl);
itemsEl.querySelectorAll('.item-duration').forEach(input => {
input.addEventListener('change', async (e) => {
@ -676,7 +677,7 @@ async function showAddItemModal(playlistId, opts = {}) {
return `
<div class="add-item-row" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}" style="display:flex;align-items:center;gap:12px;padding:10px;border-radius:var(--radius);cursor:pointer;transition:background 0.1s">
<div style="width:40px;height:30px;border-radius:4px;overflow:hidden;background:var(--bg-input);flex-shrink:0;display:flex;align-items:center;justify-content:center">
${thumb ? `<img src="${thumb}" style="width:100%;height:100%;object-fit:cover">` : '<div style="color:var(--text-muted);opacity:0.4"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg></div>'}
${thumb ? `<img data-auth-src="${thumb}" style="width:100%;height:100%;object-fit:cover">` : '<div style="color:var(--text-muted);opacity:0.4"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg></div>'}
</div>
<div style="flex:1;min-width:0">
<div style="font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(name)}</div>
@ -686,6 +687,7 @@ async function showAddItemModal(playlistId, opts = {}) {
</div>
`;
}).join('');
hydrateAuthImages(list);
list.querySelectorAll('.add-item-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {

View file

@ -1,5 +1,6 @@
import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
import { hydrateAuthImages } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
@ -63,15 +64,17 @@ function openContentPicker({ multiple = false, title } = {}) {
list.innerHTML = `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px">${
filtered.map(c => {
const isSel = selected.has(c.id);
const isRemote = !!c.remote_url;
const thumb = c.remote_url || `/api/content/${c.id}/thumbnail`;
return `
<div data-pick-id="${escAttr(c.id)}" style="position:relative;cursor:pointer;border-radius:6px;overflow:hidden;border:2px solid ${isSel ? 'var(--primary, #4a7cff)' : 'transparent'};aspect-ratio:4/3;background:var(--bg-input)">
<img src="${escAttr(thumb)}" style="width:100%;height:100%;object-fit:cover" loading="lazy" onerror="this.style.opacity='0.2'">
<img ${isRemote ? `src="${escAttr(thumb)}"` : `data-auth-src="${escAttr(thumb)}"`} style="width:100%;height:100%;object-fit:cover" loading="lazy" onerror="this.style.opacity='0.2'">
<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,0.75);color:#fff;padding:4px 6px;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escAttr(c.filename)}</div>
${isSel ? '<div style="position:absolute;top:6px;right:6px;width:22px;height:22px;background:var(--primary, #4a7cff);color:#fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;line-height:1">&#10003;</div>' : ''}
</div>`;
}).join('')
}</div>`;
hydrateAuthImages(list);
list.querySelectorAll('[data-pick-id]').forEach(el => el.onclick = () => {
const id = el.dataset.pickId;
if (multiple) {
@ -446,7 +449,7 @@ export async function render(container) {
if (dirState.logo_url) {
box.innerHTML = `
<div style="display:flex;align-items:center;gap:10px;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input)">
<img src="${escAttr(dirState.logo_url)}" style="max-height:50px;max-width:120px;object-fit:contain;background:#0003;border-radius:3px" onerror="this.style.opacity='0.3'">
<img ${dirState.logo_url && dirState.logo_url.startsWith('/api/') ? `data-auth-src="${escAttr(dirState.logo_url)}"` : `src="${escAttr(dirState.logo_url)}"`} style="max-height:50px;max-width:120px;object-fit:contain;background:#0003;border-radius:3px" onerror="this.style.opacity='0.3'">
<div style="flex:1;min-width:0;font-size:11px;color:var(--text-muted);word-break:break-all;overflow:hidden;text-overflow:ellipsis">${escAttr(dirState.logo_url)}</div>
<button type="button" class="btn btn-secondary btn-sm" id="wLogoChange">${t('widget.dir.change')}</button>
<button type="button" class="btn-icon" id="wLogoClear" title="${t('widget.dir.remove_logo')}" style="color:#ff6b6b;padding:4px 8px">&#215;</button>
@ -457,6 +460,7 @@ export async function render(container) {
box.innerHTML = `<button type="button" class="btn btn-secondary btn-sm" id="wLogoChoose">${t('widget.dir.choose_logo')}</button>`;
document.getElementById('wLogoChoose').onclick = pickLogo;
}
hydrateAuthImages(box);
}
async function pickLogo() {
@ -474,11 +478,12 @@ export async function render(container) {
list.innerHTML = `<div style="display:flex;gap:8px;flex-wrap:wrap">${
dirState.background_images.map((u, i) => `
<div style="position:relative;width:90px;height:68px;border-radius:4px;overflow:hidden;background:var(--bg-input);border:1px solid var(--border)">
<img src="${escAttr(u)}" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">
<img ${u && u.startsWith('/api/') ? `data-auth-src="${escAttr(u)}"` : `src="${escAttr(u)}"`} style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">
<button type="button" data-bg-remove="${i}" title="${t('widget.dir.remove_bg')}" style="position:absolute;top:3px;right:3px;width:22px;height:22px;border-radius:50%;border:0;background:rgba(0,0,0,0.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;padding:0">&#215;</button>
</div>
`).join('')
}</div>`;
hydrateAuthImages(list);
list.querySelectorAll('[data-bg-remove]').forEach(b => b.onclick = () => {
dirState.background_images.splice(+b.dataset.bgRemove, 1);
renderBgList();