diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 22741ca..c10cce8 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -199,6 +199,7 @@ export default { 'device.back': 'Zurück zu Bildschirmen', 'device.owner_label': 'Besitzer: {owner}', 'device.rename': 'Umbenennen', + 'device.preview_btn': 'Vorschau', 'device.screenshot_btn': 'Screenshot', 'device.remove': 'Entfernen', 'device.click_to_confirm': 'Erneut klicken zum Bestätigen', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 7b9d0ea..cade7a6 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -214,6 +214,7 @@ export default { 'device.back': 'Back to Displays', 'device.owner_label': 'Owner: {owner}', 'device.rename': 'Rename', + 'device.preview_btn': 'Preview', 'device.screenshot_btn': 'Screenshot', 'device.remove': 'Remove', 'device.click_to_confirm': 'Click again to confirm', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 0b5e572..5b32161 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -198,6 +198,7 @@ export default { 'device.back': 'Volver a Pantallas', 'device.owner_label': 'Propietario: {owner}', 'device.rename': 'Renombrar', + 'device.preview_btn': 'Vista previa', 'device.screenshot_btn': 'Captura', 'device.remove': 'Eliminar', 'device.click_to_confirm': 'Haz clic de nuevo para confirmar', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 1a79f68..9f38035 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -199,6 +199,7 @@ export default { 'device.back': 'Retour aux écrans', 'device.owner_label': 'Propriétaire : {owner}', 'device.rename': 'Renommer', + 'device.preview_btn': 'Aperçu', 'device.screenshot_btn': 'Capture', 'device.remove': 'Retirer', 'device.click_to_confirm': 'Cliquez à nouveau pour confirmer', diff --git a/frontend/js/i18n/it.js b/frontend/js/i18n/it.js index dac221f..4c13ee8 100644 --- a/frontend/js/i18n/it.js +++ b/frontend/js/i18n/it.js @@ -210,6 +210,7 @@ export default { 'device.back': 'Torna a Schermi', 'device.owner_label': 'Proprietario: {owner}', 'device.rename': 'Rinomina', + 'device.preview_btn': 'Anteprima', 'device.screenshot_btn': 'Screenshot', 'device.remove': 'Rimuovi', 'device.click_to_confirm': 'Clicca di nuovo per confermare', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index 15906cc..7da984a 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -199,6 +199,7 @@ export default { 'device.back': 'Voltar para Telas', 'device.owner_label': 'Proprietário: {owner}', 'device.rename': 'Renomear', + 'device.preview_btn': 'Pré-visualização', 'device.screenshot_btn': 'Captura', 'device.remove': 'Remover', 'device.click_to_confirm': 'Clique novamente para confirmar', diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index f57a46f..0cb65df 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -153,6 +153,7 @@ async function loadDevice(deviceId, activeTab = null) { ${device.owner_name || device.owner_email ? `${t('device.owner_label', { owner: device.owner_name || device.owner_email })}` : ''}
+ +
+
+ +
+ `; + document.body.appendChild(overlay); + const close = () => overlay.remove(); + overlay.querySelector('#dpvClose').onclick = close; + overlay.onclick = (e) => { if (e.target === overlay) close(); }; + document.addEventListener('keydown', function esc2(ev) { + if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc2); } + }); +} + async function setupActions(device) { + // #104 Preview button + document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device)); + // Screenshot button document.getElementById('screenshotBtn')?.addEventListener('click', () => { requestScreenshot(device.id); diff --git a/server/player/index.html b/server/player/index.html index 6fa9610..d36533f 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -522,8 +522,8 @@ // pairing and NO socket. Gated here, before the normal boot, so the unpaired // auto-connect timer below can never fire underneath a preview. const _previewQS = new URLSearchParams(location.search); - if (_previewQS.get('preview') === '1' && _previewQS.get('playlist')) { - bootPreview(_previewQS.get('playlist'), _previewQS.get('orientation')); + if (_previewQS.get('preview') === '1' && (_previewQS.get('playlist') || _previewQS.get('device'))) { + bootPreview(_previewQS); } else { // Auto-detect server URL from origin since player is served from the same server @@ -642,22 +642,40 @@ document.getElementById('connectBtn').onclick = connectBtnFunc; // ==================== #104 Device-free preview ==================== - // Fetch a draft playlist's preview payload (same shape the device socket sends) - // and hand it straight to the UNMODIFIED renderer. No socket, no pairing. - async function bootPreview(playlistId, orientation) { + // #104: device-free dashboard preview. Renders EITHER a draft playlist + // (?playlist=ID — layout DERIVED from the playlist's zones, orientation togglable) + // OR a device exactly as it shows now (?device=ID — layout/orientation from the + // DEVICE row). Both produce the same payload shape and feed the UNMODIFIED renderer. + function bootPreview(qs) { + const playlistId = qs.get('playlist'); + const deviceId = qs.get('device'); + let url; + if (playlistId) { + const orientation = qs.get('orientation'); + const q = orientation ? ('?orientation=' + encodeURIComponent(orientation)) : ''; + url = '/api/playlists/' + encodeURIComponent(playlistId) + '/preview-payload' + q; + } else { + // Device preview: the device's own layout/orientation come from the server; no + // orientation override (we show what the device actually shows). + url = '/api/devices/' + encodeURIComponent(deviceId) + '/preview-payload'; + } + return renderPreviewFromUrl(url); + } + + // Shared: fetch a preview payload (same shape the device socket sends) and hand it + // straight to the UNMODIFIED renderer. No socket, no pairing. + async function renderPreviewFromUrl(url) { PREVIEW_MODE = true; config.serverUrl = window.location.origin; // same-origin -> /uploads + /api/widgets resolve const setup = document.getElementById('setupScreen'); if (setup) setup.style.display = 'none'; try { const token = localStorage.getItem('token'); // same-origin: shares the dashboard's Bearer token - const q = orientation ? ('?orientation=' + encodeURIComponent(orientation)) : ''; - const res = await fetch('/api/playlists/' + encodeURIComponent(playlistId) + '/preview-payload' + q, { - headers: token ? { Authorization: 'Bearer ' + token } : {}, - }); + const res = await fetch(url, { headers: token ? { Authorization: 'Bearer ' + token } : {} }); if (!res.ok) return showPreviewError(res.status); const payload = await res.json(); - // #104: items span >1 layout (rare) — server picked the dominant one; say so. + // playlist-only: items span >1 layout (rare) — server picked the dominant one. + // Device payloads never carry this flag (layout is device-bound, unambiguous). if (payload.layout && payload.layout._preview_ambiguous) { const b = document.createElement('div'); b.textContent = 'Previewing layout: ' + (payload.layout.name || '—'); diff --git a/server/routes/devices.js b/server/routes/devices.js index 5312a47..a9acde3 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -141,6 +141,27 @@ function checkDeviceOwnership(req, res) { return device; } +// #104: device-manager preview payload. Returns the device's CURRENT payload exactly +// as the device renders it — its OWN layout/orientation/wall from the device row and +// its published items — built by the same buildPlaylistPayload the device socket uses. +// Device-bound layout (the correct side of the layout seam); derivePreviewLayout is +// playlist-only and never touches this path. wall_config is forced null in v1: a wall +// FOLLOWER would otherwise freeze waiting for leader wall:sync that a socket-free +// preview can't deliver, so wall members preview full-frame. Device-READ gated +// (mirrors GET /:id — viewers allowed); NOT requirePlaylistRead, NOT the write gate. +router.get('/:id/preview-payload', (req, res) => { + const device = db.prepare('SELECT id, workspace_id FROM devices WHERE id = ?').get(req.params.id); + if (!device) return res.status(404).json({ error: 'Device not found' }); + if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' }); + const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id); + const ctx = ws && accessContext(req.user.id, req.user.role, ws); + if (!ctx) return res.status(403).json({ error: 'Access denied' }); + const { buildPlaylistPayload } = require('../ws/deviceSocket'); + const payload = buildPlaylistPayload(req.params.id); + payload.wall_config = null; // v1: wall members preview full-frame (no socket-free follower freeze) + res.json(payload); +}); + // Update device router.put('/:id', (req, res) => { const device = checkDeviceOwnership(req, res);