From 70436059d82b5464b8b970193643ac68af50e2c0 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Mon, 19 Sep 2022 21:15:06 +0200 Subject: [PATCH] Feat/132 (#198) --- .gitignore | 1 + client/package.json | 2 +- client/public/index.html | 2 +- client/src/AppRouter.jsx | 16 +- client/src/common/api/apiConstants.js | 6 +- client/src/common/api/ontimeApi.js | 23 +++ .../components/countdown/TimerDisplay.jsx | 11 +- .../countdown/TimerDisplay.module.scss | 29 --- .../components/countdown/TimerDisplay.scss | 22 ++ .../errorBoundary/ErrorBoundary.jsx | 4 +- .../components/nav/navigatorConstants.js | 2 +- .../{views => paginator}/Paginator.jsx | 4 +- .../components/paginator/Paginator.scss | 62 ++++++ .../{views => paginator}/TodayItem.jsx | 16 +- .../{views => title-card}/TitleCard.jsx | 0 .../TitleCard.module.scss | 0 .../components/title-side/TitleSide.jsx | 26 +++ .../components/title-side/TitleSide.scss | 61 ++++++ .../components/views/Paginator.module.scss | 64 ------ .../src/common/components/views/TitleSide.jsx | 28 --- .../components/views/TitleSide.module.scss | 54 ----- client/src/common/context/socketContext.tsx | 2 +- .../src/common/hooks/useRuntimeStylesheet.js | 46 +++++ client/src/features/modals/ModalManager.jsx | 5 + client/src/features/modals/Modals.module.scss | 3 +- .../features/modals/ViewsSettingsModal.jsx | 124 ++++++++++++ client/src/features/viewers/ViewWrapper.jsx | 20 +- .../{StageManager.jsx => Backstage.jsx} | 67 ++++--- .../features/viewers/backstage/Backstage.scss | 152 ++++++++++++++ .../backstage/StageManager.module.scss | 59 ------ .../features/viewers/countdown/Countdown.jsx | 73 +++---- .../{Countdown.module.scss => Countdown.scss} | 44 ++-- .../features/viewers/foh/Public.module.scss | 54 ----- .../lower => lower-thirds}/LowerClean.jsx | 16 +- .../viewers/lower-thirds/LowerClean.scss | 26 +++ .../lower => lower-thirds}/LowerLines.jsx | 24 +-- .../viewers/lower-thirds/LowerLines.scss | 66 ++++++ .../viewers/lower-thirds/LowerWrapper.jsx | 137 +++++++++++++ .../{timer => minimal-timer}/MinimalTimer.jsx | 32 ++- .../viewers/minimal-timer/MinimalTimer.scss | 74 +++++++ .../Pip.jsx | 68 +++---- .../viewers/picture-in-picture/Pip.scss | 136 +++++++++++++ .../viewers/production/Pip.module.scss | 63 ------ .../production/lower/LowerClean.module.css | 27 --- .../production/lower/LowerLines.module.css | 79 -------- .../viewers/production/lower/LowerWrapper.jsx | 141 ------------- .../viewers/{foh => public}/Public.jsx | 57 +++--- .../src/features/viewers/public/Public.scss | 146 ++++++++++++++ .../features/viewers/studio/StudioClock.jsx | 38 ++-- ...udioClock.module.scss => StudioClock.scss} | 90 ++++----- .../viewers/timer/MinimalTimer.module.scss | 74 ------- client/src/features/viewers/timer/Timer.jsx | 63 +++--- .../features/viewers/timer/Timer.module.scss | 141 ------------- client/src/features/viewers/timer/Timer.scss | 138 +++++++++++++ client/src/theme/_main.scss | 1 + client/src/theme/_viewerDefs.scss | 28 +++ client/src/theme/_viewers.scss | 61 +++--- client/src/theme/_viewers_mixins.scss | 189 ++++++++++++++++++ docker-compose.yml | 1 + server/package.json | 2 +- server/src/app.js | 6 +- server/src/controllers/ontimeController.js | 28 +++ server/src/external/styles/override.css | 11 + server/src/models/dataModel.js | 3 + server/src/package.json | 3 +- server/src/routes/ontimeRouter.js | 33 +-- server/src/utils/__tests__/parser.tests.js | 31 ++- server/src/utils/parser.js | 3 + server/src/utils/parserUtils_v1.js | 27 +++ server/src/validation/ontimeValidator.js | 13 ++ server/src/yarn.lock | 18 ++ 71 files changed, 2001 insertions(+), 1175 deletions(-) delete mode 100644 client/src/common/components/countdown/TimerDisplay.module.scss create mode 100644 client/src/common/components/countdown/TimerDisplay.scss rename client/src/common/components/{views => paginator}/Paginator.jsx (96%) create mode 100644 client/src/common/components/paginator/Paginator.scss rename client/src/common/components/{views => paginator}/TodayItem.jsx (60%) rename client/src/common/components/{views => title-card}/TitleCard.jsx (100%) rename client/src/common/components/{views => title-card}/TitleCard.module.scss (100%) create mode 100644 client/src/common/components/title-side/TitleSide.jsx create mode 100644 client/src/common/components/title-side/TitleSide.scss delete mode 100644 client/src/common/components/views/Paginator.module.scss delete mode 100644 client/src/common/components/views/TitleSide.jsx delete mode 100644 client/src/common/components/views/TitleSide.module.scss create mode 100644 client/src/common/hooks/useRuntimeStylesheet.js create mode 100644 client/src/features/modals/ViewsSettingsModal.jsx rename client/src/features/viewers/backstage/{StageManager.jsx => Backstage.jsx} (66%) create mode 100644 client/src/features/viewers/backstage/Backstage.scss delete mode 100644 client/src/features/viewers/backstage/StageManager.module.scss rename client/src/features/viewers/countdown/{Countdown.module.scss => Countdown.scss} (69%) delete mode 100644 client/src/features/viewers/foh/Public.module.scss rename client/src/features/viewers/{production/lower => lower-thirds}/LowerClean.jsx (86%) create mode 100644 client/src/features/viewers/lower-thirds/LowerClean.scss rename client/src/features/viewers/{production/lower => lower-thirds}/LowerLines.jsx (85%) create mode 100644 client/src/features/viewers/lower-thirds/LowerLines.scss create mode 100644 client/src/features/viewers/lower-thirds/LowerWrapper.jsx rename client/src/features/viewers/{timer => minimal-timer}/MinimalTimer.jsx (77%) create mode 100644 client/src/features/viewers/minimal-timer/MinimalTimer.scss rename client/src/features/viewers/{production => picture-in-picture}/Pip.jsx (61%) create mode 100644 client/src/features/viewers/picture-in-picture/Pip.scss delete mode 100644 client/src/features/viewers/production/Pip.module.scss delete mode 100644 client/src/features/viewers/production/lower/LowerClean.module.css delete mode 100644 client/src/features/viewers/production/lower/LowerLines.module.css delete mode 100644 client/src/features/viewers/production/lower/LowerWrapper.jsx rename client/src/features/viewers/{foh => public}/Public.jsx (65%) create mode 100644 client/src/features/viewers/public/Public.scss rename client/src/features/viewers/studio/{StudioClock.module.scss => StudioClock.scss} (72%) delete mode 100644 client/src/features/viewers/timer/MinimalTimer.module.scss create mode 100644 client/src/features/viewers/timer/Timer.scss create mode 100644 client/src/theme/_viewerDefs.scss create mode 100644 client/src/theme/_viewers_mixins.scss create mode 100644 server/src/external/styles/override.css create mode 100644 server/src/validation/ontimeValidator.js diff --git a/.gitignore b/.gitignore index 2f39aed86..f862b6075 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ server/src/preloaded-db/db.json server/src/models/db.json TODO.md ontime-db/ +ontime-external/ # vscode stuff .vscode/* diff --git a/client/package.json b/client/package.json index a1197b4a4..f808f26fc 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "ontime-ui", - "version": "1.7.3", + "version": "1.8.0", "private": true, "dependencies": { "@chakra-ui/react": "^2.3.2", diff --git a/client/public/index.html b/client/public/index.html index 5bfc9fe49..099290cf2 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -5,7 +5,7 @@ - + import('features/editors/ProtectedEditor')); const Table = lazy(() => import('features/table/ProtectedTable')); const TimerView = lazy(() => import('features/viewers/timer/Timer')); -const MinimalTimerView = lazy(() => import('features/viewers/timer/MinimalTimer')); +const MinimalTimerView = lazy(() => import('features/viewers/minimal-timer/MinimalTimer')); const Countdown = lazy(() => import('features/viewers/countdown/Countdown')); -const StageManager = lazy(() => import('features/viewers/backstage/StageManager')); -const Public = lazy(() => import('features/viewers/foh/Public')); -const Lower = lazy(() => import('features/viewers/production/lower/LowerWrapper')); -const Pip = lazy(() => import('features/viewers/production/Pip')); +const Backstage = lazy(() => import('features/viewers/backstage/Backstage')); +const Public = lazy(() => import('features/viewers/public/Public')); +const Lower = lazy(() => import('features/viewers/lower-thirds/LowerWrapper')); +const Pip = lazy(() => import('features/viewers/picture-in-picture/Pip')); const StudioClock = lazy(() => import('features/viewers/studio/StudioClock')); const STimer = withSocket(TimerView); const SMinimalTimer = withSocket(MinimalTimerView); const SCountdown = withSocket(Countdown); -const SStageManager = withSocket(StageManager); +const SBackstage = withSocket(Backstage); const SPublic = withSocket(Public); const SLowerThird = withSocket(Lower); const SPip = withSocket(Pip); @@ -66,8 +66,8 @@ export default function AppRouter() { } /> - } /> - } /> + } /> + } /> } /> } /> diff --git a/client/src/common/api/apiConstants.js b/client/src/common/api/apiConstants.js index d4257dc39..9386a5ba4 100644 --- a/client/src/common/api/apiConstants.js +++ b/client/src/common/api/apiConstants.js @@ -6,6 +6,7 @@ export const EVENTS_TABLE = ['events']; export const APP_TABLE = ['appinfo']; export const OSC_SETTINGS = ['oscSettings']; export const APP_SETTINGS = ['appSettings']; +export const VIEW_SETTINGS = ['viewSettings']; export const FEAT_EVENTLIST = ['feat-eventList']; export const FEAT_MESSAGECONTROL = ['feat-messagecontrol']; @@ -18,7 +19,7 @@ export const TIMER = ['ontime-timer']; * @description finds server path given the current location, it * @return {*} */ -const calculateServer = () => { +export const calculateServer = () => { if (process.env?.NODE_ENV === 'development') { return `http://localhost:${STATIC_PORT}`; } @@ -30,3 +31,6 @@ export const eventURL = `${serverURL}/${EVENT_TABLE}`; export const eventsURL = `${serverURL}/${EVENTS_TABLE}`; export const playbackURL = `${serverURL}/playback`; export const ontimeURL = `${serverURL}/ontime`; + +export const stylesPath = 'external/styles/override.css'; +export const overrideStylesURL = `serverURL/${stylesPath}`; diff --git a/client/src/common/api/ontimeApi.js b/client/src/common/api/ontimeApi.js index 5abb115f0..2009ab56b 100644 --- a/client/src/common/api/ontimeApi.js +++ b/client/src/common/api/ontimeApi.js @@ -67,6 +67,14 @@ export const oscPlaceholderSettings = { enabled: false, }; +/** + * @description placeholder information for view settings + * @type {{overrideCSS: boolean}} + */ +export const viewsPlaceholder = { + overrideStyles: false, +}; + /** * @description placeholder information for httpSettings * @type {{onStart: {url: string, enabled: boolean}, onLoad: {url: string, enabled: boolean}, onPause: {url: string, enabled: boolean}, onFinish: {url: string, enabled: boolean}, onUpdate: {url: string, enabled: boolean}, onStop: {url: string, enabled: boolean}}} @@ -163,6 +171,21 @@ export const getInfo = async () => { */ export const postInfo = async (data) => axios.post(`${ontimeURL}/info`, data); +/** + * @description HTTP request to retrieve view settings + * @return {Promise} + */ +export const getView = async () => { + const res = await axios.get(`${ontimeURL}/views`); + return res.data; +}; + +/** + * @description HTTP request to mutate view settings + * @return {Promise} + */ +export const postView = async (data) => axios.post(`${ontimeURL}/views`, data); + /** * @description HTTP request to retrieve aliases * @return {Promise} diff --git a/client/src/common/components/countdown/TimerDisplay.jsx b/client/src/common/components/countdown/TimerDisplay.jsx index 583d33aac..8fa4b428a 100644 --- a/client/src/common/components/countdown/TimerDisplay.jsx +++ b/client/src/common/components/countdown/TimerDisplay.jsx @@ -2,7 +2,7 @@ import { memo } from 'react'; import { formatDisplay } from 'common/utils/dateConfig'; import PropTypes from 'prop-types'; -import styles from './TimerDisplay.module.scss'; +import './TimerDisplay.scss'; const TimerDisplay = ({ time, small, isNegative, hideZeroHours }) => { @@ -11,14 +11,9 @@ const TimerDisplay = ({ time, small, isNegative, hideZeroHours }) => { ? '-- : -- : --' : formatDisplay(time, hideZeroHours); - const classes = `${small ? styles.countdownClockSmall : styles.countdownClock} - ${isNegative ? styles.negative : ''}`; + const classes = `timer ${small ? 'timer--small' : ''} ${isNegative ? 'timer--finished' : ''}`; - return ( -
- {display} -
- ); + return
{display}
; }; export default memo(TimerDisplay); diff --git a/client/src/common/components/countdown/TimerDisplay.module.scss b/client/src/common/components/countdown/TimerDisplay.module.scss deleted file mode 100644 index 33889acfa..000000000 --- a/client/src/common/components/countdown/TimerDisplay.module.scss +++ /dev/null @@ -1,29 +0,0 @@ -@use '../../../theme/main' as *; - -/* Common */ -.countdownClock, -.countdownClockSmall { - font-family: 'Open Sans', sans-serif; - line-height: 0.9; - color: $text-white; -} - -/* Viewers */ -.countdownClock { - font-size: 21vw; - line-height: 21vw; - text-align: center; - letter-spacing: 1vw; -} - -/* Editor */ -.countdownClockSmall { - font-size: 4em; - text-align: center; - letter-spacing: 0.1em; - font-weight: 600; -} - -.negative { - color: $ontime-pink; -} diff --git a/client/src/common/components/countdown/TimerDisplay.scss b/client/src/common/components/countdown/TimerDisplay.scss new file mode 100644 index 000000000..bc0db663a --- /dev/null +++ b/client/src/common/components/countdown/TimerDisplay.scss @@ -0,0 +1,22 @@ +@use '../../../theme/viewerDefs' as *; + +.timer { + font-family: 'Open Sans', sans-serif; + color: var(--timer-color-override, $viewer-color); + font-size: 21vw; + line-height: 21vw; + text-align: center; + letter-spacing: 1vw; + + &--small { + font-size: 4em; + line-height: 0.9; + text-align: center; + letter-spacing: 0.1em; + font-weight: 600; + } + + &--finished { + color: $timer-finished-color; + } +} diff --git a/client/src/common/components/errorBoundary/ErrorBoundary.jsx b/client/src/common/components/errorBoundary/ErrorBoundary.jsx index fc8b2c6b4..7c8abedea 100644 --- a/client/src/common/components/errorBoundary/ErrorBoundary.jsx +++ b/client/src/common/components/errorBoundary/ErrorBoundary.jsx @@ -28,8 +28,8 @@ class ErrorBoundary extends React.Component { }); try { this.context.emitError(error.toString()); - } catch { - console.log('Unable to emit error') + } catch (e) { + console.log('Unable to emit error', error, e ) } this.reportContent = `${error} ${info.componentStack}`; } diff --git a/client/src/common/components/nav/navigatorConstants.js b/client/src/common/components/nav/navigatorConstants.js index 8ececfeba..93ea057d8 100644 --- a/client/src/common/components/nav/navigatorConstants.js +++ b/client/src/common/components/nav/navigatorConstants.js @@ -1,7 +1,7 @@ const navigatorConstants = [ { url: '/timer', label: 'Timer' }, { url: '/minimal', label: 'Minimal Timer' }, - { url: '/sm', label: 'Backstage' }, + { url: '/backstage', label: 'Backstage' }, { url: '/public', label: 'Public' }, { url: '/lower', label: 'Lower Thirds' }, { url: '/pip', label: 'PiP' }, diff --git a/client/src/common/components/views/Paginator.jsx b/client/src/common/components/paginator/Paginator.jsx similarity index 96% rename from client/src/common/components/views/Paginator.jsx rename to client/src/common/components/paginator/Paginator.jsx index 4784e2da4..21588a3b2 100644 --- a/client/src/common/components/views/Paginator.jsx +++ b/client/src/common/components/paginator/Paginator.jsx @@ -6,7 +6,7 @@ import Empty from '../state/Empty'; import TodayItem from './TodayItem'; -import style from './Paginator.module.scss'; +import './Paginator.scss'; export default function Paginator(props) { const { @@ -70,7 +70,7 @@ export default function Paginator(props) { } return ( -
+
{page.map((e) => { if (e.id === selectedId) selectedState = 1; else if (selectedState === 1) selectedState = 2; diff --git a/client/src/common/components/paginator/Paginator.scss b/client/src/common/components/paginator/Paginator.scss new file mode 100644 index 000000000..d7a7989aa --- /dev/null +++ b/client/src/common/components/paginator/Paginator.scss @@ -0,0 +1,62 @@ +@use '../../../theme/viewerDefs' as *; + +.paginator.entries { + width: 100%; + + .entry-times { + font-family: 'Open Sans', sans-serif; + font-size: 1.4vw; + + min-width: 10vw; + opacity: 0.9; + letter-spacing: 0.035em; + } + + .entry-title { + align-self: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .entry { + display: flex; + font-size: 1.3vw; + padding: 0.2vh 1vw; + margin-bottom: 1.5vh; + border-radius: 3px; + + &--now, + &--future { + color: var(--color-override, $viewer-color); + } + + &--past { + font-size: 1.2vw; + opacity: 0.6; + } + + &--now { + line-height: 5vh; + background-color: $now-bg-color; + border-bottom: 0.5vh solid; + border-bottom-color: var(--accent-color-override, $accent-color); + margin-left: -0.5em; + padding-left: 0.5em; + } + + &--future { + background-color: $future-bg-color; + } + } + + .backstage-indicator { + color: var(--accent-color-override, $accent-color); + margin-left: auto; + margin-right: -0.5vw; + + &::before { + content: '*'; + } + } +} diff --git a/client/src/common/components/views/TodayItem.jsx b/client/src/common/components/paginator/TodayItem.jsx similarity index 60% rename from client/src/common/components/views/TodayItem.jsx rename to client/src/common/components/paginator/TodayItem.jsx index a957bc5a5..9200a6f52 100644 --- a/client/src/common/components/views/TodayItem.jsx +++ b/client/src/common/components/paginator/TodayItem.jsx @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import { formatTime } from '../../utils/time'; -import style from './Paginator.module.scss'; +import './Paginator.scss'; export default function TodayItem(props) { const { selected, timeStart, timeEnd, title, backstageEvent, colour } = props; @@ -15,16 +15,16 @@ export default function TodayItem(props) { const userColour = colour !== '' ? colour : 'transparent'; // select styling - let selectStyle = style.entryPast; - if (selected === 1) selectStyle = style.entryNow; - else if (selected === 2) selectStyle = style.entryFuture; + let selectStyle = 'entry--past'; + if (selected === 1) selectStyle = 'entry--now'; + else if (selected === 2) selectStyle = 'entry--future'; return ( -
-
+
+
{`${start} · ${end}`}
-
{title}
- {backstageEvent &&
} +
{title}
+ {backstageEvent &&
}
); } diff --git a/client/src/common/components/views/TitleCard.jsx b/client/src/common/components/title-card/TitleCard.jsx similarity index 100% rename from client/src/common/components/views/TitleCard.jsx rename to client/src/common/components/title-card/TitleCard.jsx diff --git a/client/src/common/components/views/TitleCard.module.scss b/client/src/common/components/title-card/TitleCard.module.scss similarity index 100% rename from client/src/common/components/views/TitleCard.module.scss rename to client/src/common/components/title-card/TitleCard.module.scss diff --git a/client/src/common/components/title-side/TitleSide.jsx b/client/src/common/components/title-side/TitleSide.jsx new file mode 100644 index 000000000..6c31d1a65 --- /dev/null +++ b/client/src/common/components/title-side/TitleSide.jsx @@ -0,0 +1,26 @@ +import PropTypes from 'prop-types'; + +import './TitleSide.scss'; + +export default function TitleSide(props) { + const { type, label, title, subtitle, presenter } = props; + + const isNext = type === 'next'; + + return ( +
+
{label}
+
{title}
+
{presenter}
+
{subtitle}
+
+ ); +} + +TitleSide.propTypes = { + type: PropTypes.oneOf(['now', 'next']), + label: PropTypes.string, + title: PropTypes.string, + subtitle: PropTypes.string, + presenter: PropTypes.string, +} diff --git a/client/src/common/components/title-side/TitleSide.scss b/client/src/common/components/title-side/TitleSide.scss new file mode 100644 index 000000000..b2420f663 --- /dev/null +++ b/client/src/common/components/title-side/TitleSide.scss @@ -0,0 +1,61 @@ +@use '../../../theme/viewerDefs' as *; + +.title-side { + .label { + font-size: 1.3vw; + color: var(--accent-color-override, $accent-color); + } + + .title, + .subtitle, + .presenter { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .title { + color: $title-color; + font-weight: 600; + + &.now { + font-size: 2.5vw; + } + + &.next { + font-size: 2vw; + } + } + + .subtitle { + color: $subtitle-color; + &.now { + font-size: 1.2vw; + margin-top: -1vh; + } + + &.next { + font-size: 1.1vw; + margin-top: -0.8vh; + } + } + + .presenter { + font-weight: 400; + color: $subtitle-color; + + &.now { + font-size: 2vw; + } + + &.next { + font-size: 1.5vw; + } + } + + .title:after, + .subtitle:after, + .presenter:after { + content: '\200b'; + } +} diff --git a/client/src/common/components/views/Paginator.module.scss b/client/src/common/components/views/Paginator.module.scss deleted file mode 100644 index 9f75b5672..000000000 --- a/client/src/common/components/views/Paginator.module.scss +++ /dev/null @@ -1,64 +0,0 @@ -@use '../../../theme/main' as *; - -.entries { - width: 100%; -} - -.entryTimes { - font-family: 'Open Sans', sans-serif; - font-size: 1.4vw; - - min-width: 10vw; - opacity: 0.9; - letter-spacing: 0.035em; -} - -.entryTitle { - align-self: center; - @include ellipsis; -} - -.entryPast, -.entryNow, -.entryFuture { - display: flex; - font-size: 1.3vw; - padding: 0.2vh 1vw; - margin-bottom: 1.5vh; - border-radius: 3px; -} - -.entryPast { - font-size: 1.2vw; -} - -.entryPast > .entryTitle { - color: $text-gray-disabled; -} - -.entryNow, -.entryFuture { - color: $text-white; -} - -.entryFuture { - background-color: $bg-gray-950; -} - -.entryNow { - line-height: 5vh; - background-color: $bg-gray-900; - border-bottom: 0.5vh solid $ontime-pink; - margin-left: -0.5em; - padding-left: 0.5em; -} - -.backstageInd { - color: $ontime-pink; - margin-left: auto; - margin-right: -0.5vw; -} - -.backstageInd::before { - content: '*'; -} diff --git a/client/src/common/components/views/TitleSide.jsx b/client/src/common/components/views/TitleSide.jsx deleted file mode 100644 index bf7a98f7d..000000000 --- a/client/src/common/components/views/TitleSide.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import PropTypes from 'prop-types'; - -import style from './TitleSide.module.scss'; - -export default function TitleSide(props) { - const { type, label, title, subtitle, presenter } = props; - - const titleStyle = type === 'next' ? style.nextTitle : style.nowTitle; - const subStyle = type === 'next' ? style.nextSubtitle : style.nowSubtitle; - const presStyle = type === 'next' ? style.nextPresenter : style.nowPresenter; - - return ( - <> -
{label}
-
{title}
-
{presenter}
-
{subtitle}
- - ); -} - -TitleSide.propTypes = { - type: PropTypes.oneOf(['now', 'next']), - label: PropTypes.string, - title: PropTypes.string, - subtitle: PropTypes.string, - presenter: PropTypes.string, -} diff --git a/client/src/common/components/views/TitleSide.module.scss b/client/src/common/components/views/TitleSide.module.scss deleted file mode 100644 index 292fe9ad6..000000000 --- a/client/src/common/components/views/TitleSide.module.scss +++ /dev/null @@ -1,54 +0,0 @@ -@use '../../../theme/main' as *; - -.label { - @include card-label; -} - -.nowTitle, -.nextTitle { - @include card-title; -} - -.nowSubtitle, -.nowPresenter, -.nextSubtitle, -.nextPresenter { - color: $subtitle-gray; -} - -.nowTitle { - font-size: 2.5vw; -} - -.nowPresenter { - font-size: 2vw; - font-weight: 400; -} - -.nowSubtitle { - font-size: 1.2vw; - margin-top: -1vh; -} - -.nextTitle { - font-size: 2vw; -} - -.nextSubtitle { - margin-top: -0.8vh; - font-size: 1.1vw; -} - -.nextPresenter { - font-weight: 400; - font-size: 1.5vw; -} - -.nowTitle:after, -.nowSubtitle:after, -.nowPresenter:after, -.nextTitle:after, -.nextSubtitle:after, -.nextPresenter:after { - content: '\200b'; -} diff --git a/client/src/common/context/socketContext.tsx b/client/src/common/context/socketContext.tsx index f7304c1cc..a291ae975 100644 --- a/client/src/common/context/socketContext.tsx +++ b/client/src/common/context/socketContext.tsx @@ -26,7 +26,7 @@ export const useSocket = () => { }; function SocketProvider({ children }: SocketProviderProps) { - const [socket, setSocket] = useState({} as Socket); + const [socket, setSocket] = useState(null); useEffect(() => { const socketInstance = io(serverURL, { transports: ["websocket"] }); diff --git a/client/src/common/hooks/useRuntimeStylesheet.js b/client/src/common/hooks/useRuntimeStylesheet.js new file mode 100644 index 000000000..209432142 --- /dev/null +++ b/client/src/common/hooks/useRuntimeStylesheet.js @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; + +const scriptTagId = 'ontime-override'; +export const useRuntimeStylesheet = (pathToFile) => { + const [shouldRender, setShouldRender] = useState(false); + + useEffect(() => { + const fetchData = async () => { + let response = await fetch(pathToFile); + if (response.ok) { + return await response.text(); + } + }; + + if (!pathToFile) { + document.getElementById(scriptTagId)?.remove(); + setShouldRender(true); + return; + } + + if (document.getElementById(scriptTagId)) { + setShouldRender(true); + return; + } + + setShouldRender(false); + const styleSheet = document.createElement('style'); + styleSheet.rel = 'stylesheet'; + styleSheet.setAttribute('id', scriptTagId); + + fetchData() + .then((data) => { + styleSheet.innerHTML = data; + document.head.append(styleSheet); + }) + .catch((error) => { + console.error(`Error loading stylesheet: ${error}`); + }) + .finally(() => { + // schedule render for next tick + setTimeout(() => setShouldRender(true), 0); + }); + }, [pathToFile]); + + return { shouldRender }; +}; diff --git a/client/src/features/modals/ModalManager.jsx b/client/src/features/modals/ModalManager.jsx index f26b8a3f6..fe8dcf2b4 100644 --- a/client/src/features/modals/ModalManager.jsx +++ b/client/src/features/modals/ModalManager.jsx @@ -14,6 +14,7 @@ import EventSettingsModal from './EventSettingsModal'; import IntegrationSettingsModal from './IntegrationSettingsModal'; import OscSettingsModal from './OscSettingsModal'; import TableOptionsModal from './TableOptionsModal'; +import ViewsSettingsModal from './ViewsSettingsModal'; export default function ModalManager(props) { const { isOpen, onClose } = props; @@ -34,6 +35,7 @@ export default function ModalManager(props) { App Settings + Viewers Event Data URL Aliases Cuesheet @@ -44,6 +46,9 @@ export default function ModalManager(props) { + + + diff --git a/client/src/features/modals/Modals.module.scss b/client/src/features/modals/Modals.module.scss index 5db5ea5ad..98532f84a 100644 --- a/client/src/features/modals/Modals.module.scss +++ b/client/src/features/modals/Modals.module.scss @@ -96,7 +96,8 @@ } .submitContainer { - margin-top: 2em; + margin-top: auto; + padding-top: 2em; display: flex; justify-content: flex-end; gap: 1em; diff --git a/client/src/features/modals/ViewsSettingsModal.jsx b/client/src/features/modals/ViewsSettingsModal.jsx new file mode 100644 index 000000000..615958cff --- /dev/null +++ b/client/src/features/modals/ViewsSettingsModal.jsx @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useState } from 'react'; +import { ModalBody } from '@chakra-ui/modal'; +import { FormControl, FormLabel } from '@chakra-ui/react'; +import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline'; + +import { VIEW_SETTINGS } from '../../common/api/apiConstants'; +import { getView, postView, viewsPlaceholder } from '../../common/api/ontimeApi'; +import EnableBtn from '../../common/components/buttons/EnableBtn'; +import { useFetch } from '../../common/hooks/useFetch'; +import { openLink } from '../../common/utils/linkUtils'; + +import SubmitContainer from './SubmitContainer'; + +import style from './Modals.module.scss'; + +export default function ViewsSettingsModal() { + const { data, status, refetch } = useFetch(VIEW_SETTINGS, getView); + const [formData, setFormData] = useState(viewsPlaceholder); + const [changed, setChanged] = useState(false); + const [submitting, setSubmitting] = useState(false); + + /** + * Set formdata from server state + */ + useEffect(() => { + if (data == null) return; + if (changed) return; + setFormData({ ...data }); + }, [changed, data]); + + /** + * Validate and submit data + */ + const submitHandler = useCallback( + async (event) => { + event.preventDefault(); + setSubmitting(true); + await postView(formData); + await refetch(); + setChanged(false); + setSubmitting(false); + }, + [formData, refetch] + ); + + /** + * Reverts local state equals to server state + */ + const revert = useCallback(async () => { + setChanged(false); + await refetch(); + }, [refetch]); + + /** + * Handles change of input field in local state + * @param {string} field - object parameter to update + * @param {(string | number | boolean)} value - new object parameter value + */ + const handleChange = useCallback( + (field, value) => { + const temp = { ...formData }; + temp[field] = value; + setFormData(temp); + setChanged(true); + }, + [formData] + ); + + return ( + +

+ Options related to the viewers +
+ 🔥 Changes take effect immediately 🔥 +

+
+
Style Options
+
+ + + CSS Style Overrides + + This feature allows user defined CSS to override the application stylesheets as a way to + customise viewers appearance. +
+ Read more about it in the documentation{' '} + openLink('https://cpvalente.gitbook.io/ontime/features/custom-styling')} + className={style.if} + > + over at Gitbook + +
+
+
+ + + Override CSS Styles + +
+ Enable / Disable override +
+
+ handleChange('overrideStyles', !formData.overrideStyles)} + /> +
+
+ +
+
+
+ ); +} diff --git a/client/src/features/viewers/ViewWrapper.jsx b/client/src/features/viewers/ViewWrapper.jsx index a67a4949b..876ba5035 100644 --- a/client/src/features/viewers/ViewWrapper.jsx +++ b/client/src/features/viewers/ViewWrapper.jsx @@ -1,16 +1,18 @@ /* eslint-disable react/display-name */ import { useEffect, useState } from 'react'; +import { EVENT_TABLE, EVENTS_TABLE, VIEW_SETTINGS } from 'common/api/apiConstants'; +import { fetchEvent } from 'common/api/eventApi'; +import { fetchAllEvents } from 'common/api/eventsApi'; +import { useSocket } from 'common/context/socketContext'; +import { useFetch } from 'common/hooks/useFetch'; -import { EVENT_TABLE, EVENTS_TABLE } from '../../common/api/apiConstants'; -import { fetchEvent } from '../../common/api/eventApi'; -import { fetchAllEvents } from '../../common/api/eventsApi'; -import { useSocket } from '../../common/context/socketContext'; -import { useFetch } from '../../common/hooks/useFetch'; +import { getView } from '../../common/api/ontimeApi'; const withSocket = (Component) => { return (props) => { const { data: eventsData } = useFetch(EVENTS_TABLE, fetchAllEvents); const { data: genData } = useFetch(EVENT_TABLE, fetchEvent); + const { data: viewSettings } = useFetch(VIEW_SETTINGS, getView); const [publicEvents, setPublicEvents] = useState([]); const [backstageEvents, setBackstageEvents] = useState([]); @@ -176,7 +178,6 @@ const withSocket = (Component) => { setGeneral(genData); }, [genData]); - /********************************************/ /*** + titleManager ***/ /*** WRAP INFORMATION RELATED TO TITLES ***/ @@ -228,6 +229,12 @@ const withSocket = (Component) => { playstate: playback, }; + // prevent render until we get all the data we need + if (!viewSettings) { + return null; + } + + Component.displayName = 'ComponentWithData'; return ( { backstageEvents={backstageEvents} selectedId={selectedId} publicSelectedId={publicSelectedId} + viewSettings={viewSettings} nextId={nextId} general={general} onAir={onAir} diff --git a/client/src/features/viewers/backstage/StageManager.jsx b/client/src/features/viewers/backstage/Backstage.jsx similarity index 66% rename from client/src/features/viewers/backstage/StageManager.jsx rename to client/src/features/viewers/backstage/Backstage.jsx index 34185fa50..915bcede8 100644 --- a/client/src/features/viewers/backstage/StageManager.jsx +++ b/client/src/features/viewers/backstage/Backstage.jsx @@ -1,25 +1,28 @@ import { useEffect, useState } from 'react'; import QRCode from 'react-qr-code'; import NavLogo from 'common/components/nav/NavLogo'; -import Paginator from 'common/components/views/Paginator'; -import TitleSide from 'common/components/views/TitleSide'; +import Paginator from 'common/components/paginator/Paginator'; +import TitleSide from 'common/components/title-side/TitleSide'; import { formatDisplay } from 'common/utils/dateConfig'; import { AnimatePresence, motion } from 'framer-motion'; import PropTypes from 'prop-types'; +import { overrideStylesURL } from '../../../common/api/apiConstants'; +import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { getEventsWithDelay } from '../../../common/utils/eventsManager'; import { formatTime } from '../../../common/utils/time'; import { titleVariants } from '../common/animation'; -import style from './StageManager.module.scss'; +import './Backstage.scss'; const formatOptions = { showSeconds: true, format: 'hh:mm:ss a', }; -export default function StageManager(props) { - const { publ, title, time, backstageEvents, selectedId, general } = props; +export default function Backstage(props) { + const { publ, title, time, backstageEvents, selectedId, general, viewSettings } = props; + const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const [filteredEvents, setFilteredEvents] = useState(null); const [pageNumber, setPageNumber] = useState(0); const [currentPage, setCurrentPage] = useState(0); @@ -36,6 +39,11 @@ export default function StageManager(props) { setFilteredEvents(f); }, [backstageEvents]); + // defer rendering until we load stylesheets + if (!shouldRender) { + return null; + } + // Format messages const showPubl = publ.text !== '' && publ.visible; @@ -50,15 +58,15 @@ export default function StageManager(props) { const clock = formatTime(time.clock, formatOptions); return ( -
+
-
{general.title}
+
{general.title}
{title.showNow && ( {title.showNext && ( -
-
-
Today
-
+
+
+
Today
+
{pageNumber > 1 && [...Array(pageNumber).keys()].map((i) => (
))}
@@ -119,27 +127,25 @@ export default function StageManager(props) { />
-
-
Public message
-
{publ.text}
+
+
Public message
+
{publ.text}
-
-
Time Now
-
{clock}
+
+
Time Now
+
{clock}
-
-
Stage Timer
-
{stageTimer}
+
+
Stage Timer
+
{stageTimer}
-
-
Info
-
-
{general.backstageInfo}
-
-
+
+
Info
+
{general.backstageInfo}
+
{general.url != null && general.url !== '' && ( )} @@ -149,11 +155,12 @@ export default function StageManager(props) { ); } -StageManager.propTypes = { +Backstage.propTypes = { publ: PropTypes.object, title: PropTypes.object, time: PropTypes.object, backstageEvents: PropTypes.object, selectedId: PropTypes.string, general: PropTypes.object, + viewSettings: PropTypes.object, }; diff --git a/client/src/features/viewers/backstage/Backstage.scss b/client/src/features/viewers/backstage/Backstage.scss new file mode 100644 index 000000000..55810553b --- /dev/null +++ b/client/src/features/viewers/backstage/Backstage.scss @@ -0,0 +1,152 @@ +@use '../../../theme/viewerDefs' as *; + +.backstage { + margin: 0; + box-sizing: border-box; /* reset */ + overflow: hidden; + width: 100%; /* restrict the page width to viewport */ + background: var(--background-color-override, $viewer-background-color); + height: 100vh; + color: var(--color-override, $viewer-color); + font-weight: 300; + + display: grid; + grid-template-columns: 1fr 1fr 1fr 3vw 2fr; + grid-template-rows: 15vh 1fr 1fr 0 13vh 13vh; + grid-template-areas: + ' titl titl titl . schd' + ' now now now . schd' + ' next next .... . schd' + ' ... .... .... . ....' + ' publ publ clck . info' + ' publ publ time . info'; + gap: 1vw; + padding: 1vw; + + .event, + .public-container, + .info, + .timer-container, + .clock-container, + .today-container { + background-color: var(--outdent-background-color-override, $viewer-outdent-bg-color); + padding: 1vh 1vw; + border-radius: 1vw; + + .label { + font-size: 1.3vw; + color: var(--accent-color-override, $accent-color); + } + + .clock, + .timer { + font-family: 'Open Sans', sans-serif; + font-size: 2.25vw; + line-height: 3vw; + text-align: center; + letter-spacing: 0.25vw; + color: var(--color-override, $viewer-color); + } + } + + .event { + border-radius: 0 2vw 2vw 0; + margin-left: -1vw; + padding: 1vh 2vw; + overflow: hidden; + + &.now { + grid-area: now; + } + + &.next { + grid-area: next; + } + } + + .event-title { + grid-area: titl; + font-size: 3vw; + font-weight: 600; + text-decoration: underline 0.5vh; + text-decoration-color: var(--accent-color-override, $accent-color); + padding-top: 0.2vh; + padding-left: 1vw; + } + + .today-container { + grid-area: schd; + padding: 2.5vh 2vw; + overflow: hidden; + margin-top: 3vh; + height: 95%; + + .today-header-block { + display: flex; + justify-content: space-between; + margin-bottom: 1.5vh; + + .nav { + align-self: center; + justify-content: flex-end; + display: flex; + margin-bottom: 2.5vh; + } + + .nav-item { + background-color: $today-item-bg; + width: 0.7vw; + height: 0.7vw; + border-radius: 0.35vw; + margin-left: 0.5vw; + } + } + } + + .public-container { + grid-area: publ; + + &--hidden { + opacity: 0; + transition: 0.5s; + transition-property: opacity; + } + } + + .clock-container { + grid-area: time; + border-radius: 0 0 1vw 1vw; + } + + .timer-container { + grid-area: clck; + border-radius: 1vw 1vw 0 0; + } + + .info { + grid-area: info; + display: grid; + padding: 2.5vh 2vw; + + grid-template-rows: 3vh minmax(0, 1fr); + grid-template-columns: 3fr 1fr; + grid-template-areas: + 'titl .' + 'binf qr'; + gap: 0.5vw; + + &__message { + grid-area: binf; + font-size: 1.5vw; + line-height: 2vw; + white-space: pre-line; + overflow: hidden; + } + + .qr { + align-self: center; + justify-self: center; + grid-area: qr; + } + } +} diff --git a/client/src/features/viewers/backstage/StageManager.module.scss b/client/src/features/viewers/backstage/StageManager.module.scss deleted file mode 100644 index 2c86f9dc4..000000000 --- a/client/src/features/viewers/backstage/StageManager.module.scss +++ /dev/null @@ -1,59 +0,0 @@ -@use '../../../theme/main' as *; -@use '../../../theme/viewers' as *; - -.container__gray, -.container__grayFinished { - @include viewer-container; - - display: grid; - grid-template-columns: 1fr 1fr 1fr 3vw 2fr; - grid-template-rows: 15vh 1fr 1fr 0 13vh 13vh; - grid-template-areas: - ' titl titl titl . schd' - ' now now now . schd' - ' next next .... . schd' - ' ... .... .... . ....' - ' publ publ clck . info' - ' publ publ time . info'; - gap: 1vw; - padding: 1vw; -} - -.eventTitle { - grid-area: titl; - @include viewer-event-title; -} - -.nowContainer { - grid-area: now; - background-color: $bg-gray-950; - border-radius: 0 2vw 2vw 0; -} - -.publicContainer, -.publicContainerHidden { - grid-area: publ; -} - -.nextContainer { - grid-area: next; - border-radius: 0 2vw 2vw 0; -} - -.todayContainer { - grid-area: schd; -} - -.infoContainer { - grid-area: info; -} - -.clockContainer { - grid-area: time; - border-radius: 0 0 1vw 1vw; -} - -.countdownContainer { - grid-area: clck; - border-radius: 1vw 1vw 0 0; -} diff --git a/client/src/features/viewers/countdown/Countdown.jsx b/client/src/features/viewers/countdown/Countdown.jsx index 6081b12c8..22e9201a1 100644 --- a/client/src/features/viewers/countdown/Countdown.jsx +++ b/client/src/features/viewers/countdown/Countdown.jsx @@ -1,16 +1,18 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import PropTypes from 'prop-types'; +import { overrideStylesURL } from '../../../common/api/apiConstants'; import NavLogo from '../../../common/components/nav/NavLogo'; import Empty from '../../../common/components/state/Empty'; +import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig'; import getDelayTo from '../../../common/utils/getDelayTo'; import { formatTime } from '../../../common/utils/time'; import { fetchTimerData, sanitiseTitle, timerMessages } from './countdown.helpers'; -import style from './Countdown.module.scss'; +import './Countdown.scss'; const formatOptions = { showSeconds: true, @@ -18,14 +20,15 @@ const formatOptions = { }; export default function Countdown(props) { - const { backstageEvents, time, selectedId } = props; + const { backstageEvents, time, selectedId, viewSettings } = props; + const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); + const [searchParams] = useSearchParams(); + const [follow, setFollow] = useState(null); const [runningTimer, setRunningTimer] = useState(0); const [runningMessage, setRunningMessage] = useState(''); const [delay, setDelay] = useState(0); - const [searchParams] = useSearchParams(); - // Set window title useEffect(() => { document.title = 'ontime - Countdown'; }, []); @@ -66,17 +69,15 @@ export default function Countdown(props) { setRunningTimer(timer); }, [follow, selectedId, time]); - const standby = useMemo( - () => time.playstate !== 'start' && selectedId === follow?.id, - [follow?.id, selectedId, time.playstate] - ); + // defer rendering until we load stylesheets + if (!shouldRender) { + return null; + } - const isRunningFinished = useMemo( - () => time.finished && runningMessage === timerMessages.running, - [time.finished, runningMessage] - ); - - const isSelected = useMemo(() => runningMessage === timerMessages.running, [runningMessage]); + const standby = time.playstate !== 'start' && selectedId === follow?.id; + const isRunningFinished = time.finished && runningMessage === timerMessages.running; + const isSelected = runningMessage === timerMessages.running; + const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : ''; const clock = formatTime(time.clock, formatOptions); const startTime = @@ -89,12 +90,12 @@ export default function Countdown(props) { : formatTime(follow.timeEnd + delay, formatOptions); return ( -
+
{follow === null ? ( -
- Select an event to follow -
    +
    + Select an event to follow +
      {backstageEvents.length === 0 ? ( ) : ( @@ -111,27 +112,29 @@ export default function Countdown(props) {
    ) : ( -
    -
    -
    -
    Time Now
    - {clock} +
    +
    +
    +
    Time Now
    + {clock}
    -
    -
    Start Time
    - 0 ? style.delayed : ''}`}> +
    +
    Start Time
    + {startTime}
    -
    -
    End Time
    - 0 ? style.delayed : ''}`}>{endTime} +
    +
    End Time
    + + {endTime} +
    -
    {runningMessage}
    +
    {runningMessage}
    {formatDisplay( @@ -139,7 +142,7 @@ export default function Countdown(props) { isSelected || time.waiting )} -
    {follow?.title || 'Untitled Event'}
    +
    {follow?.title || 'Untitled Event'}
    )}
    @@ -150,5 +153,5 @@ Countdown.propTypes = { backstageEvents: PropTypes.array, time: PropTypes.object, selectedId: PropTypes.string, - settings: PropTypes.object, + viewSettings: PropTypes.object, }; diff --git a/client/src/features/viewers/countdown/Countdown.module.scss b/client/src/features/viewers/countdown/Countdown.scss similarity index 69% rename from client/src/features/viewers/countdown/Countdown.module.scss rename to client/src/features/viewers/countdown/Countdown.scss index 94e478a7f..ccaeeaf09 100644 --- a/client/src/features/viewers/countdown/Countdown.module.scss +++ b/client/src/features/viewers/countdown/Countdown.scss @@ -1,17 +1,17 @@ -@use '../../../theme/main' as *; +@use '../../../theme/viewerDefs' as *; -.container { +.countdown { margin: 0; box-sizing: border-box; /* reset */ overflow: hidden; width: 100%; /* restrict the page width to viewport */ - background: $bg-black; + background: var(--background-color-override, $viewer-background-color); height: 100vh; - color: $title-white; + color: var(--color-override, $viewer-color); padding: 1vw; - .eventSelect { + .event-select { display: flex; margin-top: 8vh; align-items: center; @@ -19,11 +19,11 @@ font-size: max(1.3vw, 12px); flex-direction: column; - .actionTitle { + &__title { font-size: max(2vw, 14px); } - .events { + &__events { margin-top: 1em; overflow-y: auto; height: 70vh; @@ -31,7 +31,7 @@ } } - .countdownContainer { + .countdown-container { height: 100%; width: 100%; display: grid; @@ -47,28 +47,27 @@ justify-content: center; text-align: center; - .timers { + .timer-group { grid-area: timers; display: flex; justify-content: space-evenly; align-items: flex-end; - .timer { + .aux-timers { text-align: center; - .label { + &__label { font-size: max(1.3vw, 12px); - color: $ontime-pink; + color: var(--accent-color-override, $accent-color); } - .value { + &__value { font-family: 'Open Sans', sans-serif; font-size: max(2vw, 14px); letter-spacing: 0.3px; - color: #ddd; - &.delayed { - color: $block-delay-color; + &--delayed { + color: $delay-color; } } } @@ -78,8 +77,8 @@ grid-area: title; font-size: max(4vw, 18px); align-self: center; - color: $ontime-pink; - background-color: $bg-gray-1000; + color: var(--accent-color-override, $accent-color); + background-color: var(--outdent-background-color-override, $viewer-outdent-bg-color); padding: 1vh 2vw; border-radius: 1vw; } @@ -92,7 +91,7 @@ align-self: flex-end; } - .countdownClock { + .timer { grid-area: clock; line-height: 20vw; font-size: 21vw; @@ -101,12 +100,13 @@ align-self: flex-start; opacity: 1.0; transition: opacity 0.5s; + color: var(--timer-color-override, $viewer-color); - &.standby { + &--paused { opacity: 0.6; } - &.finished { - color: $ontime-pink-variant; + &--finished { + color: $timer-finished-color; } } } diff --git a/client/src/features/viewers/foh/Public.module.scss b/client/src/features/viewers/foh/Public.module.scss deleted file mode 100644 index 8c7e8b79a..000000000 --- a/client/src/features/viewers/foh/Public.module.scss +++ /dev/null @@ -1,54 +0,0 @@ -@use '../../../theme/main' as *; -@use '../../../theme/viewers' as *; - -.container__gray, -.container__grayFinished { - @include viewer-container; - - display: grid; - grid-template-columns: 1fr 1fr 1fr 3vw 2fr; - grid-template-rows: 15vh 1fr 1fr 0 13vh 13vh; - grid-template-areas: - ' titl titl titl . schd' - ' now now now . schd' - ' next next .... . schd' - ' ... .... .... . ....' - ' publ publ .... . info' - ' publ publ time . info'; - gap: 1vw; - padding: 1vw; -} - -.eventTitle { - grid-area: titl; - @include viewer-event-title; -} - -.nowContainer { - grid-area: now; - background-color: $bg-gray-950; - border-radius: 0 2vw 2vw 0; -} - -.publicContainer, -.publicContainerHidden { - grid-area: publ; -} - -.nextContainer { - grid-area: next; - border-radius: 0 2vw 2vw 0; -} - -.todayContainer { - grid-area: schd; -} - -.infoContainer { - grid-area: info; -} - -.clockContainer { - grid-area: time; - border-radius: 1vw; -} diff --git a/client/src/features/viewers/production/lower/LowerClean.jsx b/client/src/features/viewers/lower-thirds/LowerClean.jsx similarity index 86% rename from client/src/features/viewers/production/lower/LowerClean.jsx rename to client/src/features/viewers/lower-thirds/LowerClean.jsx index a1d227d36..b4ed06c2e 100644 --- a/client/src/features/viewers/production/lower/LowerClean.jsx +++ b/client/src/features/viewers/lower-thirds/LowerClean.jsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; -import NavLogo from "../../../../common/components/nav/NavLogo"; +import NavLogo from "../../../common/components/nav/NavLogo"; -import style from './LowerClean.module.css'; +import './LowerClean.scss'; export default function LowerClean(props) { const { lower, title, options } = props; @@ -79,7 +79,7 @@ export default function LowerClean(props) { return (
    {showLower && ( - + {title.titleNow} - + {title.presenterNow} @@ -116,7 +116,7 @@ export default function LowerClean(props) { {showLowerMessage && ( -
    {lower.text}
    +
    {lower.text}
    )}
    diff --git a/client/src/features/viewers/lower-thirds/LowerClean.scss b/client/src/features/viewers/lower-thirds/LowerClean.scss new file mode 100644 index 000000000..b32cca142 --- /dev/null +++ b/client/src/features/viewers/lower-thirds/LowerClean.scss @@ -0,0 +1,26 @@ +.lower-third.clean { + margin: 0; + box-sizing: border-box; /* reset */ + overflow: hidden; + width: 100%; /* restrict the page width to viewport */ + height: 100%; + color: #fffffa; + font-size: 4vh; + + .lower-container { + position: absolute; + padding: 1vh 2vh; + border-radius: 1vh; + } + + .message-container { + position: absolute; + bottom: 2vh; + width: 100%; + text-align: center; + + .message { + font-size: 3.5vh; + } + } +} diff --git a/client/src/features/viewers/production/lower/LowerLines.jsx b/client/src/features/viewers/lower-thirds/LowerLines.jsx similarity index 85% rename from client/src/features/viewers/production/lower/LowerLines.jsx rename to client/src/features/viewers/lower-thirds/LowerLines.jsx index 36c1d7d22..0923cfce8 100644 --- a/client/src/features/viewers/production/lower/LowerLines.jsx +++ b/client/src/features/viewers/lower-thirds/LowerLines.jsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; -import NavLogo from "../../../../common/components/nav/NavLogo"; +import NavLogo from "../../../common/components/nav/NavLogo"; -import style from './LowerLines.module.css'; +import './LowerLines.scss'; export default function LowerLines(props) { const { lower, title, options } = props; @@ -122,7 +122,7 @@ export default function LowerLines(props) { return (
    {showLower && ( - + {title.titleNow} -
    +
    -
    +
    {title.presenterNow} @@ -170,14 +170,14 @@ export default function LowerLines(props) { {showLowerMessage && ( -
    {lower.text}
    +
    {lower.text}
    )}
    diff --git a/client/src/features/viewers/lower-thirds/LowerLines.scss b/client/src/features/viewers/lower-thirds/LowerLines.scss new file mode 100644 index 000000000..72b2a8537 --- /dev/null +++ b/client/src/features/viewers/lower-thirds/LowerLines.scss @@ -0,0 +1,66 @@ +.lower-third.lines { + margin: 0; + box-sizing: border-box; /* reset */ + overflow: hidden; + width: 100%; /* restrict the page width to viewport */ + height: 100%; + color: #fffffa; + font-size: 4vh; + + .lower-container { + position: absolute; + top: 75vh; + background-color: #0003; + padding: 1vh 1vw 1vh 0; + display: flex; + flex-direction: column; + width: 45vw; + border-radius: 0 1vh 1vh 0; + } + + .message-container { + position: absolute; + bottom: 2vh; + width: 100%; + text-align: center; + background-color: #0003; + + .message { + font-size: 3.5vh; + } + } + + .title-container, + .subtitle-container { + display: grid; + grid-template-columns: auto max-content; + grid-template-areas: 'decor text'; + align-items: center; + position: relative; + margin-bottom: 1vh; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .title-decor, + .sub-decor { + grid-area: decor; + height: 4vh; + width: 100%; + background-color: #ff6969; + background: linear-gradient( + 90deg, + rgb(255 105 105 / 100%) 0%, + rgb(255 132 132 / 100%) 100% + ); + } + + .title, + .subtitle { + grid-area: text; + padding-left: 1vw; + justify-self: right; + width: fit-content; + } +} diff --git a/client/src/features/viewers/lower-thirds/LowerWrapper.jsx b/client/src/features/viewers/lower-thirds/LowerWrapper.jsx new file mode 100644 index 000000000..d0fc9c0f6 --- /dev/null +++ b/client/src/features/viewers/lower-thirds/LowerWrapper.jsx @@ -0,0 +1,137 @@ +import { memo, useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import PropTypes from 'prop-types'; + +import { overrideStylesURL } from '../../../common/api/apiConstants'; +import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; + +import LowerClean from './LowerClean'; +import LowerLines from './LowerLines'; + +const isEqual = require('react-fast-compare'); + +const areEqual = (prevProps, nextProps) => { + return isEqual(prevProps.title, nextProps.title) && isEqual(prevProps.lower, nextProps.lower); +}; + +const Lower = (props) => { + const { title, lower, viewSettings } = props; + const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); + const [searchParams] = useSearchParams(); + const [titles, setTitles] = useState({ + titleNow: '', + titleNext: '', + subtitleNow: '', + subtitleNext: '', + presenterNow: '', + presenterNext: '', + showNow: false, + showNext: false, + }); + + // Set window title + useEffect(() => { + document.title = 'ontime - Lower Thirds'; + }, []); + + // reload if data changes + useEffect(() => { + // clear titles if necessary + // will trigger an animation out in the component + let timeout = null; + if ( + title?.titleNow !== titles?.titleNow || + title?.subtitleNow !== titles?.subtitleNow || + title?.presenterNow !== titles?.presenterNow + ) { + setTitles((t) => ({ ...t, showNow: false })); + + const transitionTime = 2000; + + timeout = setTimeout(() => { + setTitles(title); + }, transitionTime); + } + + return () => { + if (timeout != null) { + clearTimeout(timeout); + } + }; + // eslint-disable-next-line + }, [title.titleNow, title.subtitleNow, title.presenterNow]); + + // defer rendering until we load stylesheets + if (!shouldRender) { + return null; + } + + // TODO: sanitize data + // getting config from URL: preset, size, transition, bg, text, key + // eg. http://localhost:3000/lower?bg=ff2&text=f00&size=0.6&transition=5 + // Check for user options + // create aux + const options = {}; + + // preset: selector + // Should be a number 1-n + const p = parseInt(searchParams.get('preset'), 10); + const preset = !isNaN(p) ? 1 : p; + + // size: multiplier + // Should be a number 0.0-n + const s = searchParams.get('size'); + if (s) options.size = s; + + // transitionIn: seconds + // Should be a number 0-n + const t = parseInt(searchParams.get('transition'), 10); + if (!isNaN(t)) options.transitionIn = t; + + // textColour: string + // Should be a hex string '#ffffff' + const c = searchParams.get('text'); + if (c) options.textColour = `#${c}`; + + // bgColour: string + // Should be a hex string '#ffffff' + const b = searchParams.get('bg'); + if (b) options.bgColour = `#${b}`; + + // key: string + // Should be a hex string '#00FF00' with key colour + const k = searchParams.get('key'); + if (k) options.keyColour = `#${k}`; + + // fadeOut: seconds + // Should be a number 0-n + const f = parseInt(searchParams.get('fadeout'), 10); + if (!isNaN(f)) options.fadeOut = f; + + // x: pixels + // Should be a number 0-n + const x = parseInt(searchParams.get('x'), 10); + if (!isNaN(x)) options.posX = x; + + // y: pixels + // Should be a number 0-n + const y = parseInt(searchParams.get('y'), 10); + if (!isNaN(y)) options.posY = y; + + switch (preset) { + case 0: + return ; + case 1: + return ; + default: + return ; + } +}; + +export default memo(Lower, areEqual); + +Lower.propTypes = { + title: PropTypes.object, + lower: PropTypes.object, + viewSettings: PropTypes.object, +}; diff --git a/client/src/features/viewers/timer/MinimalTimer.jsx b/client/src/features/viewers/minimal-timer/MinimalTimer.jsx similarity index 77% rename from client/src/features/viewers/timer/MinimalTimer.jsx rename to client/src/features/viewers/minimal-timer/MinimalTimer.jsx index 2db8a5feb..3db3c3896 100644 --- a/client/src/features/viewers/timer/MinimalTimer.jsx +++ b/client/src/features/viewers/minimal-timer/MinimalTimer.jsx @@ -1,16 +1,27 @@ +import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import PropTypes from 'prop-types'; +import { overrideStylesURL } from '../../../common/api/apiConstants'; import NavLogo from '../../../common/components/nav/NavLogo'; +import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { formatDisplay } from '../../../common/utils/dateConfig'; -import style from './MinimalTimer.module.scss'; +import './MinimalTimer.scss'; export default function MinimalTimer(props) { - const { pres, time } = props; + const { pres, time, viewSettings } = props; + const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const [searchParams] = useSearchParams(); - document.title = 'ontime - Minimal Timer'; + useEffect(() => { + document.title = 'ontime - Minimal Timer'; + }, []); + + // defer rendering until we load stylesheets + if (!shouldRender) { + return null; + } // get config from url: key, text, font, size, hidenav, hideovertime // eg. http://localhost:3000/minimal?key=f00&text=fff @@ -102,11 +113,11 @@ export default function MinimalTimer(props) { const isPlaying = time.playstate !== 'pause'; const timer = formatDisplay(time.running, true); const clean = timer.replace('/:/g', ''); - const finishedStyle = userOptions?.hideOvertime ? style.container : style.containerFinished; + const showFinished = time.isNegative && !userOptions?.hideOvertime; return (
    {!hideMessagesOverlay && ( -
    -
    {pres.text}
    +
    +
    {pres.text}
    )} {!userOptions?.hideNav && }
    { - const h = pipAreaRef.current.clientHeight; - const w = pipAreaRef.current.clientWidth; - setSize(`${w} x ${h}`); - }, []); - // Set window title useEffect(() => { document.title = 'ontime - Pip'; @@ -57,6 +52,11 @@ export default function Pip(props) { setFilteredEvents(events.filter((e) => e.type === 'event')); }, [backstageEvents]); + // defer rendering until we load stylesheets + if (!shouldRender) { + return null; + } + // Format messages const showInfo = general.backstageInfo !== '' && general.backstageInfo != null; let stageTimer = formatDisplay(Math.abs(time.running), true); @@ -65,20 +65,20 @@ export default function Pip(props) { const clock = formatTime(time.clock, formatOptions); return ( -
    +
    -
    {general.title}
    +
    {general.title}
    -
    -
    -
    Today
    -
    +
    +
    +
    Today
    +
    {pageNumber > 1 && [...Array(pageNumber).keys()].map((i) => (
    ))}
    @@ -94,19 +94,16 @@ export default function Pip(props) { />
    -
    - - {size} +
    +
    {showInfo && ( - -
    Info
    -
    -
    {general.backstageInfo}
    -
    -
    + +
    Info
    +
    {general.backstageInfo}
    +
    {general.url != null && general.url !== '' && ( )} @@ -115,14 +112,14 @@ export default function Pip(props) { )} -
    -
    Time Now
    -
    {clock}
    +
    +
    Time Now
    +
    {clock}
    -
    -
    Stage Timer
    -
    {stageTimer}
    +
    +
    Stage Timer
    +
    {stageTimer}
    ); @@ -133,4 +130,5 @@ Pip.propTypes = { backstageEvents: PropTypes.object, selectedId: PropTypes.string, general: PropTypes.object, + viewSettings: PropTypes.object, }; diff --git a/client/src/features/viewers/picture-in-picture/Pip.scss b/client/src/features/viewers/picture-in-picture/Pip.scss new file mode 100644 index 000000000..fde38641a --- /dev/null +++ b/client/src/features/viewers/picture-in-picture/Pip.scss @@ -0,0 +1,136 @@ +@use '../../../theme/viewerDefs' as *; + +.pip { + margin: 0; + box-sizing: border-box; /* reset */ + overflow: hidden; + width: 100%; /* restrict the page width to viewport */ + background: var(--background-color-override, $viewer-background-color); + height: 100vh; + color: var(--color-override, $viewer-color); + font-weight: 300; + + display: grid; + grid-template-columns: 20vw 20vw 18vw 3vw 1fr; + grid-template-rows: 60vh 1fr 1fr 1fr; + grid-template-areas: + ' pip pip pip . schd' + ' titl titl titl . schd' + ' info info clck . schd' + ' info info time . schd'; + gap: 1vw; + padding: 1vw; + + .today-container, + .info-container, + .clock-container, + .timer-container { + background-color: var(--outdent-background-color-override, $viewer-outdent-bg-color); + padding: 1vh 1vw; + border-radius: 1vw; + + .label { + font-size: 1.3vw; + color: var(--accent-color-override, $accent-color); + } + + .clock, + .timer { + font-family: 'Open Sans', sans-serif; + font-size: 2.25vw; + line-height: 3vw; + text-align: center; + letter-spacing: 0.25vw; + color: var(--color-override, $viewer-color); + } + } + + .event-title { + grid-area: titl; + font-size: 3vw; + font-weight: 600; + text-decoration: underline 0.5vh; + text-decoration-color: var(--accent-color-override, $accent-color); + padding-top: 0.2vh; + padding-left: 1vw; + } + + .today-container { + grid-area: schd; + padding: 2.5vh 2vw; + overflow: hidden; + margin-top: 3vh; + height: 95%; + + .today-header-block { + display: flex; + justify-content: space-between; + margin-bottom: 1.5vh; + + .nav { + align-self: center; + justify-content: flex-end; + display: flex; + margin-bottom: 2.5vh; + } + + .nav-item { + background-color: $today-item-bg; + width: 0.7vw; + height: 0.7vw; + border-radius: 0.35vw; + margin-left: 0.5vw; + } + } + } + + .pip-placeholder { + grid-area: pip; + background-color: $pip-bg-color; + border: 1px solid $pip-border-color; + width: 100%; + text-align: center; + display: grid; + place-content: center; + + &__empty { + opacity: 0.5; + } + } + + .info-container { + grid-area: info; + display: grid; + + grid-template-rows: 3vh minmax(0, 1fr); + grid-template-columns: 3fr 1fr; + grid-template-areas: + 'titl .' + 'binf qr'; + gap: 0.5vw; + + .info-message { + grid-area: binf; + font-size: 1.5vw; + line-height: 2vw; + white-space: pre-line; + overflow: hidden; + } + + .qr { + align-self: center; + justify-self: center; + grid-area: qr; + } + } + + .clock-container { + grid-area: time; + border-radius: 0 0 1vw 1vw; + } + + .timer-container { + grid-area: clck; + border-radius: 1vw 1vw 0 0; + } +} diff --git a/client/src/features/viewers/production/Pip.module.scss b/client/src/features/viewers/production/Pip.module.scss deleted file mode 100644 index f219ce49d..000000000 --- a/client/src/features/viewers/production/Pip.module.scss +++ /dev/null @@ -1,63 +0,0 @@ -@use '../../../theme/main' as *; -@use '../../../theme/viewers' as *; - -.container__gray, -.container__grayFinished { - @include viewer-container; - - display: grid; - grid-template-columns: 20vw 20vw 18vw 3vw 1fr; - grid-template-rows: 60vh 1fr 1fr 1fr; - grid-template-areas: - ' pip pip pip . schd' - ' titl titl titl . schd' - ' info info clck . schd' - ' info info time . schd'; - gap: 1vw; - padding: 1vw; -} - -.eventTitle { - grid-area: titl; - @include viewer-event-title; -} - -.pip { - grid-area: pip; - background-color: $bg-black-100; - border: 1px solid $bg-black-200; - width: 100%; - text-align: center; - display: grid; - place-content: center; -} - -.empty { - opacity: 0.5; -} - -.piptext { - color: $bg-gray-900; - font-weight: 600; - font-size: 4vh; -} - -.infoContainer, -.clockContainer, -.countdownContainer { - background-color: $bg-gray-1000; - padding: 1vh 1vw; -} - -.todayContainer { - grid-area: schd; -} - -.countdownContainer { - grid-area: clck; - border-radius: 1vw 1vw 0 0; -} - -.clockContainer { - border-radius: 0 0 1vw 1vw; -} diff --git a/client/src/features/viewers/production/lower/LowerClean.module.css b/client/src/features/viewers/production/lower/LowerClean.module.css deleted file mode 100644 index 409a473c3..000000000 --- a/client/src/features/viewers/production/lower/LowerClean.module.css +++ /dev/null @@ -1,27 +0,0 @@ -.lowerThird { - margin: 0; - box-sizing: border-box; /* reset */ - overflow: hidden; - width: 100%; /* restrict the page width to viewport */ - height: 100%; - - color: #fffffa; - font-size: 4vh; -} - -.lowerContainer { - position: absolute; - padding: 1vh 2vh; - border-radius: 1vh; -} - -.messageContainer { - position: absolute; - bottom: 2vh; - width: 100%; - text-align: center; -} - -.message { - font-size: 3.5vh; -} diff --git a/client/src/features/viewers/production/lower/LowerLines.module.css b/client/src/features/viewers/production/lower/LowerLines.module.css deleted file mode 100644 index 0beb2485a..000000000 --- a/client/src/features/viewers/production/lower/LowerLines.module.css +++ /dev/null @@ -1,79 +0,0 @@ -.lowerThird { - margin: 0; - box-sizing: border-box; /* reset */ - overflow: hidden; - width: 100%; /* restrict the page width to viewport */ - height: 100%; - - color: #fffffa; - font-size: 4vh; -} - -.lowerContainer { - position: absolute; - top: 75vh; - background-color: #00000033; - - padding: 1vh 1vw 1vh 0; - display: flex; - flex-direction: column; - width: 45vw; - border-radius: 0 1vh 1vh 0; -} - -.messageContainer { - position: absolute; - bottom: 2vh; - width: 100%; - text-align: center; -} - -.titleContainer, -.subtitleContainer { - display: grid; - grid-template-columns: auto max-content; - grid-template-areas: 'decor text'; - align-items: center; - position: relative; -} - -.titleContainer, -.subtitleContainer { - margin-bottom: 1vh; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.titleDecor, -.subDecor { - grid-area: decor; - height: 4vh; - background-color: #ff6969; - background: linear-gradient( - 90deg, - rgba(255, 105, 105, 1) 0%, - rgba(255, 132, 132, 1) 100% - ); -} - -.titleDecor, -.subDecor { - width: 100%; -} - -.title, -.subtitle { - grid-area: text; - padding-left: 1vw; - justify-self: right; - width: fit-content; -} - -.messageContainer { - background-color: #00000033; -} - -.message { - font-size: 3.5vh; -} diff --git a/client/src/features/viewers/production/lower/LowerWrapper.jsx b/client/src/features/viewers/production/lower/LowerWrapper.jsx deleted file mode 100644 index 900f89916..000000000 --- a/client/src/features/viewers/production/lower/LowerWrapper.jsx +++ /dev/null @@ -1,141 +0,0 @@ -import { memo, useEffect, useState } from 'react'; -import isEqual from "react-fast-compare"; -import { useSearchParams } from 'react-router-dom'; - -import LowerClean from './LowerClean'; -import LowerLines from './LowerLines'; - -const areEqual = (prevProps, nextProps) => { - return ( - isEqual(prevProps.title, nextProps.title) && - isEqual(prevProps.lower, nextProps.lower) - ); -}; - -const Lower = (props) => { - const { title, lower } = props; - const [searchParams,] = useSearchParams(); - const [titles, setTitles] = useState({ - titleNow: '', - titleNext: '', - subtitleNow: '', - subtitleNext: '', - presenterNow: '', - presenterNext: '', - showNow: false, - showNext: false, - }); - const [preset, setPreset] = useState(1); - const [lowerOptions, setLowerOptions] = useState({}); - - // Set window title - useEffect(() => { - document.title = 'ontime - Lower Thirds'; - }, []); - - // reload if data changes - useEffect(() => { - // clear titles if necessary - // will trigger an animation out in the component - let timeout = null; - if ( - title?.titleNow !== titles?.titleNow || - title?.subtitleNow !== titles?.subtitleNow || - title?.presenterNow !== titles?.presenterNow - ) { - setTitles((t) => ({ ...t, showNow: false })); - - const transitionTime = 2000; - - timeout = setTimeout(() => { - setTitles(title); - }, transitionTime); - } - - return () => { - if (timeout != null) { - clearTimeout(timeout); - } - }; - // eslint-disable-next-line - }, [title.titleNow, title.subtitleNow, title.presenterNow]); - - // TODO: sanitize data - // getting config from URL: preset, size, transition, bg, text, key - // eg. http://localhost:3000/lower?bg=ff2&text=f00&size=0.6&transition=5 - // Check for user options - useEffect(() => { - // create aux - const options = {}; - - // preset: selector - // Should be a number 1-n - const p = parseInt(searchParams.get('preset'), 10); - if (!isNaN(p)) setPreset(p); - - // size: multiplier - // Should be a number 0.0-n - const s = searchParams.get('size'); - if (s) options.size = s; - - // transitionIn: seconds - // Should be a number 0-n - const t = parseInt(searchParams.get('transition'), 10); - if (!isNaN(t)) options.transitionIn = t; - - // textColour: string - // Should be a hex string '#ffffff' - const c = searchParams.get('text'); - if (c) options.textColour = `#${c}`; - - // bgColour: string - // Should be a hex string '#ffffff' - const b = searchParams.get('bg'); - if (b) options.bgColour = `#${b}`; - - // key: string - // Should be a hex string '#00FF00' with key colour - const k = searchParams.get('key'); - if (k) options.keyColour = `#${k}`; - - // fadeOut: seconds - // Should be a number 0-n - const f = parseInt(searchParams.get('fadeout'), 10); - if (!isNaN(f)) options.fadeOut = f; - - // x: pixels - // Should be a number 0-n - const x = parseInt(searchParams.get('x'), 10); - if (!isNaN(x)) options.posX = x; - - // y: pixels - // Should be a number 0-n - const y = parseInt(searchParams.get('y'), 10); - if (!isNaN(y)) options.posY = y; - - setLowerOptions({ - ...options, - set: true, - }); - }, [searchParams]); - - // Defer rendering until we have data ready - if (!lowerOptions.set) return null; - - switch (preset) { - case 0: - return ( - - ); - case 1: - return ( - - ); - default: - return ( - - ); - } -}; - -export default memo(Lower, areEqual); diff --git a/client/src/features/viewers/foh/Public.jsx b/client/src/features/viewers/public/Public.jsx similarity index 65% rename from client/src/features/viewers/foh/Public.jsx rename to client/src/features/viewers/public/Public.jsx index f4aad3d4a..07f410da1 100644 --- a/client/src/features/viewers/foh/Public.jsx +++ b/client/src/features/viewers/public/Public.jsx @@ -1,15 +1,17 @@ import { useEffect, useState } from 'react'; import QRCode from 'react-qr-code'; import NavLogo from 'common/components/nav/NavLogo'; -import Paginator from 'common/components/views/Paginator'; -import TitleSide from 'common/components/views/TitleSide'; +import Paginator from 'common/components/paginator/Paginator'; +import TitleSide from 'common/components/title-side/TitleSide'; import { AnimatePresence, motion } from 'framer-motion'; import PropTypes from 'prop-types'; +import { overrideStylesURL } from '../../../common/api/apiConstants'; +import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { formatTime } from '../../../common/utils/time'; import { titleVariants } from '../common/animation'; -import style from './Public.module.scss'; +import './Public.scss'; const formatOptions = { showSeconds: true, @@ -17,7 +19,8 @@ const formatOptions = { }; export default function Public(props) { - const { publ, publicTitle, time, events, publicSelectedId, general } = props; + const { publ, publicTitle, time, events, publicSelectedId, general, viewSettings } = props; + const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const [pageNumber, setPageNumber] = useState(0); const [currentPage, setCurrentPage] = useState(0); @@ -26,21 +29,26 @@ export default function Public(props) { document.title = 'ontime - Public Screen'; }, []); + // defer rendering until we load stylesheets + if (!shouldRender) { + return null; + } + // Format messages const showPubl = publ.text !== '' && publ.visible; const clock = formatTime(time.clock, formatOptions); return ( -
    +
    -
    {general.title}
    +
    {general.title}
    {publicTitle.showNow && ( {publicTitle.showNext && ( -
    -
    -
    Today
    -
    +
    +
    +
    Today
    +
    {pageNumber > 1 && [...Array(pageNumber).keys()].map((i) => (
    ))}
    @@ -101,22 +109,20 @@ export default function Public(props) { />
    -
    -
    Public message
    -
    {publ.text}
    +
    +
    Public message
    +
    {publ.text}
    -
    -
    Time Now
    -
    {clock}
    +
    +
    Time Now
    +
    {clock}
    -
    -
    Info
    -
    -
    {general.publicInfo}
    -
    -
    +
    +
    Info
    +
    {general.publicInfo}
    +
    {general.url != null && general.url !== '' && ( )} @@ -133,4 +139,5 @@ Public.propTypes = { events: PropTypes.object, publicSelectedId: PropTypes.string, general: PropTypes.object, + viewSettings: PropTypes.object, }; diff --git a/client/src/features/viewers/public/Public.scss b/client/src/features/viewers/public/Public.scss new file mode 100644 index 000000000..fb5fa3c38 --- /dev/null +++ b/client/src/features/viewers/public/Public.scss @@ -0,0 +1,146 @@ +@use '../../../theme/viewerDefs' as *; + +.public-screen { + margin: 0; + box-sizing: border-box; /* reset */ + overflow: hidden; + width: 100%; /* restrict the page width to viewport */ + background: var(--background-color-override, $viewer-background-color); + height: 100vh; + color: var(--color-override, $viewer-color); + font-weight: 300; + + display: grid; + grid-template-columns: 1fr 1fr 1fr 3vw 2fr; + grid-template-rows: 15vh 1fr 1fr 0 13vh 13vh; + grid-template-areas: + ' titl titl titl . schd' + ' now now now . schd' + ' next next .... . schd' + ' ... .... .... . ....' + ' publ publ .... . info' + ' publ publ time . info'; + gap: 1vw; + padding: 1vw; + + .event, + .public-container, + .info, + .timer-container, + .clock-container, + .today-container { + background-color: var(--outdent-background-color-override, $viewer-outdent-bg-color); + padding: 1vh 1vw; + border-radius: 1vw; + + .label { + font-size: 1.3vw; + color: var(--accent-color-override, $accent-color); + } + + .clock, + .timer { + font-family: 'Open Sans', sans-serif; + font-size: 2.25vw; + line-height: 3vw; + text-align: center; + letter-spacing: 0.25vw; + color: var(--color-override, $viewer-color); + } + } + + .event { + border-radius: 0 2vw 2vw 0; + margin-left: -1vw; + padding: 1vh 2vw; + overflow: hidden; + + &.now { + grid-area: now; + } + &.next { + grid-area: next; + } + } + + .event-title { + grid-area: titl; + font-size: 3vw; + font-weight: 600; + text-decoration: underline 0.5vh; + text-decoration-color: var(--accent-color-override, $accent-color); + padding-top: 0.2vh; + padding-left: 1vw; + } + + .today-container { + grid-area: schd; + padding: 2.5vh 2vw; + overflow: hidden; + margin-top: 3vh; + height: 95%; + + .today-header-block { + display: flex; + justify-content: space-between; + margin-bottom: 1.5vh; + + .nav { + align-self: center; + justify-content: flex-end; + display: flex; + margin-bottom: 2.5vh; + } + + .nav-item { + background-color: $today-item-bg; + width: 0.7vw; + height: 0.7vw; + border-radius: 0.35vw; + margin-left: 0.5vw; + } + } + } + + .public-container { + grid-area: publ; + + &--hidden { + opacity: 0; + transition: 0.5s; + transition-property: opacity; + } + } + + .clock-container { + grid-area: time; + border-radius: 1vw; + } + + .info { + grid-area: info; + display: grid; + padding: 2.5vh 2vw; + + grid-template-rows: 3vh minmax(0, 1fr); + grid-template-columns: 3fr 1fr; + grid-template-areas: + 'titl .' + 'binf qr'; + gap: 0.5vw; + + &__message { + grid-area: binf; + font-size: 1.5vw; + line-height: 2vw; + white-space: pre-line; + overflow: hidden; + } + + .qr { + align-self: center; + justify-self: center; + grid-area: qr; + } + } +} diff --git a/client/src/features/viewers/studio/StudioClock.jsx b/client/src/features/viewers/studio/StudioClock.jsx index 51ffa7766..fa8d8e5a0 100644 --- a/client/src/features/viewers/studio/StudioClock.jsx +++ b/client/src/features/viewers/studio/StudioClock.jsx @@ -2,7 +2,9 @@ import { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import useFitText from 'use-fit-text'; +import { overrideStylesURL } from '../../../common/api/apiConstants'; import NavLogo from '../../../common/components/nav/NavLogo'; +import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { formatDisplay } from '../../../common/utils/dateConfig'; import { formatEventList, @@ -11,7 +13,7 @@ import { } from '../../../common/utils/eventsManager'; import { formatTime, stringFromMillis } from '../../../common/utils/time'; -import style from './StudioClock.module.scss'; +import './StudioClock.scss'; const formatOptions = { showSeconds: false, @@ -19,15 +21,18 @@ const formatOptions = { }; export default function StudioClock(props) { - const { title, time, backstageEvents, selectedId, nextId, onAir } = props; + const { title, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props; + + // deferring rendering seems to affect styling (font and useFitText) + useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 }); + const [schedule, setSchedule] = useState([]); const activeIndicators = [...Array(12).keys()]; const secondsIndicators = [...Array(60).keys()]; const MAX_TITLES = 10; - // Set window title useEffect(() => { document.title = 'ontime - Studio Clock'; }, []); @@ -36,7 +41,6 @@ export default function StudioClock(props) { useEffect(() => { if (backstageEvents == null) return; - const delayed = getEventsWithDelay(backstageEvents); const events = delayed.filter((e) => e.type === 'event'); const trimmed = trimEventlist(events, selectedId, MAX_TITLES); @@ -47,29 +51,28 @@ export default function StudioClock(props) { }, [backstageEvents, nextId, selectedId] ); const clock = formatTime(time.clock, formatOptions); - const [, , secondsNow] = stringFromMillis(time.clock).split(':'); return ( -
    +
    -
    -
    {clock}
    +
    +
    {clock}
    {title.titleNext}
    -
    +
    {selectedId != null && formatDisplay(time.running)}
    -
    +
    {activeIndicators.map((i) => (
    (
    -
    -
    ON AIR
    -
    +
    +
    ON AIR
    +
      {schedule.map((s) => (
    • {`${s.time} ${s.title}`} @@ -113,4 +116,5 @@ StudioClock.propTypes = { selectedId: PropTypes.string, nextId: PropTypes.string, onAir: PropTypes.bool, + viewSettings: PropTypes.object, }; diff --git a/client/src/features/viewers/studio/StudioClock.module.scss b/client/src/features/viewers/studio/StudioClock.scss similarity index 72% rename from client/src/features/viewers/studio/StudioClock.module.scss rename to client/src/features/viewers/studio/StudioClock.scss index 797d54465..6db554ead 100644 --- a/client/src/features/viewers/studio/StudioClock.module.scss +++ b/client/src/features/viewers/studio/StudioClock.scss @@ -1,3 +1,5 @@ +@use '../../../theme/viewerDefs' as *; + @font-face { font-family: "digital-clock"; src: local('digital-7'), url('./../../../assets/fonts/digital-7.monoitalic.ttf') format('truetype') ; @@ -15,7 +17,7 @@ $red-idle: #300000; $cyan-active: #0ff; $cyan-idle: #0aa; -.container { +.studio-clock { margin: 0; box-sizing: border-box; /* reset */ overflow: hidden; @@ -23,14 +25,14 @@ $cyan-idle: #0aa; height: 100vh; padding: 1vw; - background: #000; + background: var(--background-color-override, #000); display: grid; grid-template-columns: 95vh 1fr; gap: 2vw; grid-template-areas: "clck schd"; - .clockContainer { + .clock-container { display: flex; flex-direction: column; align-items: center; @@ -44,114 +46,92 @@ $cyan-idle: #0aa; font-family: digital-clock, monospace; text-transform: uppercase; - .indicators { + .clock-indicators { position: absolute; top: 0; width: 100%; height: 100%; .min, - .min__active, - .hours, - .hours__active { + .hours { border-radius: 50%; position: absolute; background: $red-idle; + + &--active { + background: $red-active; + box-shadow: 0 0 10px 2px rgba(255, 0, 0, 0.25); + } } - .min, - .min__active { + .min { min-height: $size-min; width: $size-min; top: calc(50% - #{$half_min}); left: calc(50% - #{$half_min}); } - .hours, - .hours__active { + .hours { min-height: $size-hours; width: $size-hours; top: calc(50% - #{$half_hours}); left: calc(50% - #{$half_hours}); } - - .min__active, - .hours__active { - background: $red-active; - box-shadow: 0 0 10px 2px rgba(255, 0, 0, 0.25); - } } - .time { + .studio-timer { color: $red-active; font-size: calc(#{$clock-size} / 3); margin-top: calc(50% - calc(#{$clock-size} / 7)); line-height: 0.8em; } - .timeAA { - color: $red-active; - font-size: calc(#{$clock-size} / 4.5); - margin-top: calc(50% - calc(#{$clock-size} / 7)); - line-height: 0.8em; - } - - .nextTitle:after, - .nextCountdown:after, - .nextCountdown__overtime:after { + .next-title:after, + .next-title:after, + .next-countdown__overtime:after { content: '\200b'; } - .nextTitle { + .next-title { color: $cyan-idle; text-align: center; } - .nextCountdown, - .nextCountdown__overtime { + .next-countdown { font-size: 10vh; line-height: 1em; - } - - .nextCountdown { color: $cyan-active; - text-shadow: rgb(0, 100, 100) 0 0 20px; + + &--overtime { + color: darken($red-active, 10%); + } } - .nextCountdown::before { + .next-countdown::before { content: '-'; } - - .nextCountdown__overtime { - color: darken($red-active, 10%); - } } /* ============= SCHEDULE STUFF =================*/ - .scheduleContainer { + .schedule-container { grid-area: schd; margin: 4vh 0; font-family: digital-clock, monospace; text-transform: uppercase; - .onAir, - .onAir__idle { + .onAir { padding-bottom: 2vh; font-size: 15vh; line-height: 0.9em; - } - - .onAir { color: $red-active; - } - .onAir__idle { - color: $red-idle; + &--idle { + color: $red-idle; + } } .schedule { - ul { color: $cyan-idle; font-size: 3.75vh; @@ -161,6 +141,7 @@ $cyan-idle: #0aa; li { margin-bottom: 1.5vh; + padding-left: 0.25em; } .now { @@ -175,13 +156,14 @@ $cyan-idle: #0aa; } @media only screen and (max-width: 1200px) { - .container { + .studio-clock { display: grid; grid-template-areas: "clck"; grid-template-columns: 100%; place-content: center; - } - .scheduleContainer { - display: none; + + .schedule-container { + display: none; + } } } diff --git a/client/src/features/viewers/timer/MinimalTimer.module.scss b/client/src/features/viewers/timer/MinimalTimer.module.scss deleted file mode 100644 index 1ad79ccb2..000000000 --- a/client/src/features/viewers/timer/MinimalTimer.module.scss +++ /dev/null @@ -1,74 +0,0 @@ -@use '../../../theme/main' as *; - -.container, -.containerFinished { - background: $bg-black; - height: 100vh; - color: $title-white; - display: flex; - align-items: center; - justify-content: center; - gap: 1vw; - border: 1vw solid transparent; -} - -.containerFinished { - border: 1vw solid $ontime-pink-variant; - color: $ontime-pink-variant; - transition: 0.3s; -} - -.timer, -.timerPaused { - font-family: "Arial Black", sans-serif; - font-size: 20vw; - position: relative; - color: inherit; - opacity: 1; - transition: 0.5s; - transition-property: opacity; -} - -.timerPaused { - opacity: 0.6; - transition: 0.5s; -} - -/* =================== OVERLAY ===================*/ - -.messageOverlay, -.messageOverlayActive { - position: fixed; - width: 100%; - height: 100%; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: $bg-overlay; - z-index: -1; - opacity: 0; - transition: 0.5s; -} - -.messageOverlayActive { - opacity: 1; - transition: 0.5s; - transition-property: opacity; - z-index: 2; -} - -.message { - width: inherit; - padding: 2vw; - position: absolute; - top: 50%; - left: 50%; - color: white; - transform: translate(-50%, -50%); - -ms-transform: translate(-50%, -50%); - font-size: 15vw; - line-height: 30vh; - text-align: center; - font-weight: 600; -} diff --git a/client/src/features/viewers/timer/Timer.jsx b/client/src/features/viewers/timer/Timer.jsx index f8035c224..2e5e8f1e2 100644 --- a/client/src/features/viewers/timer/Timer.jsx +++ b/client/src/features/viewers/timer/Timer.jsx @@ -3,13 +3,15 @@ import { useSearchParams } from 'react-router-dom'; import TimerDisplay from 'common/components/countdown/TimerDisplay'; import MyProgressBar from 'common/components/myProgressBar/MyProgressBar'; import NavLogo from 'common/components/nav/NavLogo'; -import TitleCard from 'common/components/views/TitleCard'; +import TitleCard from 'common/components/title-card/TitleCard'; import { AnimatePresence, motion } from 'framer-motion'; import PropTypes from 'prop-types'; +import { overrideStylesURL } from '../../../common/api/apiConstants'; +import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { formatTime } from '../../../common/utils/time'; -import style from './Timer.module.scss'; +import './Timer.scss'; const formatOptions = { showSeconds: true, @@ -17,30 +19,32 @@ const formatOptions = { }; export default function Timer(props) { - const { general, pres, title, time } = props; + const { general, pres, title, time, viewSettings } = props; + const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const [elapsed, setElapsed] = useState(true); const [searchParams] = useSearchParams(); - // Set window title useEffect(() => { document.title = 'ontime - Timer'; }, []); + // defer rendering until we load stylesheets + if (!shouldRender) { + return null; + } + // eg. http://localhost:3000/timer?progress=up // Check for user options - useEffect(() => { - // progress: selector - // Should be 'up' or 'down' - const progress = searchParams.get('progress'); - if (progress === 'up') { - setElapsed(true); - } else if (progress === 'down') { - setElapsed(false); - } - }, [searchParams]); + // progress: selector + // Should be 'up' or 'down' + const progress = searchParams.get('progress'); + if (progress === 'up') { + setElapsed(true); + } else if (progress === 'down') { + setElapsed(false); + } const clock = formatTime(time.clock, formatOptions); - const showOverlay = pres.text !== '' && pres.visible; const isPlaying = time.playstate !== 'pause'; const normalisedTime = Math.max(time.running, 0); @@ -62,21 +66,21 @@ export default function Timer(props) { }; return ( -
      -
      -
      {pres.text}
      +
      +
      +
      {pres.text}
      -
      -
      Time Now
      -
      {clock}
      +
      +
      Time Now
      +
      {clock}
      -
      +
      {time.finished ? ( -
      +
      {general.endMessage == null || general.endMessage === '' ? ( ) : ( @@ -84,14 +88,18 @@ export default function Timer(props) { )}
      ) : ( -
      +
      )}
      {!time.finished && ( -
      +
      {title.showNow && ( {title.showNext && ( div { +.info-container > div { overflow: hidden; } -.nextContainer > div, -.nowContainer > div { +.next-container > div, +.now-container > div { @include ellipsis; } -.nowContainer, -.nextContainer, -.todayContainer { +.now-container, +.next-container, +.today-container { background-color: $bg-gray-1000; padding: 1vh 2vw; overflow: hidden; } -.clockContainer, -.countdownContainer, -.publicContainer, -.publicContainerHidden { - background-color: $bg-gray-1000; +.clock-container, +.countdown-container, +.public-container, +.public-container--hidden { padding: 1vh 1vw; } -.publicContainer { +.public-container { opacity: 1; transition: 0.5s; transition-property: opacity; } -.publicContainerHidden { +.public-container--hidden { opacity: 0; transition: 0.5s; transition-property: opacity; } -.todayContainer, -.infoContainer { +.today-container, +.info-container { background-color: $bg-gray-1100; padding: 2.5vh 2vw; } -.infoContainer, -.todayContainer, -.publicContainer, -.publicContainerHidden { +.info-container, +.today-container, +.public-container, +.public-container--hidden { border-radius: 1vw; } -.nowContainer, -.nextContainer { +.now-container, +.next-container { margin-left: -1vw; } @@ -102,7 +101,7 @@ color: $clocks; } -.infoContainer { +.info-container { grid-area: info; display: grid; @@ -114,14 +113,14 @@ gap: 0.5vw; } -.infoMessages { +.info-messages { grid-area: binf; font-size: 1.5vw; line-height: 2vw; white-space: pre-line; } -.clockContainer { +.clock-container { grid-area: time; border-radius: 1vw; } @@ -132,17 +131,17 @@ padding: 0.5vh 0 0.5vh 1vw; } -.todayContainer { +.today-container { margin-top: 3vh; height: 95%; - .todayHeaderBlock { + .today-header-block { display: flex; justify-content: space-between; margin-bottom: 1.5vh; - .navItem, - .navItemSelected { + .nav-item, + .nav-item--selected { background-color: $bg-gray-700; width: 0.7vw; height: 0.7vw; @@ -166,7 +165,7 @@ } } -.infoContainer { +.info-container { display: grid; grid-template-rows: 3vh minmax(0, 1fr); @@ -176,14 +175,14 @@ 'binf qr'; gap: 0.5vw; - .infoMessages { + .info-messages { grid-area: binf; font-size: 1.5vw; line-height: 2vw; white-space: pre-line; } - .clockContainer { + .clock-container { grid-area: time; border-radius: 1vw; } diff --git a/client/src/theme/_viewers_mixins.scss b/client/src/theme/_viewers_mixins.scss new file mode 100644 index 000000000..a67d2a5f3 --- /dev/null +++ b/client/src/theme/_viewers_mixins.scss @@ -0,0 +1,189 @@ +@use 'main' as *; + +@mixin viewer-container { + margin: 0; + box-sizing: border-box; /* reset */ + overflow: hidden; + width: 100%; /* restrict the page width to viewport */ + background: $bg-black; + height: 100vh; + color: $title-white; + font-weight: 300; +} + +@mixin viewer-event-title { + font-size: 3vw; + font-weight: 600; + text-decoration: underline $ontime-pink 0.5vh; + padding-top: 0.2vh; + padding-left: 1vw; +} + +@mixin viewer-info-messages { + font-size: 1.5vw; + line-height: 2vw; + white-space: pre-line; +} + +////////////////////////////// Specifics + +.label { + font-size: 1.3vw; + color: $ontime-pink; +} + +.infoContainer > div { + overflow: hidden; +} + +.nextContainer > div, +.nowContainer > div { + @include ellipsis; +} + +.nowContainer, +.nextContainer, +.todayContainer { + background-color: $bg-gray-1000; + padding: 1vh 2vw; + overflow: hidden; +} + +.clockContainer, +.countdownContainer, +.publicContainer, +.publicContainerHidden { + padding: 1vh 1vw; +} + +.publicContainer { + opacity: 1; + transition: 0.5s; + transition-property: opacity; +} + +.publicContainerHidden { + opacity: 0; + transition: 0.5s; + transition-property: opacity; +} + +.todayContainer, +.infoContainer { + background-color: $bg-gray-1100; + padding: 2.5vh 2vw; +} + +.infoContainer, +.todayContainer, +.publicContainer, +.publicContainerHidden { + border-radius: 1vw; +} + +.nowContainer, +.nextContainer { + margin-left: -1vw; +} + +.qr { + align-self: center; + justify-self: center; + grid-area: qr; +} + +.clock { + font-family: 'Open Sans', sans-serif; + font-size: 3vw; + line-height: 3vw; + text-align: center; + letter-spacing: 0.25vw; + color: $clocks; +} + +.infoContainer { + grid-area: info; + display: grid; + + grid-template-rows: 3vh minmax(0, 1fr); + grid-template-columns: 3fr 1fr; + grid-template-areas: + 'titl .' + 'binf qr'; + gap: 0.5vw; +} + +.infoMessages { + grid-area: binf; + font-size: 1.5vw; + line-height: 2vw; + white-space: pre-line; +} + +.clockContainer { + grid-area: time; + border-radius: 1vw; +} + +.message { + font-size: 3vw; + line-height: 3vw; + padding: 0.5vh 0 0.5vh 1vw; +} + +.todayContainer { + margin-top: 3vh; + height: 95%; + + .todayHeaderBlock { + display: flex; + justify-content: space-between; + margin-bottom: 1.5vh; + + .navItem, + .navItemSelected { + background-color: $bg-gray-700; + width: 0.7vw; + height: 0.7vw; + border-radius: 0.35vw; + } + + .navItemSelected { + background-color: $bg-gray-100; + } + + .nav { + align-self: center; + justify-content: flex-end; + display: flex; + margin-bottom: 2.5vh; + } + + .nav > div { + margin-left: 0.5vw; + } + } +} + +.infoContainer { + display: grid; + + grid-template-rows: 3vh minmax(0, 1fr); + grid-template-columns: 3fr 1fr; + grid-template-areas: + 'titl .' + 'binf qr'; + gap: 0.5vw; + + .infoMessages { + grid-area: binf; + font-size: 1.5vw; + line-height: 2vw; + white-space: pre-line; + } + + .clockContainer { + grid-area: time; + border-radius: 1vw; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 691025db5..6c6571a6d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - "127.0.0.1:8888:8888/udp" volumes: - "./ontime-db:/server/preloaded-db" + - "./ontime-external:/server/external" environment: - TZ=Asia/Singapore restart: unless-stopped diff --git a/server/package.json b/server/package.json index ef1ac6911..c1e19ddeb 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "ontime", - "version": "1.7.3", + "version": "1.8.0", "author": "Carlos Valente", "description": "Time keeping for live events", "repository": "https://github.com/cpvalente/ontime", diff --git a/server/src/app.js b/server/src/app.js index 017d31a5f..5a6db6a0c 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -48,15 +48,15 @@ app.options('*', cors()); app.use(express.urlencoded({ extended: true })); app.use(express.json({ limit: '1mb' })); -app.use('/uploads', express.static('uploads')); - // Implement route endpoints app.use('/events', eventsRouter); app.use('/event', eventRouter); app.use('/ontime', ontimeRouter); app.use('/playback', playbackRouter); -// serve react +// serve static - css +app.use('/external', express.static(join(__dirname, 'external'))); +// serve static - react app.use(express.static(join(__dirname, env === 'production' ? '../' : '../../', 'client/build'))); app.get('*', (req, res) => { diff --git a/server/src/controllers/ontimeController.js b/server/src/controllers/ontimeController.js index 7dd1f19b6..5daa7d334 100644 --- a/server/src/controllers/ontimeController.js +++ b/server/src/controllers/ontimeController.js @@ -234,6 +234,34 @@ export const postSettings = async (req, res) => { } }; +/** + * @description Get view Settings + * @method GET + */ +export const getViewSettings = async (req, res) => { + res.status(200).send({ ...data.views }); +}; + +/** + * @description Change view Settings + * @method POST + */ +export const postViewSettings = async (req, res) => { + if (!req.body) { + res.status(400).send('No object found in request'); + return; + } + try { + data.views = { + overrideStyles: req.body?.overrideStyles ?? data.views.overrideStyles, + }; + await db.write(); + res.sendStatus(200); + } catch (error) { + res.status(400).send(error); + } +}; + // Create controller for POST request to '/ontime/info' // Returns ACK message export const postInfo = async (req, res) => { diff --git a/server/src/external/styles/override.css b/server/src/external/styles/override.css new file mode 100644 index 000000000..c796b82aa --- /dev/null +++ b/server/src/external/styles/override.css @@ -0,0 +1,11 @@ +:root { + --background-color-override: #f3f4f8; + --color-override: #5051f9; + --accent-color-override: #23235f; + --timer-color-override: #5051f9; + --outdent-background-color-override: white; +} + +.timer { + font-family: "Comic Sans MS", serif !important; +} diff --git a/server/src/models/dataModel.js b/server/src/models/dataModel.js index 9d469332f..571aac78d 100644 --- a/server/src/models/dataModel.js +++ b/server/src/models/dataModel.js @@ -15,6 +15,9 @@ export const dbModelv1 = { pinCode: null, timeFormat: '24', }, + views: { + overrideStyles: false, + }, aliases: [], userFields: { user0: 'user0', diff --git a/server/src/package.json b/server/src/package.json index 8afe05309..1eae3c477 100644 --- a/server/src/package.json +++ b/server/src/package.json @@ -1,12 +1,13 @@ { "name": "ontime-server", "type": "module", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "body-parser": "^1.20.0", "dotenv": "^16.0.1", "express": "^4.18.1", "express-session": "^1.17.3", + "express-validator": "^6.14.2", "lowdb": "3.0.0", "multer": "^1.4.4", "nanoid": "^3.3.3", diff --git a/server/src/routes/ontimeRouter.js b/server/src/routes/ontimeRouter.js index 951ee7871..987103132 100644 --- a/server/src/routes/ontimeRouter.js +++ b/server/src/routes/ontimeRouter.js @@ -1,25 +1,28 @@ import express from 'express'; import { uploadFile } from '../utils/upload.js'; - -export const router = express.Router(); - import { dbDownload, - dbUpload, - getInfo, - postInfo, dbPathToUpload, - getOSC, - postOSC, - getSettings, - postSettings, + dbUpload, getAliases, - postAliases, - poll, + getInfo, + getOSC, + getSettings, getUserFields, + getViewSettings, + poll, + postAliases, + postInfo, + postOSC, + postSettings, postUserFields, + postViewSettings, } from '../controllers/ontimeController.js'; +import { viewValidator } from '../validation/ontimeValidator.js'; + +export const router = express.Router(); + // create route between controller and '/ontime/sync' endpoint router.get('/poll', poll); @@ -35,6 +38,12 @@ router.get('/settings', getSettings); // create route between controller and '/ontime/settings' endpoint router.post('/settings', postSettings); +// create route between controller and '/ontime/views' endpoint +router.get('/views', getViewSettings); + +// create route between controller and '/ontime/views' endpoint +router.post('/views', viewValidator, postViewSettings); + // create route between controller and '/ontime/aliases' endpoint router.get('/aliases', getAliases); diff --git a/server/src/utils/__tests__/parser.tests.js b/server/src/utils/__tests__/parser.tests.js index ddc77361a..9db5bea23 100644 --- a/server/src/utils/__tests__/parser.tests.js +++ b/server/src/utils/__tests__/parser.tests.js @@ -2,7 +2,7 @@ import jest from 'jest-mock'; import { dbModelv1, dbModelv1 as dbModel } from '../../models/dataModel.js'; import { isStringEmpty, parseExcel_v1, parseJson_v1, validateEvent_v1 } from '../parser.js'; import { makeString, validateDuration } from '../parserUtils.js'; -import { parseAliases_v1, parseUserFields_v1 } from '../parserUtils_v1.js'; +import { parseAliases_v1, parseUserFields_v1, parseViews_v1 } from '../parserUtils_v1.js'; describe('test json parser with valid def', () => { const testData = { @@ -817,6 +817,35 @@ describe('test userFields import', () => { }); }); +describe('test views import', () => { + it('imports data from file', () => { + const testData = { + events: [], + settings: { + app: 'ontime', + version: 1, + }, + views: { + overrideStyles: true, + }, + }; + const parsed = parseViews_v1(testData); + expect(parsed).toStrictEqual(testData.views); + }); + + it('imports defaults to model', () => { + const testData = { + events: [], + settings: { + app: 'ontime', + version: 1, + }, + }; + const parsed = parseViews_v1(testData, true); + expect(parsed).toStrictEqual(dbModelv1.views); + }); +}); + describe('test validateDuration()', () => { describe('handles valid inputs', () => { const valid = [ diff --git a/server/src/utils/parser.js b/server/src/utils/parser.js index c26fb7a7e..aebb58f50 100644 --- a/server/src/utils/parser.js +++ b/server/src/utils/parser.js @@ -11,6 +11,7 @@ import { parseOsc_v1, parseSettings_v1, parseUserFields_v1, + parseViews_v1, } from './parserUtils_v1.js'; import { parseExcelDate } from './time.js'; import { generateId } from './generate_id.js'; @@ -272,6 +273,8 @@ export const parseJson_v1 = async (jsonData, enforce = false) => { returnData.event = parseEvent_v1(jsonData, enforce); // Settings handled partially returnData.settings = parseSettings_v1(jsonData, enforce); + // View settings handled partially + returnData.views = parseViews_v1(jsonData, enforce); // Import OSC settings if any returnData.osc = parseOsc_v1(jsonData, enforce); // Import HTTP settings if any diff --git a/server/src/utils/parserUtils_v1.js b/server/src/utils/parserUtils_v1.js index d20078b3e..e4ae8f105 100644 --- a/server/src/utils/parserUtils_v1.js +++ b/server/src/utils/parserUtils_v1.js @@ -118,6 +118,33 @@ export const parseSettings_v1 = (data, enforce) => { return newSettings; }; +/** + * Parse settings portion of an entry + * @param {object} data - data object + * @param {boolean} enforce - whether to create a definition if one is missing + * @returns {object} - event object data + */ +export const parseViews_v1 = (data, enforce) => { + let newViews = {}; + if ('views' in data) { + console.log('Found view definition, importing...'); + const v = data.views; + + const viewSettings = { + overrideStyles: v.overrideStyles ?? dbModelv1.views.overrideStyles, + }; + + // write to db + newViews = { + ...viewSettings, + }; + } else if (enforce) { + newViews = dbModelv1.views; + console.log(`Created view object in db`); + } + return newViews; +}; + /** * Parse osc portion of an entry * @param {object} data - data object diff --git a/server/src/validation/ontimeValidator.js b/server/src/validation/ontimeValidator.js new file mode 100644 index 000000000..55a4dc44e --- /dev/null +++ b/server/src/validation/ontimeValidator.js @@ -0,0 +1,13 @@ +import { check, validationResult } from 'express-validator'; + +/** + * @description Validates object for POST /ontime/views + */ +export const viewValidator = [ + check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; diff --git a/server/src/yarn.lock b/server/src/yarn.lock index c18ab4efb..8a571dc6e 100644 --- a/server/src/yarn.lock +++ b/server/src/yarn.lock @@ -559,6 +559,14 @@ express-session@^1.17.3: safe-buffer "5.2.1" uid-safe "~2.1.5" +express-validator@^6.14.2: + version "6.14.2" + resolved "https://registry.yarnpkg.com/express-validator/-/express-validator-6.14.2.tgz#6147893f7bec0e14162c3a88b3653121afc4678f" + integrity sha512-8XfAUrQ6Y7dIIuy9KcUPCfG/uCbvREctrxf5EeeME+ulanJ4iiW71lWmm9r4YcKKYOCBMan0WpVg7FtHu4Z4Wg== + dependencies: + lodash "^4.17.21" + validator "^13.7.0" + express@^4.18.1: version "4.18.1" resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf" @@ -837,6 +845,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + lowdb@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lowdb/-/lowdb-3.0.0.tgz#c10ab4e7eb86f1cbe255e35e60ffb0c6f42049e0" @@ -1420,6 +1433,11 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== +validator@^13.7.0: + version "13.7.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.7.0.tgz#4f9658ba13ba8f3d82ee881d3516489ea85c0857" + integrity sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw== + vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"