diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c8d1b2..e0d0fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## Unreleased +## [2.2.0] - 2025-05-09 + +- [#142](https://github.com/os2display/display-client/pull/142) + - Added support for previews. + ## [2.1.2] - 2024-11-20 - [#140](https://github.com/os2display/display-client/pull/140) diff --git a/README.md b/README.md index f59272a..9c21ef8 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,18 @@ All endpoint should be configured without a trailing slash. The endpoints `apiEn left empty if the api is hosted from the root of the same domain as the client. E.g. if the api is at https://example.org and the client is at https://example.org/client +## Preview + +The client can be started in preview mode by setting the following url parameters: +``` +preview= +preview-id= +preview-token= +preview-tenant= +``` + +The preview will use the token and tenant for acessing the data from the api. + ## Docker development setup Start docker setup diff --git a/src/app.jsx b/src/app.jsx index b0923e2..68fdb2b 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; +import PropTypes from "prop-types"; import Screen from "./components/screen"; import ContentService from "./service/content-service"; import ConfigLoader from "./util/config-loader"; @@ -16,10 +17,13 @@ import constants from "./util/constants"; /** * App component. * + * @param {object} props The props. + * @param {string | null} props.preview Type of preview to enable. + * @param {string | null} props.previewId The id of the entity to preview. * @returns {object} * The component. */ -function App() { +function App({ preview, previewId }) { const [running, setRunning] = useState(false); const [screen, setScreen] = useState(""); const [bindKey, setBindKey] = useState(null); @@ -187,30 +191,52 @@ function App() { useEffect(() => { logger.info("Mounting App."); + if (preview !== null) { + document.addEventListener("screen", screenHandler); + document.addEventListener("contentEmpty", contentEmpty); + document.addEventListener("contentNotEmpty", contentNotEmpty); + + if (preview === "screen") { + startContent(previewId); + return; + } + setRunning(true); + contentServiceRef.current = new ContentService(); + contentServiceRef.current.start(); + document.dispatchEvent( + new CustomEvent("startPreview", { + detail: { + mode: preview, + id: previewId, + }, + }) + ); + } else { + document.addEventListener("keypress", handleKeyboard); + document.addEventListener("screen", screenHandler); + document.addEventListener("reauthenticate", reauthenticateHandler); + document.addEventListener("contentEmpty", contentEmpty); + document.addEventListener("contentNotEmpty", contentNotEmpty); - document.addEventListener("keypress", handleKeyboard); - document.addEventListener("screen", screenHandler); - document.addEventListener("reauthenticate", reauthenticateHandler); - document.addEventListener("contentEmpty", contentEmpty); - document.addEventListener("contentNotEmpty", contentNotEmpty); - - tokenService.checkToken(); + tokenService.checkToken(); - ConfigLoader.loadConfig().then((config) => { - setDebug(config.debug ?? false); - }); + ConfigLoader.loadConfig().then((config) => { + setDebug(config.debug ?? false); + }); - releaseService.checkForNewRelease().finally(() => { - releaseService.setPreviousBootInUrl(); - releaseService.startReleaseCheck(); + releaseService.checkForNewRelease().finally(() => { + releaseService.setPreviousBootInUrl(); + releaseService.startReleaseCheck(); - checkLogin(); + checkLogin(); - appStorage.setPreviousBoot(new Date().getTime()); - }); + appStorage.setPreviousBoot(new Date().getTime()); + }); - statusService.setStatusInUrl(); + statusService.setStatusInUrl(); + } + /* eslint-disable-next-line consistent-return */ return function cleanup() { logger.info("Unmounting App."); @@ -261,4 +287,9 @@ function App() { ); } +App.propTypes = { + preview: PropTypes.string, + previewId: PropTypes.string, +}; + export default App; diff --git a/src/data-sync/api-helper.js b/src/data-sync/api-helper.js index 569f518..221b3f4 100644 --- a/src/data-sync/api-helper.js +++ b/src/data-sync/api-helper.js @@ -27,12 +27,16 @@ class ApiHelper { let response; try { - logger.info(`Fetching: ${this.endpoint + path}`); + const url = new URL(window.location.href); + const previewToken = url.searchParams.get('preview-token'); + const previewTenant = url.searchParams.get('preview-tenant'); + + logger.log('info', `Fetching: ${this.endpoint + path}`); const token = appStorage.getToken(); const tenantKey = appStorage.getTenantKey(); - if (!token || !tenantKey) { + if ((!token || !tenantKey) && (!previewToken || !previewTenant)) { logger.error('Token or tenantKey not set.'); return null; @@ -40,8 +44,8 @@ class ApiHelper { response = await fetch(this.endpoint + path, { headers: { - authorization: `Bearer ${token}`, - 'Authorization-Tenant-Key': tenantKey, + authorization: `Bearer ${previewToken ?? token}`, + 'Authorization-Tenant-Key': previewTenant ?? tenantKey, }, }); diff --git a/src/data-sync/pull-strategy.js b/src/data-sync/pull-strategy.js index 125f079..5270726 100644 --- a/src/data-sync/pull-strategy.js +++ b/src/data-sync/pull-strategy.js @@ -408,6 +408,40 @@ class PullStrategy { document.dispatchEvent(event); } + getPath(id) { + return this.apiHelper.getPath(id); + } + + async getTemplateData(slide) { + return new Promise((resolve) => { + const templatePath = slide.templateInfo['@id']; + + this.apiHelper.getPath(templatePath).then((data) => { + resolve(data); + }); + }); + } + + async getFeedData(slide) { + return new Promise((resolve) => { + if (!slide?.feed?.feedUrl) { + resolve([]); + } else { + this.apiHelper.getPath(slide.feed.feedUrl).then((data) => { + resolve(data); + }); + } + }); + } + + async getMediaData(media) { + return new Promise((resolve) => { + this.apiHelper.getPath(media).then((data) => { + resolve(data); + }); + }); + } + /** * Start the data synchronization. */ diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..7ac8059 --- /dev/null +++ b/src/index.js @@ -0,0 +1,12 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './app'; + +const url = new URL(window.location.href); +const preview = url.searchParams.get('preview'); +const previewId = url.searchParams.get('preview-id'); + +const container = document.getElementById('root'); +const root = createRoot(container); + +root.render(); diff --git a/src/index.jsx b/src/index.jsx index be78842..121022c 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -2,7 +2,11 @@ import React from "react"; import { createRoot } from "react-dom/client"; import App from "./app"; +const url = new URL(window.location.href); +const preview = url.searchParams.get("preview"); +const previewId = url.searchParams.get("preview-id"); + const container = document.getElementById("root"); const root = createRoot(container); -root.render(); +root.render(); diff --git a/src/service/content-service.js b/src/service/content-service.js index 6e43b80..e473a52 100644 --- a/src/service/content-service.js +++ b/src/service/content-service.js @@ -1,5 +1,10 @@ import sha256 from 'crypto-js/sha256'; import Base64 from 'crypto-js/enc-base64'; +import PullStrategy from '../data-sync/pull-strategy'; +import { + screenForPlaylistPreview, + screenForSlidePreview, +} from '../util/preview'; import logger from '../logger/logger'; import DataSync from '../data-sync/data-sync'; import ScheduleService from './schedule-service'; @@ -81,14 +86,17 @@ class ContentService { this.stopSyncHandler(); + logger.log( + 'info', + `Event received: Start data synchronization from ${data?.screenPath}` + ); if (data?.screenPath) { logger.info( `Event received: Start data synchronization from ${data.screenPath}` ); this.startSyncing(data.screenPath); } else { - logger.info('Event received: Start data synchronization'); - this.startSyncing(); + logger.log('error', 'Error: screenPath not set.'); } } @@ -176,6 +184,7 @@ class ContentService { document.addEventListener('content', this.contentHandler); document.addEventListener('regionReady', this.regionReadyHandler); document.addEventListener('regionRemoved', this.regionRemovedHandler); + document.addEventListener('startPreview', this.startPreview); } /** @@ -189,6 +198,93 @@ class ContentService { document.removeEventListener('content', this.contentHandler); document.removeEventListener('regionReady', this.regionReadyHandler); document.removeEventListener('regionRemoved', this.regionRemovedHandler); + document.removeEventListener('startPreview', this.startPreview); + } + + /** + * Start preview. + * + * @param {CustomEvent} event The event. + */ + async startPreview(event) { + const data = event.detail; + const { mode, id } = data; + logger.log('info', `Starting preview. Mode: ${mode}, ID: ${id}`); + + const config = await ConfigLoader.loadConfig(); + + if (mode === 'screen') { + this.startSyncing(`/v2/screen/${id}`); + } else if (mode === 'playlist') { + const pullStrategy = new PullStrategy({ + endpoint: config.apiEndpoint, + }); + + const playlist = await pullStrategy.getPath(`/v2/playlists/${id}`); + + const playlistSlidesResponse = await pullStrategy.getPath( + playlist.slides + ); + + playlist.slidesData = playlistSlidesResponse['hydra:member'].map( + (playlistSlide) => playlistSlide.slide + ); + + // eslint-disable-next-line no-restricted-syntax + for (const slide of playlist.slidesData) { + // eslint-disable-next-line no-await-in-loop + await ContentService.attachReferencesToSlide(pullStrategy, slide); + } + + const screen = screenForPlaylistPreview(playlist); + + document.dispatchEvent( + new CustomEvent('content', { + detail: { + screen, + }, + }) + ); + } else if (mode === 'slide') { + const pullStrategy = new PullStrategy({ + endpoint: config.apiEndpoint, + }); + + const slide = await pullStrategy.getPath(`/v2/slides/${id}`); + + // eslint-disable-next-line no-await-in-loop + await ContentService.attachReferencesToSlide(pullStrategy, slide); + + const screen = screenForSlidePreview(slide); + + document.dispatchEvent( + new CustomEvent('content', { + detail: { + screen, + }, + }) + ); + } else { + logger.error(`Unsupported preview mode: ${mode}.`); + } + } + + static async attachReferencesToSlide(strategy, slide) { + /* eslint-disable no-param-reassign */ + slide.templateData = await strategy.getTemplateData(slide); + slide.feedData = await strategy.getFeedData(slide); + + slide.mediaData = {}; + // eslint-disable-next-line no-restricted-syntax + for (const media of slide.media) { + // eslint-disable-next-line no-await-in-loop + slide.mediaData[media] = await strategy.getMediaData(media); + } + + if (typeof slide.theme === 'string' || slide.theme instanceof String) { + slide.theme = await strategy.getPath(slide.theme); + } + /* eslint-enable no-param-reassign */ } /** diff --git a/src/service/token-service.js b/src/service/token-service.js index 70bd57e..4ec64ec 100644 --- a/src/service/token-service.js +++ b/src/service/token-service.js @@ -132,11 +132,19 @@ class TokenService { checkToken = () => { const expiredState = this.getExpireState(); - if ([constants.NO_EXPIRE, constants.NO_ISSUED_AT, constants.NO_TOKEN].includes(expiredState)) { + if ( + [ + constants.NO_EXPIRE, + constants.NO_ISSUED_AT, + constants.NO_TOKEN, + ].includes(expiredState) + ) { // Ignore. No token saved in storage. } else if (expiredState === constants.TOKEN_EXPIRED) { statusService.setError(constants.ERROR_TOKEN_EXPIRED); - } else if (expiredState === constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED) { + } else if ( + expiredState === constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED + ) { statusService.setError( constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED ); diff --git a/src/util/preview.js b/src/util/preview.js new file mode 100644 index 0000000..9fe10e7 --- /dev/null +++ b/src/util/preview.js @@ -0,0 +1,60 @@ +/** + * Create screen data for displaying playlist preview. + * + * @param {object} playlist Playlist data. + * @returns {object} Screen data. + */ +function screenForPlaylistPreview(playlist) { + return { + '@id': '/v2/screens/SCREEN01234567890123456789', + '@type': 'Screen', + title: 'Preview', + description: 'Screen for preview.', + layout: '/v2/layouts/LAYOUT01234567890123456789', + regions: [ + '/v2/screens/SCREEN01234567890123456789/regions/REGION01234567890123456789/playlists', + ], + regionData: { + REGION01234567890123456789: [playlist], + }, + layoutData: { + '@id': '/v2/layouts/LAYOUT01234567890123456789', + '@type': 'ScreenLayout', + title: 'Full screen', + grid: { + rows: 1, + columns: 1, + }, + regions: [ + { + '@type': 'ScreenLayoutRegions', + '@id': '/v2/layouts/regions/REGION01234567890123456789', + title: 'full', + gridArea: ['a'], + screenLayout: '/v2/layouts/LAYOUT01234567890123456789', + }, + ], + }, + }; +} + +/** + * Create screen data for displaying slide preview. + * + * @param {object} slide Slide data. + * @returns {object} Screen data. + */ +function screenForSlidePreview(slide) { + const playlist = { + '@id': '/v2/playlists/01HT6WPZCR50W8EF9004PVQ11P', + '@type': 'Playlist', + title: 'Preview playlist', + description: 'Playlist for preview', + schedules: [], + slides: '/v2/playlists/PLAYLIST12345678901234567/slides', + slidesData: [slide], + }; + return screenForPlaylistPreview(playlist); +} + +export { screenForPlaylistPreview, screenForSlidePreview };