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 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
113 lines
5.9 KiB
JavaScript
113 lines
5.9 KiB
JavaScript
import { t } from './i18n.js';
|
|
|
|
// HTML escape helper — prevents XSS when inserting user data into innerHTML
|
|
export function esc(str) {
|
|
if (str == null) return '';
|
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
}
|
|
|
|
// v4 liveness badge. The patch4 server derives a 3-state liveness — 'healthy' / 'degraded'
|
|
// (temporarily reconnecting) / 'offline' — and emits it as `data.liveness` on dashboard:device-status.
|
|
// It is present on SOME emits only (the plain reconnect + disconnect emits, and any device object read
|
|
// from the DB, carry just the binary `status`), so we DEGRADE to the binary status when liveness is
|
|
// absent — nothing ever renders blank. 'provisioning' is a lifecycle state (never-paired), kept
|
|
// distinct from liveness. livenessState() is pure (unit-testable); livenessBadge() adds the i18n label.
|
|
const LIVENESS_LABEL_KEY = {
|
|
healthy: 'device.liveness.healthy',
|
|
degraded: 'device.liveness.degraded',
|
|
offline: 'device.liveness.offline',
|
|
provisioning: 'dashboard.awaiting_pairing',
|
|
};
|
|
export function livenessState(data) {
|
|
const lv = data && data.liveness;
|
|
if (lv === 'healthy' || lv === 'degraded' || lv === 'offline') return lv; // 3-state signal present
|
|
const st = data && data.status; // backward-compat: derive from binary status
|
|
if (st === 'provisioning') return 'provisioning';
|
|
if (st === 'online') return 'healthy';
|
|
if (st === 'offline') return 'offline';
|
|
return 'offline'; // unknown / no data yet -> safe default, never blank
|
|
}
|
|
// Exit-signal contract §8/§10 — honest, reliability-aware manner-of-death sub-label for an Offline
|
|
// device. clean_exit is RELIABLE only on the browser /player (pagehide+sendBeacon); best-effort on
|
|
// APK/.wgt, so we qualify it there rather than overstate certainty. crashed/silent are labeled plainly.
|
|
// Returns null when no reason is known (old data / never went offline) -> plain "Offline".
|
|
// short=true -> concise LIST label (drops the parenthetical qualifiers, which the tooltip still carries);
|
|
// full (default) -> DETAIL label with the reliability qualifier. Honesty is preserved either way: the
|
|
// full meaning lives in the tooltip (both views) and in the detail label.
|
|
function offlineReasonLabel(reason, clientType, short) {
|
|
if (reason === 'crashed') return t('device.exit.crashed');
|
|
if (reason === 'clean_exit') {
|
|
if (short) return t('device.exit.clean'); // list: "clean exit" (tooltip carries best-effort)
|
|
return clientType === 'player' ? t('device.exit.clean') : t('device.exit.clean_besteffort');
|
|
}
|
|
if (reason === 'silent') return short ? t('device.exit.silent_short') : t('device.exit.silent');
|
|
return null;
|
|
}
|
|
// Honest hover explanation of the manner of death — carries the contract's reliability (esp. 'silent'
|
|
// = external/violent, and best-effort clean_exit) so an operator isn't misled by a terse badge label.
|
|
function offlineReasonTip(reason, clientType) {
|
|
if (reason === 'crashed') return t('device.exit.crashed.tip');
|
|
if (reason === 'clean_exit') return clientType === 'player' ? t('device.exit.clean.tip') : t('device.exit.clean_besteffort.tip');
|
|
if (reason === 'silent') return t('device.exit.silent.tip');
|
|
return '';
|
|
}
|
|
export function livenessBadge(data, opts = {}) {
|
|
const state = livenessState(data);
|
|
let label = t(LIVENESS_LABEL_KEY[state]);
|
|
let title = '', reason = '';
|
|
if (state === 'offline') { // annotate Offline with the manner of death, if known
|
|
const r = data && data.offline_reason, ct = data && data.client_type;
|
|
const sub = offlineReasonLabel(r, ct, opts.short);
|
|
if (sub) { label += ' · ' + sub; title = offlineReasonTip(r, ct); reason = r || ''; }
|
|
}
|
|
return { state, label, title, reason }; // reason -> data-offline-reason (filter drill-in); '' unless offline+known
|
|
}
|
|
|
|
// Phase 2.1: the Phase 1 schema migration renamed the legacy 'superadmin'
|
|
// role to 'platform_admin'. Existing frontend checks still match the old
|
|
// string; this helper accepts both so we don't have to splatter the array
|
|
// at every call site. Use everywhere the UI gates on platform-level access.
|
|
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); });
|
|
}
|