diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 29dbdf1..da4d7cd 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -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 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); }); +} diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js index edb13be..6452484 100644 --- a/frontend/js/views/content-library.js +++ b/frontend/js/views/content-library.js @@ -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 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 = ` ` : ''} `; + // 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] || '⚙'} ` : a.thumbnail_path - ? `` + ? `` : `
@@ -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 => `
${c.thumbnail_path - ? `` + ? `` : c.remote_url ? `
@@ -1449,6 +1454,7 @@ async function setupPlaylistActions(device) {
`; document.body.appendChild(modal); + hydrateAuthImages(modal); // Tab switching modal.querySelectorAll('.assign-tab').forEach(tab => { diff --git a/frontend/js/views/playlists.js b/frontend/js/views/playlists.js index 69daa6c..3fbc36f 100644 --- a/frontend/js/views/playlists.js +++ b/frontend/js/views/playlists.js @@ -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) {
${i + 1}
${item.thumbnail_path - ? `` + ? `` : `
${getTypeIcon(item)}
` }
@@ -411,6 +411,7 @@ function renderItems(items) {
`).join(''); + hydrateAuthImages(itemsEl); itemsEl.querySelectorAll('.item-duration').forEach(input => { input.addEventListener('change', async (e) => { @@ -676,7 +677,7 @@ async function showAddItemModal(playlistId, opts = {}) { return `
- ${thumb ? `` : '
'} + ${thumb ? `` : '
'}
${esc(name)}
@@ -686,6 +687,7 @@ async function showAddItemModal(playlistId, opts = {}) {
`; }).join(''); + hydrateAuthImages(list); list.querySelectorAll('.add-item-btn').forEach(btn => { btn.addEventListener('click', async (e) => { diff --git a/frontend/js/views/widgets.js b/frontend/js/views/widgets.js index 06cf613..8bae96c 100644 --- a/frontend/js/views/widgets.js +++ b/frontend/js/views/widgets.js @@ -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 = `
${ 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 `
- +
${escAttr(c.filename)}
${isSel ? '
' : ''}
`; }).join('') }
`; + 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 = `
- +
${escAttr(dirState.logo_url)}
@@ -457,6 +460,7 @@ export async function render(container) { box.innerHTML = ``; document.getElementById('wLogoChoose').onclick = pickLogo; } + hydrateAuthImages(box); } async function pickLogo() { @@ -474,11 +478,12 @@ export async function render(container) { list.innerHTML = `
${ dirState.background_images.map((u, i) => `
- +
`).join('') }
`; + hydrateAuthImages(list); list.querySelectorAll('[data-bg-remove]').forEach(b => b.onclick = () => { dirState.background_images.splice(+b.dataset.bgRemove, 1); renderBgList();