'use strict'; // #73 agency portal. Token-auth ONLY (never the dashboard JWT). The access key lives in // sessionStorage (cleared on tab close — chosen over localStorage so it doesn't linger on a // shared agency machine) and is sent as a Bearer header. Any 401/403 resets to the entry // screen with a clear "key invalid" message — never a wall of 403s. The token is narrow // (agency scope), so even if leaked its blast radius is upload + drafts to designated // playlists, which the admin must publish. (function () { const KEY = 'agency_key'; const $ = (id) => document.getElementById(id); let uploadedContentId = null; const getKey = () => sessionStorage.getItem(KEY) || ''; const setKey = (k) => sessionStorage.setItem(KEY, k); const clearKey = () => sessionStorage.removeItem(KEY); function showEntry(msg) { $('portal').classList.add('hidden'); $('entry').classList.remove('hidden'); const m = $('entryMsg'); if (msg) { m.textContent = msg; m.style.display = 'block'; } else { m.style.display = 'none'; } } function showPortal() { $('entry').classList.add('hidden'); $('portal').classList.remove('hidden'); } function portalMsg(text, kind) { const m = $('portalMsg'); m.textContent = text || ''; m.className = 'msg ' + (kind || 'ok'); m.style.display = text ? 'block' : 'none'; } const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // Fetch /api/agency/* with the bearer key. On 401/403 -> graceful reset to entry. async function agencyFetch(path, opts = {}) { const headers = Object.assign({}, opts.headers, { Authorization: 'Bearer ' + getKey() }); const res = await fetch('/api/agency' + path, Object.assign({}, opts, { headers })); if (res.status === 401 || res.status === 403) { clearKey(); showEntry('That access key is invalid, revoked, or expired. Paste it again.'); throw new Error('auth'); } return res; } async function loadPortal() { let playlists; try { playlists = await (await agencyFetch('/playlists')).json(); } catch (e) { return; } // agencyFetch already reset to entry on an auth failure const sel = $('plSelect'); sel.innerHTML = playlists.length ? playlists.map(p => ``).join('') : ''; showPortal(); portalMsg('', ''); // #73: the placement card reacts to the playlist selector - "where does THIS playlist go?" sel.onchange = () => loadLayoutForPlaylist(sel.value); loadLayoutForPlaylist(sel.value); // initial selection } // Visual placement guide for the SELECTED playlist: draw its layout to scale, highlight the // GRANTED zone(s) with the px size to design for, show sibling zones as context (geometry // only - no content, no device/screen data; the endpoint is device-free). async function loadLayoutForPlaylist(playlistId) { const card = $('placementCard'), view = $('layoutView'); if (!playlistId) { card.style.display = 'none'; return; } let layouts; try { layouts = await (await agencyFetch('/playlists/' + encodeURIComponent(playlistId) + '/layout')).json(); } catch (e) { return; } card.style.display = 'block'; if (!layouts.length) { view.innerHTML = '
This playlist plays full-screen — design for the full display.
'; return; } view.innerHTML = layouts.map(l => { const mine = new Set(l.feeds_zone_ids); const aspect = (l.height / l.width) * 100; // padding-bottom % = aspect ratio const zones = l.zones.map(z => { const isMine = mine.has(z.id); const wpx = Math.round(l.width * z.width_percent / 100); const hpx = Math.round(l.height * z.height_percent / 100); return ``; }).join(''); return `