mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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:
parent
b127ff5014
commit
00e8300af7
|
|
@ -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); });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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:'🕓',weather:'⛅',rss:'📰',text:'📝',webpage:'🌐',social:'💬'}[a.widget_type] || '⚙'}
|
||||
</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 => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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">✓</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">×</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">×</button>
|
||||
</div>
|
||||
`).join('')
|
||||
}</div>`;
|
||||
hydrateAuthImages(list);
|
||||
list.querySelectorAll('[data-bg-remove]').forEach(b => b.onclick = () => {
|
||||
dirState.background_images.splice(+b.dataset.bgRemove, 1);
|
||||
renderBgList();
|
||||
|
|
|
|||
Loading…
Reference in a new issue