This commit is contained in:
Carlos Valente
2022-09-19 21:15:06 +02:00
committed by GitHub
parent fc0cbf6134
commit 70436059d8
71 changed files with 2001 additions and 1175 deletions
+1
View File
@@ -32,6 +32,7 @@ server/src/preloaded-db/db.json
server/src/models/db.json
TODO.md
ontime-db/
ontime-external/
# vscode stuff
.vscode/*
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "1.7.3",
"version": "1.8.0",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.3.2",
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#121212" />
<meta name="onsite" content="onsite - event timer manager" />
<meta name="ontime" content="ontime - event timer manager" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link
rel="icon"
+8 -8
View File
@@ -11,19 +11,19 @@ const Editor = lazy(() => 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() {
<Route path='/countdown' element={<SCountdown />} />
<Route path='/sm' element={<SStageManager />} />
<Route path='/backstage' element={<SStageManager />} />
<Route path='/sm' element={<SBackstage />} />
<Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
<Route path='/pip' element={<SPip />} />
+5 -1
View File
@@ -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}`;
+23
View File
@@ -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}
@@ -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 (
<div className={classes}>
{display}
</div>
);
return <div className={classes}>{display}</div>;
};
export default memo(TimerDisplay);
@@ -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;
}
@@ -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;
}
}
@@ -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}`;
}
@@ -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' },
@@ -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 (
<div className={style.entries}>
<div className='paginator entries'>
{page.map((e) => {
if (e.id === selectedId) selectedState = 1;
else if (selectedState === 1) selectedState = 2;
@@ -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: '*';
}
}
}
@@ -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 (
<div className={selectStyle} style={{ borderLeft: `4px solid ${userColour}` }}>
<div className={`${style.entryTimes} ${backstageEvent ? style.backstage : ''}`}>
<div className={`entry ${selectStyle}`} style={{ borderLeft: `4px solid ${userColour}` }}>
<div className='entry-times'>
{`${start} · ${end}`}
</div>
<div className={style.entryTitle}>{title}</div>
{backstageEvent && <div className={style.backstageInd} />}
<div className='entry-title'>{title}</div>
{backstageEvent && <div className='backstage-indicator' />}
</div>
);
}
@@ -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 (
<div className='title-side'>
<div className='label'>{label}</div>
<div className={isNext ? 'title next' : 'title now'}>{title}</div>
<div className={isNext ? 'presenter next' : 'presenter now'}>{presenter}</div>
<div className={isNext ? 'subtitle next' : 'subtitle now'}>{subtitle}</div>
</div>
);
}
TitleSide.propTypes = {
type: PropTypes.oneOf(['now', 'next']),
label: PropTypes.string,
title: PropTypes.string,
subtitle: PropTypes.string,
presenter: PropTypes.string,
}
@@ -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';
}
}
@@ -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: '*';
}
@@ -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 (
<>
<div className={style.label}>{label}</div>
<div className={titleStyle}>{title}</div>
<div className={presStyle}>{presenter}</div>
<div className={subStyle}>{subtitle}</div>
</>
);
}
TitleSide.propTypes = {
type: PropTypes.oneOf(['now', 'next']),
label: PropTypes.string,
title: PropTypes.string,
subtitle: PropTypes.string,
presenter: PropTypes.string,
}
@@ -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';
}
+1 -1
View File
@@ -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"] });
@@ -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 };
};
@@ -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) {
<Tabs size='sm' isLazy>
<TabList>
<Tab style={{ fontSize: '0.9em' }}>App Settings</Tab>
<Tab style={{ fontSize: '0.9em' }}>Viewers</Tab>
<Tab style={{ fontSize: '0.9em' }}>Event Data</Tab>
<Tab style={{ fontSize: '0.9em' }}>URL Aliases</Tab>
<Tab style={{ fontSize: '0.9em' }}>Cuesheet</Tab>
@@ -44,6 +46,9 @@ export default function ModalManager(props) {
<TabPanel>
<AppSettingsModal />
</TabPanel>
<TabPanel>
<ViewsSettingsModal />
</TabPanel>
<TabPanel>
<EventSettingsModal />
</TabPanel>
@@ -96,7 +96,8 @@
}
.submitContainer {
margin-top: 2em;
margin-top: auto;
padding-top: 2em;
display: flex;
justify-content: flex-end;
gap: 1em;
@@ -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 (
<ModalBody className={style.modalBody}>
<p className={style.notes}>
Options related to the viewers
<br />
🔥 Changes take effect immediately 🔥
</p>
<form onSubmit={submitHandler}>
<div className={style.hSeparator}>Style Options</div>
<div className={style.blockNotes}>
<span className={style.inlineFlex}>
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
CSS Style Overrides
</span>
This feature allows user defined CSS to override the application stylesheets as a way to
customise viewers appearance.
<br />
Read more about it in the documentation{' '}
<a
href='#!'
onClick={() => openLink('https://cpvalente.gitbook.io/ontime/features/custom-styling')}
className={style.if}
>
over at Gitbook
</a>
</div>
<div className={style.modalFields}>
<div className={style.modalInline}>
<FormControl>
<FormLabel htmlFor='overrideStyles'>
Override CSS Styles
<span className={style.labelNote}>
<br />
Enable / Disable override
</span>
</FormLabel>
<EnableBtn
active={formData.overrideStyles}
text={
formData.overrideStyles ? 'Style Override Enabled' : 'Style Override Disabled'
}
actionHandler={() => handleChange('overrideStyles', !formData.overrideStyles)}
/>
</FormControl>
</div>
<SubmitContainer
revert={revert}
submitting={submitting}
changed={changed}
status={status}
/>
</div>
</form>
</ModalBody>
);
}
+14 -6
View File
@@ -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 (
<Component
{...props}
@@ -241,6 +248,7 @@ const withSocket = (Component) => {
backstageEvents={backstageEvents}
selectedId={selectedId}
publicSelectedId={publicSelectedId}
viewSettings={viewSettings}
nextId={nextId}
general={general}
onAir={onAir}
@@ -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 (
<div className={style.container__gray}>
<div className='backstage'>
<NavLogo />
<div className={style.eventTitle}>{general.title}</div>
<div className='event-title'>{general.title}</div>
<AnimatePresence>
{title.showNow && (
<motion.div
className={style.nowContainer}
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
@@ -79,7 +87,7 @@ export default function StageManager(props) {
<AnimatePresence>
{title.showNext && (
<motion.div
className={style.nextContainer}
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
@@ -97,15 +105,15 @@ export default function StageManager(props) {
)}
</AnimatePresence>
<div className={style.todayContainer}>
<div className={style.todayHeaderBlock}>
<div className={style.label}>Today</div>
<div className={style.nav}>
<div className='today-container'>
<div className='today-header-block'>
<div className='label'>Today</div>
<div className='nav'>
{pageNumber > 1 &&
[...Array(pageNumber).keys()].map((i) => (
<div
key={i}
className={i === currentPage ? style.navItemSelected : style.navItem}
className={i === currentPage ? 'nav-item nav-item--selected' : 'nav-item'}
/>
))}
</div>
@@ -119,27 +127,25 @@ export default function StageManager(props) {
/>
</div>
<div className={showPubl ? style.publicContainer : style.publicContainerHidden}>
<div className={style.label}>Public message</div>
<div className={style.message}>{publ.text}</div>
<div className={showPubl ? 'public-container' : 'public-container public-container--hidden'}>
<div className='label'>Public message</div>
<div className='message'>{publ.text}</div>
</div>
<div className={style.clockContainer}>
<div className={style.label}>Time Now</div>
<div className={style.clock}>{clock}</div>
<div className='clock-container'>
<div className='label'>Time Now</div>
<div className='clock'>{clock}</div>
</div>
<div className={style.countdownContainer}>
<div className={style.label}>Stage Timer</div>
<div className={style.clock}>{stageTimer}</div>
<div className='timer-container'>
<div className='label'>Stage Timer</div>
<div className='timer'>{stageTimer}</div>
</div>
<div className={style.infoContainer}>
<div className={style.label}>Info</div>
<div className={style.infoMessages}>
<div className={style.info}>{general.backstageInfo}</div>
</div>
<div className={style.qr}>
<div className='info'>
<div className='label'>Info</div>
<div className='info__message'>{general.backstageInfo}</div>
<div className='qr'>
{general.url != null && general.url !== '' && (
<QRCode value={general.url} size={window.innerWidth / 12} level='L' />
)}
@@ -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,
};
@@ -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;
}
}
}
@@ -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;
}
@@ -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 (
<div className={style.container}>
<div className='countdown'>
<NavLogo />
{follow === null ? (
<div className={style.eventSelect}>
<span className={style.actionTitle}>Select an event to follow</span>
<ul className={style.events}>
<div className='event-select'>
<span className='event-select__title'>Select an event to follow</span>
<ul className='event-select__events'>
{backstageEvents.length === 0 ? (
<Empty dark text='No events in database' />
) : (
@@ -111,27 +112,29 @@ export default function Countdown(props) {
</ul>
</div>
) : (
<div className={style.countdownContainer}>
<div className={style.timers}>
<div className={style.timer}>
<div className={style.label}>Time Now</div>
<span className={style.value}>{clock}</span>
<div className='countdown-container'>
<div className='timer-group'>
<div className='aux-timers'>
<div className='aux-timers__label'>Time Now</div>
<span className='aux-timers__value'>{clock}</span>
</div>
<div className={style.timer}>
<div className={style.label}>Start Time</div>
<span className={`${style.value} ${delay > 0 ? style.delayed : ''}`}>
<div className='aux-timers'>
<div className='aux-timers__label'>Start Time</div>
<span className={`aux-timers__value ${delayedTimerStyles}`}>
{startTime}
</span>
</div>
<div className={style.timer}>
<div className={style.label}>End Time</div>
<span className={`${style.value} ${delay > 0 ? style.delayed : ''}`}>{endTime}</span>
<div className='aux-timers'>
<div className='aux-timers__label'>End Time</div>
<span className={`aux-timers__value ${delayedTimerStyles}`}>
{endTime}
</span>
</div>
</div>
<div className={style.status}>{runningMessage}</div>
<div className='status'>{runningMessage}</div>
<span
className={`${style.countdownClock} ${standby ? style.standby : ''} ${
isRunningFinished ? style.finished : ''
className={`timer ${standby ? 'timer--paused' : ''} ${
isRunningFinished ? 'timer--finished' : ''
}`}
>
{formatDisplay(
@@ -139,7 +142,7 @@ export default function Countdown(props) {
isSelected || time.waiting
)}
</span>
<div className={style.title}>{follow?.title || 'Untitled Event'}</div>
<div className='title'>{follow?.title || 'Untitled Event'}</div>
</div>
)}
</div>
@@ -150,5 +153,5 @@ Countdown.propTypes = {
backstageEvents: PropTypes.array,
time: PropTypes.object,
selectedId: PropTypes.string,
settings: PropTypes.object,
viewSettings: PropTypes.object,
};
@@ -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;
}
}
}
@@ -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;
}
@@ -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 (
<div
className={style.lowerThird}
className='lower-third clean'
style={{
backgroundColor: options.keyColour || defaults.keyColour,
color: options.textColour || defaults.textColour,
@@ -92,7 +92,7 @@ export default function LowerClean(props) {
<AnimatePresence>
{showLower && (
<motion.div
className={style.lowerContainer}
className='lower-container'
style={{
backgroundColor: options.bgColour || defaults.bgColour,
top: options.posY || defaults.posY,
@@ -103,10 +103,10 @@ export default function LowerClean(props) {
animate='visible'
exit='exit'
>
<motion.div className={style.title} variants={titleVariants}>
<motion.div className='title' variants={titleVariants}>
{title.titleNow}
</motion.div>
<motion.div className={style.subtitle} variants={titleVariants}>
<motion.div className='subtitle' variants={titleVariants}>
{title.presenterNow}
</motion.div>
</motion.div>
@@ -116,7 +116,7 @@ export default function LowerClean(props) {
<AnimatePresence>
{showLowerMessage && (
<motion.div
className={style.messageContainer}
className='message-container'
style={{
backgroundColor: options.bgColour || defaults.bgColour,
}}
@@ -126,7 +126,7 @@ export default function LowerClean(props) {
exit={{ scaleY: 0, opacity: 0 }}
transition={{ duration: 0.5 }}
>
<div className={style.message}>{lower.text}</div>
<div className='message'>{lower.text}</div>
</motion.div>
)}
</AnimatePresence>
@@ -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;
}
}
}
@@ -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 (
<div
className={style.lowerThird}
className='lower-third lines'
style={{
backgroundColor: options.keyColour || defaults.keyColour,
color: options.textColour || defaults.textColour,
@@ -135,7 +135,7 @@ export default function LowerLines(props) {
<AnimatePresence>
{showLower && (
<motion.div
className={style.lowerContainer}
className='lower-container'
style={{ backgroundColor: options.bgColour || defaults.bgColour }}
variants={lowerThirdVariants}
initial='hidden'
@@ -143,21 +143,21 @@ export default function LowerLines(props) {
exit='exit'
>
<motion.div
className={style.titleContainer}
className='title-container'
variants={titleContainerVariants}
>
<motion.div className={style.title} variants={titleVariants}>
<motion.div className='title' variants={titleVariants}>
{title.titleNow}
</motion.div>
<div className={style.titleDecor} />
<div className='title-decor' />
</motion.div>
<motion.div
className={style.subtitleContainer}
className='subtitle-container'
variants={subtitleContainerVariants}
>
<div className={style.subDecor} />
<div className='sub-decor' />
<motion.div
className={style.subtitle}
className='subtitle'
variants={subtitleVariants}
>
{title.presenterNow}
@@ -170,14 +170,14 @@ export default function LowerLines(props) {
<AnimatePresence>
{showLowerMessage && (
<motion.div
className={style.messageContainer}
className='message-container'
key='modal'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ scaleY: 0, opacity: 0 }}
transition={{ duration: 0.5 }}
>
<div className={style.message}>{lower.text}</div>
<div className='message'>{lower.text}</div>
</motion.div>
)}
</AnimatePresence>
@@ -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;
}
}
@@ -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 <LowerClean lower={lower} title={titles} options={options} />;
case 1:
return <LowerLines lower={lower} title={titles} options={options} />;
default:
return <LowerLines lower={lower} title={titles} options={options} />;
}
};
export default memo(Lower, areEqual);
Lower.propTypes = {
title: PropTypes.object,
lower: PropTypes.object,
viewSettings: PropTypes.object,
};
@@ -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 (
<div
className={time.finished ? finishedStyle : style.container}
className={showFinished ? 'minimal-timer minimal-timer--finished' : 'minimal-timer'}
style={{
backgroundColor: userOptions.keyColour,
color: userOptions.textColour,
@@ -116,13 +127,17 @@ export default function MinimalTimer(props) {
data-testid='minimal-timer'
>
{!hideMessagesOverlay && (
<div className={showOverlay ? style.messageOverlayActive : style.messageOverlay}>
<div className={style.message}>{pres.text}</div>
<div
className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}
>
<div className='message'>{pres.text}</div>
</div>
)}
{!userOptions?.hideNav && <NavLogo />}
<div
className={isPlaying ? style.timer : style.timerPaused}
className={`timer ${!isPlaying ? 'timer--paused' : ''} ${
showFinished ? 'timer--finished' : ''
}`}
style={{
fontSize: `${(89 / (clean.length - 1)) * userOptions.size}vw`,
fontFamily: userOptions.font,
@@ -139,4 +154,5 @@ export default function MinimalTimer(props) {
MinimalTimer.propTypes = {
pres: PropTypes.object,
time: PropTypes.object,
viewSettings: PropTypes.object,
};
@@ -0,0 +1,74 @@
@use '../../../theme/viewerDefs' as *;
.minimal-timer {
background: var(--background-color-override, $viewer-background-color);
height: 100vh;
color: var(--color-override, $viewer-color);
display: flex;
align-items: center;
justify-content: center;
gap: 1vw;
border: 1vw solid transparent;
&--finished {
border: 1vw solid $timer-finished-color;
transition: 0.3s;
}
.timer {
font-family: "Arial Black", sans-serif;
font-size: 20vw;
position: relative;
color: var(--timer-color-override, $viewer-color);
opacity: 1;
transition: 0.5s;
transition-property: opacity;
&--paused {
opacity: 0.6;
transition: 0.5s;
}
&--finished {
color: $timer-finished-color;
}
}
/* =================== OVERLAY ===================*/
.message-overlay {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $viewer-overlay-bg-color;
z-index: -1;
opacity: 0;
transition: 0.5s;
&--active {
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;
}
}
@@ -1,15 +1,17 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import QRCode from 'react-qr-code';
import { ReactComponent as Emptyimage } from 'assets/images/empty.svg';
import NavLogo from 'common/components/nav/NavLogo';
import Paginator from 'common/components/views/Paginator';
import Paginator from 'common/components/paginator/Paginator';
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 { formatTime } from '../../../common/utils/time';
import style from './Pip.module.scss';
import './Pip.scss';
const formatOptions = {
showSeconds: true,
@@ -17,20 +19,13 @@ const formatOptions = {
};
export default function Pip(props) {
const { time, backstageEvents, selectedId, general } = props;
const [size, setSize] = useState('');
const pipAreaRef = useRef(null);
const { time, backstageEvents, selectedId, general, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const ref = useRef(null);
const [filteredEvents, setFilteredEvents] = useState(null);
const [pageNumber, setPageNumber] = useState(0);
const [currentPage, setCurrentPage] = useState(0);
// calculate pip size
useLayoutEffect(() => {
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 (
<div className={style.container__gray}>
<div className='pip'>
<NavLogo />
<div className={style.eventTitle}>{general.title}</div>
<div className='event-title'>{general.title}</div>
<div className={style.todayContainer}>
<div className={style.todayHeaderBlock}>
<div className={style.label}>Today</div>
<div className={style.nav}>
<div className='today-container'>
<div className='today-header-block'>
<div className='label'>Today</div>
<div className='nav'>
{pageNumber > 1 &&
[...Array(pageNumber).keys()].map((i) => (
<div
key={i}
className={i === currentPage ? style.navItemSelected : style.navItem}
className={i === currentPage ? 'nav-item nav-item--selected' : 'nav-item'}
/>
))}
</div>
@@ -94,19 +94,16 @@ export default function Pip(props) {
/>
</div>
<div className={style.pip} ref={pipAreaRef}>
<Emptyimage className={style.empty} />
<span className={style.piptext}>{size}</span>
<div className='pip-placeholder' ref={ref}>
<Emptyimage className='pip-placeholder__empty' />
</div>
<AnimatePresence>
{showInfo && (
<motion.div className={style.infoContainer}>
<div className={style.label}>Info</div>
<div className={style.infoMessages}>
<div className={style.info}>{general.backstageInfo}</div>
</div>
<div className={style.qr}>
<motion.div className='info-container'>
<div className='label'>Info</div>
<div className='info-message'>{general.backstageInfo}</div>
<div className='qr'>
{general.url != null && general.url !== '' && (
<QRCode value={general.url} size={window.innerWidth / 12} level='L' />
)}
@@ -115,14 +112,14 @@ export default function Pip(props) {
)}
</AnimatePresence>
<div className={style.clockContainer}>
<div className={style.label}>Time Now</div>
<div className={style.clock}>{clock}</div>
<div className='clock-container'>
<div className='label'>Time Now</div>
<div className='clock'>{clock}</div>
</div>
<div className={style.countdownContainer}>
<div className={style.label}>Stage Timer</div>
<div className={style.clock}>{stageTimer}</div>
<div className='timer-container'>
<div className='label'>Stage Timer</div>
<div className='timer'>{stageTimer}</div>
</div>
</div>
);
@@ -133,4 +130,5 @@ Pip.propTypes = {
backstageEvents: PropTypes.object,
selectedId: PropTypes.string,
general: PropTypes.object,
viewSettings: PropTypes.object,
};
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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 (
<LowerClean lower={lower} title={titles} options={lowerOptions} />
);
case 1:
return (
<LowerLines lower={lower} title={titles} options={lowerOptions} />
);
default:
return (
<LowerLines lower={lower} title={titles} options={lowerOptions} />
);
}
};
export default memo(Lower, areEqual);
@@ -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 (
<div className={style.container__gray}>
<div className='public-screen'>
<NavLogo />
<div className={style.eventTitle}>{general.title}</div>
<div className='event-title'>{general.title}</div>
<AnimatePresence>
{publicTitle.showNow && (
<motion.div
className={style.nowContainer}
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
@@ -61,7 +69,7 @@ export default function Public(props) {
<AnimatePresence>
{publicTitle.showNext && (
<motion.div
className={style.nextContainer}
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
@@ -79,15 +87,15 @@ export default function Public(props) {
)}
</AnimatePresence>
<div className={style.todayContainer}>
<div className={style.todayHeaderBlock}>
<div className={style.label}>Today</div>
<div className={style.nav}>
<div className='today-container'>
<div className='today-header-block'>
<div className='label'>Today</div>
<div className='nav'>
{pageNumber > 1 &&
[...Array(pageNumber).keys()].map((i) => (
<div
key={i}
className={i === currentPage ? style.navItemSelected : style.navItem}
className={i === currentPage ? 'nav-item nav-item--selected' : 'nav-item'}
/>
))}
</div>
@@ -101,22 +109,20 @@ export default function Public(props) {
/>
</div>
<div className={showPubl ? style.publicContainer : style.publicContainerHidden}>
<div className={style.label}>Public message</div>
<div className={style.message}>{publ.text}</div>
<div className={showPubl ? 'public-container' : 'public-container public-container--hidden'}>
<div className='label'>Public message</div>
<div className='message'>{publ.text}</div>
</div>
<div className={style.clockContainer}>
<div className={style.label}>Time Now</div>
<div className={style.clock}>{clock}</div>
<div className='clock-container'>
<div className='label'>Time Now</div>
<div className='clock'>{clock}</div>
</div>
<div className={style.infoContainer}>
<div className={style.label}>Info</div>
<div className={style.infoMessages}>
<div className={style.info}>{general.publicInfo}</div>
</div>
<div className={style.qr}>
<div className='info'>
<div className='label'>Info</div>
<div className='info__message'>{general.publicInfo}</div>
<div className='qr'>
{general.url != null && general.url !== '' && (
<QRCode value={general.url} size={window.innerWidth / 12} level='L' />
)}
@@ -133,4 +139,5 @@ Public.propTypes = {
events: PropTypes.object,
publicSelectedId: PropTypes.string,
general: PropTypes.object,
viewSettings: PropTypes.object,
};
@@ -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;
}
}
}
@@ -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 (
<div className={style.container}>
<div className='studio-clock'>
<NavLogo />
<div className={style.clockContainer}>
<div className={style.time}>{clock}</div>
<div className='clock-container'>
<div className='studio-timer'>{clock}</div>
<div
ref={titleRef}
className={style.nextTitle}
className='next-title'
style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '82%' }}
>
{title.titleNext}
</div>
<div className={time.isNegative ? style.nextCountdown : style.nextCountdown__overtime}>
<div className={time.isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
{selectedId != null && formatDisplay(time.running)}
</div>
<div className={style.indicators}>
<div className='clock-indicators'>
{activeIndicators.map((i) => (
<div
key={i}
className={style.hours__active}
className='hours hours--active'
style={{
transform: `rotate(${(360 / 12) * i - 90}deg) translateX(40vh)`,
}}
@@ -78,7 +81,7 @@ export default function StudioClock(props) {
{secondsIndicators.map((i) => (
<div
key={i}
className={i <= secondsNow ? style.min__active : style.min}
className={i <= secondsNow ? 'min min--active' : 'min'}
style={{
transform: `rotate(${(360 / 60) * i - 90}deg) translateX(43vh)`,
}}
@@ -86,14 +89,14 @@ export default function StudioClock(props) {
))}
</div>
</div>
<div className={style.scheduleContainer}>
<div className={onAir ? style.onAir : style.onAir__idle}>ON AIR</div>
<div className={style.schedule}>
<div className='schedule-container'>
<div className={onAir ? 'onAir' : 'onAir onAir--idle'}>ON AIR</div>
<div className='schedule'>
<ul>
{schedule.map((s) => (
<li
key={s.id}
className={s.isNow ? style.now : s.isNext ? style.next : ''}
className={s.isNow ? 'now' : s.isNext ? 'next' : ''}
style={{ borderLeft: `4px solid ${s.colour !== '' ? s.colour : 'transparent'}` }}
>
{`${s.time} ${s.title}`}
@@ -113,4 +116,5 @@ StudioClock.propTypes = {
selectedId: PropTypes.string,
nextId: PropTypes.string,
onAir: PropTypes.bool,
viewSettings: PropTypes.object,
};
@@ -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;
}
}
}
@@ -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;
}
+36 -27
View File
@@ -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 (
<div className={time.finished ? style.container__grayFinished : style.container__gray}>
<div className={showOverlay ? style.messageOverlayActive : style.messageOverlay}>
<div className={style.message}>{pres.text}</div>
<div className={time.finished ? 'stage-timer stage-timer--finished' : 'stage-timer'}>
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<div className='message'>{pres.text}</div>
</div>
<NavLogo />
<div className={style.clockContainer}>
<div className={style.label}>Time Now</div>
<div className={style.clock}>{clock}</div>
<div className='clock-container'>
<div className='label'>Time Now</div>
<div className='clock'>{clock}</div>
</div>
<div className={style.timerContainer}>
<div className='timer-container'>
{time.finished ? (
<div className={style.finished}>
<div className='end-message'>
{general.endMessage == null || general.endMessage === '' ? (
<TimerDisplay time={time.running} isNegative={time.isNegative} hideZeroHours />
) : (
@@ -84,14 +88,18 @@ export default function Timer(props) {
)}
</div>
) : (
<div className={isPlaying ? style.countdown : style.countdownPaused}>
<div className={isPlaying ? 'timer' : 'timer--paused'}>
<TimerDisplay time={normalisedTime} hideZeroHours />
</div>
)}
</div>
{!time.finished && (
<div className={isPlaying ? style.progressContainer : style.progressContainerPaused}>
<div
className={
isPlaying ? 'progress-container' : 'progress-container progress-container--paused'
}
>
<MyProgressBar
now={normalisedTime}
complete={time.durationSeconds}
@@ -103,7 +111,7 @@ export default function Timer(props) {
<AnimatePresence>
{title.showNow && (
<motion.div
className={style.nowContainer}
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
@@ -123,7 +131,7 @@ export default function Timer(props) {
<AnimatePresence>
{title.showNext && (
<motion.div
className={style.nextContainer}
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
@@ -148,4 +156,5 @@ Timer.propTypes = {
pres: PropTypes.object,
title: PropTypes.object,
time: PropTypes.object,
viewSettings: PropTypes.object,
};
@@ -1,141 +0,0 @@
@use '../../../theme/main' as *;
.container__gray,
.container__grayFinished {
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;
display: grid;
grid-template-columns: 1fr 1fr 5vw 1fr 1fr;
grid-template-rows: auto 1fr auto minmax(25vh, auto);
grid-template-areas:
' clck clck .... .... ....'
' timr timr timr timr timr'
' prog prog prog prog prog'
' now now .... next next';
gap: 1vw;
padding: 1vw;
}
.label {
font-size: 1.3vw;
color: $ontime-pink;
}
/* =================== TITLES ===================*/
.nowContainer,
.nextContainer {
background-color: $bg-gray-1000;
padding: 1vh 2vw;
border-radius: 1vw;
max-width: 100%;
}
.nowContainer {
grid-area: now;
}
.nextContainer {
grid-area: next;
}
/* =================== MAIN ===================*/
.timerContainer,
.timerContainerFinished {
grid-area: timr;
justify-self: center;
align-self: center;
color: white;
}
.finished {
text-align: center;
font-size: 12vw;
line-height: 18vw;
font-weight: 600;
color: $ontime-pink-variant;
padding: 0;
}
.countdown {
opacity: 1;
}
.progressContainer,
.progressContainerPaused {
grid-area: prog;
width: 80%;
margin: 0 auto;
opacity: 1;
transition: 0.5s;
}
.countdownPaused,
.progressContainerPaused {
opacity: 0.6;
transition: 0.5s;
}
.container__grayFinished {
border: 1vw solid $ontime-pink-variant;
}
/* =================== 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;
}
/* =================== MAIN ===================*/
.clockContainer {
grid-area: clck;
padding: 1vh 2vw;
}
.clock {
font-family: 'Open Sans', sans-serif;
font-size: 4vw;
letter-spacing: 0.4vw;
color: #ddd;
}
@@ -0,0 +1,138 @@
@use '../../../theme/viewerDefs' as *;
.stage-timer {
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);
display: grid;
grid-template-columns: 1fr 1fr 5vw 1fr 1fr;
grid-template-rows: auto 1fr auto minmax(25vh, auto);
grid-template-areas:
' clck clck .... .... ....'
' timr timr timr timr timr'
' prog prog prog prog prog'
' now now .... next next';
gap: 1vw;
padding: 1vw;
&--finished {
border: 1vw solid $timer-finished-color;
}
/* =================== TITLES ===================*/
.event {
background-color: var(--outdent-background-color-override, $viewer-outdent-bg-color);
padding: 1vh 2vw;
border-radius: 1vw;
max-width: 100%;
&.now {
grid-area: now;
}
&.next {
grid-area: next;
}
}
/* =================== MAIN ===================*/
.timer-container {
grid-area: timr;
justify-self: center;
align-self: center;
color: white;
.end-message {
text-align: center;
font-size: 12vw;
line-height: 18vw;
font-weight: 600;
color: $timer-finished-color;
padding: 0;
}
.timer {
opacity: 1;
}
}
.progress-container {
grid-area: prog;
width: 80%;
margin: 0 auto;
opacity: 1;
transition: 0.5s;
}
.timer--paused,
.progress-container--paused {
opacity: 0.6;
transition: 0.5s;
}
/* =================== OVERLAY ===================*/
.message-overlay {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $viewer-overlay-bg-color;
z-index: -1;
opacity: 0;
transition: 0.5s;
&--active {
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;
}
/* =================== MAIN ===================*/
.clock-container {
grid-area: clck;
padding: 1vh 2vw;
.label {
font-size: 1.3vw;
color: var(--accent-color-override, $accent-color);
}
.clock {
font-family: 'Open Sans', sans-serif;
font-size: 4vw;
letter-spacing: 0.4vw;
}
}
}
+1
View File
@@ -64,6 +64,7 @@ $subtitle-gray: #aaa;
//////////////////////////////////// block elements
$block-delay-color: #ecc94b;
$delay-text: #d69e2e;
$block-delay-border: #d69e2e55;
$block-block-color: #805ad5;
$block-icon-drag: $bg-gray-100;
+28
View File
@@ -0,0 +1,28 @@
@use 'main' as *;
// General styling
$accent-color: $ontime-pink; // --accent-color-override
$delay-color: $delay-text;
// Main Properties of a viewer
$viewer-background-color: $bg-black; // --background-color-override
$viewer-color: $title-white; // --color-override
$viewer-outdent-bg-color: $bg-gray-1100; // --outdent-background-color-override
$viewer-overlay-bg-color: $bg-overlay;
// Properties related to timer
$timer-finished-color: $ontime-pink-variant;
// View specific - Pip
$pip-bg-color: $bg-black-100;
$pip-border-color: $bg-black-200;
$pip-text-color: $bg-gray-900;
$today-item-bg: $bg-gray-700;
// View specific - Titles
$title-color: #ddd;
$subtitle-color: #aaa;
// View specific - Paginator
$now-bg-color: #ffffff11;
$future-bg-color: #ffffff05;
+30 -31
View File
@@ -32,58 +32,57 @@
color: $ontime-pink;
}
.infoContainer > 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;
}
+189
View File
@@ -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;
}
}
+1
View File
@@ -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
+1 -1
View File
@@ -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",
+3 -3
View File
@@ -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) => {
@@ -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) => {
+11
View File
@@ -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;
}
+3
View File
@@ -15,6 +15,9 @@ export const dbModelv1 = {
pinCode: null,
timeFormat: '24',
},
views: {
overrideStyles: false,
},
aliases: [],
userFields: {
user0: 'user0',
+2 -1
View File
@@ -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",
+21 -12
View File
@@ -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);
+30 -1
View File
@@ -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 = [
+3
View File
@@ -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
+27
View File
@@ -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
+13
View File
@@ -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();
},
];
+18
View File
@@ -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"