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,'''); } // 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 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, 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'; }); } // Hydrate under `root`. Lazy by default: an // IntersectionObserver loads each thumbnail only as it scrolls into view, so a // large grid (content library, etc.) doesn't fire a fetch per thumbnail on // render. Pass { eager: true } for small, transient surfaces (pickers/modals) // where every item is on screen and immediate load reads better. export function hydrateAuthImages(root, { eager = false } = {}) { const imgs = root.querySelectorAll('img[data-auth-src]'); if (!imgs.length) return; // Eager path (opt-in) and the no-IntersectionObserver fallback both load now. if (eager || 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)); }