Compare commits

..

32 Commits

Author SHA1 Message Date
Carlos Valente 9813643c96 feat: parse timeToEnd from excel 2024-12-16 13:38:49 +01:00
Carlos Valente b88c2a6bd8 refactor: extract time-to-end 2024-12-16 13:38:46 +01:00
Carlos Valente ed65a4db67 refactor: smaller settings 2024-12-15 11:04:15 +01:00
Carlos Valente 4a70bcfe64 chore: remove unused 2024-12-15 11:04:15 +01:00
Carlos Valente 89e68c3b0e fix: correct aria label 2024-12-15 11:04:15 +01:00
Carlos Valente 976dc9cbde refactor: tweak header style 2024-12-15 11:04:15 +01:00
Carlos Valente 3701ba5174 refactor: handle missing data 2024-12-15 11:04:15 +01:00
Carlos Valente acc6ba63ba refactor: keep drawer open on submit 2024-12-15 11:04:15 +01:00
Carlos Valente 6c5afcb8d9 refactor: improve empty state for pages 2024-12-15 09:55:52 +01:00
Carlos Valente d46dfcf82e refactor: improve redirect 2024-12-14 08:25:53 +01:00
Carlos Valente 4c6acc4013 refactor: improve active styles for navigation buttons 2024-12-13 11:24:03 +01:00
Carlos Valente 26a24449de refactor: prevent modal close on click outside 2024-12-13 10:40:16 +01:00
Carlos Valente a1fb642441 bump version to 3.9.5 2024-12-13 10:40:16 +01:00
Carlos Valente b64b154330 refactor: disable shutdown 2024-12-13 10:39:58 +01:00
Carlos Valente 5032cbf65a refactor: prevent instantiating unavailable services 2024-12-13 10:39:58 +01:00
Carlos Valente 466360f9d1 chore: note service limitations 2024-12-13 10:39:58 +01:00
Carlos Valente ef2ea9a1da fix: recover edit menu and shortcuts 2024-12-13 10:39:45 +01:00
Carlos Valente fb83f48752 refactor: maintain an isOnline flag in client 2024-12-13 10:39:30 +01:00
Carlos Valente c1d53b0e55 refactor: improve empty state for project info 2024-12-13 10:12:06 +01:00
Alex Christoffer Rasmussen 7915cc822d Dockersafe rename (#1370)
* add dockerSafeRename function

* replace all rename functions

* add explanation to function
2024-12-10 23:27:51 +01:00
Carlos Valente 7dbf64d100 chore: restructure directory 2024-12-10 15:53:53 +01:00
Carlos Valente 6ffbf2af9d chore: move files to new structure 2024-12-10 15:53:53 +01:00
Carlos Valente fa7ec623f1 bump version to 3.9.4 2024-12-08 08:52:18 +01:00
Carlos Valente bfb9b51073 fix: secure protocol resolution 2024-12-08 08:52:18 +01:00
Carlos Valente 8bc3f5a56c Fix links (#1366)
* chore: remove unused code

* bump version to 3.9.3

* chore: use constant links

* refactor: build links

* chore: ignore ontime-data
2024-12-06 16:48:35 +01:00
Carlos Valente acf1afb5e9 fix: prevent overflow 2024-12-06 14:54:41 +01:00
Carlos Valente d8e2a8d092 bump version to 3.9.2 2024-12-04 21:41:49 +01:00
Carlos Valente 8e587128a1 refactor: ensure element height 2024-12-04 21:41:49 +01:00
Alex Christoffer Rasmussen 607bc40673 create addTime function in simple timer (#1363)
* create addTime function in simple timer
2024-12-04 20:25:49 +01:00
Carlos Valente e6aa7404f7 chore: correct documentation 2024-12-04 19:49:49 +01:00
Carlos Valente d622a7738f refactor: remove deprecated field 2024-12-04 19:49:49 +01:00
Carlos Valente b9ba416366 refactor: add prefix to relative assets
refactor: add prefix to server entrypoints
refactor: add prefix to express router
refactor: add prefix to router basename
2024-12-04 19:49:49 +01:00
113 changed files with 1068 additions and 898 deletions
+1
View File
@@ -39,6 +39,7 @@ dist/
# docker utils # docker utils
ontime-db ontime-db
ontime-external/ ontime-external/
ontime-data/
# versioning file # versioning file
**/ONTIME_VERSION.js **/ONTIME_VERSION.js
+1 -1
View File
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands From the project root, run the following commands
- __Build docker image from__ by running `docker build -t getontime/ontime` - __Build docker image from__ by running `docker build -t getontime/ontime .`
- __Run docker image from compose__ by running `docker-compose up -d` - __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands Other useful commands
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.9.1", "version": "3.9.5",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+25 -29
View File
@@ -1,31 +1,27 @@
<!doctype html> <!doctype html>
<html lang='en'> <html lang="en">
<head> <head>
<meta charset='utf-8' /> <meta charset="utf-8" />
<base href="/"> <base href="/" />
<link rel='icon' href='/favicon.ico' /> <link rel="icon" href="/favicon.ico" />
<meta name='viewport' content='width=device-width, initial-scale=1' /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name='theme-color' content='#101010' /> <meta name="theme-color" content="#101010" />
<meta name='ontime' content='ontime - time keeping for live events' /> <meta name="ontime" content="ontime - time keeping for live events" />
<link rel='apple-touch-icon' href='/ontime-logo.png' /> <link rel="apple-touch-icon" href="/ontime-logo.png" />
<link <link rel="icon" type="image/png" href="/ontime-logo.png" />
rel='icon' <link rel="manifest" href="/site.webmanifest" />
type='image/png' <link rel="manifest" href="/manifest.json" />
href='ontime-logo.png' <title>ontime</title>
/> <style>
<link rel='manifest' href='/site.webmanifest' /> body,
<link rel='manifest' href='/manifest.json' /> html {
<title>ontime</title> background-color: rgba(0, 0, 0, 0) !important;
<style> }
body, </style>
html { </head>
background-color: rgba(0, 0, 0, 0) !important; <body>
} <noscript>You need to enable JavaScript to run this app.</noscript>
</style> <div id="root"></div>
</head> <script type="module" src="/src/index.tsx"></script>
<body> </body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id='root'></div>
<script type='module' src='/src/index.tsx'></script>
</body>
</html> </html>
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.9.1", "version": "3.9.5",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -39,7 +39,7 @@
"build": "vite build", "build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build", "build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build", "build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "cross-env VITE_IS_CLOUD=true vite build", "build:docker": "cross-env VITE_IS_DOCKER=true vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build", "build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"test": "vitest", "test": "vitest",
+2 -1
View File
@@ -11,6 +11,7 @@ import { connectSocket } from './common/utils/socket';
import theme from './theme/theme'; import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider'; import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter'; import AppRouter from './AppRouter';
import { baseURI } from './externals';
connectSocket(); connectSocket();
@@ -19,7 +20,7 @@ function App() {
<ChakraProvider disableGlobalStyle resetCSS theme={theme}> <ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}> <QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider> <AppContextProvider>
<BrowserRouter> <BrowserRouter basename={baseURI}>
<div className='App'> <div className='App'>
<ErrorBoundary> <ErrorBoundary>
<TranslationProvider> <TranslationProvider>
+1 -1
View File
@@ -19,7 +19,7 @@ import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config'; import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor')); const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet')); const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport')); const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer')); const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
+1 -1
View File
@@ -1,7 +1,7 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils'; import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -17,7 +17,7 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline'; import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost, serverPort } from '../../../externals'; import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside'; import useClickOutside from '../../hooks/useClickOutside';
import { useElectronEvent } from '../../hooks/useElectronEvent'; import { useElectronEvent } from '../../hooks/useElectronEvent';
@@ -25,7 +25,7 @@ import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent'; import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, openLink } from '../../utils/linkUtils'; import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal'; import { RenameClientModal } from '../client-modal/RenameClientModal';
import CopyTag from '../copy-tag/CopyTag'; import CopyTag from '../copy-tag/CopyTag';
@@ -45,7 +45,7 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure(); const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const location = useLocation(); const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
@@ -65,7 +65,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<DrawerBody padding={0}> <DrawerBody padding={0}>
<div className={style.buttonsContainer}> <div className={style.buttonsContainer}>
<div <div
className={style.link} className={cx([style.link, fullscreen && style.current])}
tabIndex={0} tabIndex={0}
role='button' role='button'
onClick={toggle} onClick={toggle}
@@ -77,7 +77,7 @@ function NavigationMenu(props: NavigationMenuProps) {
{fullscreen ? <IoContract /> : <IoExpand />} {fullscreen ? <IoContract /> : <IoExpand />}
</div> </div>
<div <div
className={style.link} className={cx([style.link, mirror && style.current])}
tabIndex={0} tabIndex={0}
role='button' role='button'
onClick={() => toggleMirror()} onClick={() => toggleMirror()}
@@ -154,7 +154,8 @@ function OtherAddresses(props: OtherAddressesProps) {
return null; return null;
} }
const address = `http://${nif.address}:${serverPort}${currentLocation}`; const address = linkToOtherHost(nif.address, currentLocation);
return ( return (
<CopyTag <CopyTag
key={nif.name} key={nif.name}
@@ -0,0 +1,20 @@
@use '../../../theme/viewerDefs' as *;
/* share the same style as a page layout */
.page {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
}
@@ -0,0 +1,20 @@
import { CSSProperties } from 'react';
import Empty from './Empty';
import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
style?: CSSProperties;
}
export default function EmptyPage(props: EmptyPageProps) {
const { text, ...rest } = props;
return (
<div className={style.page}>
<Empty text={text} {...rest} />
</div>
);
}
@@ -115,8 +115,6 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
onClose();
}; };
return ( return (
+11 -4
View File
@@ -4,16 +4,23 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { useClientStore } from '../stores/clientStore'; import { useClientStore } from '../stores/clientStore';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
import { useIsOnline } from './useSocket';
export const useClientPath = () => { export const useClientPath = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname, search } = useLocation(); const { pathname, search } = useLocation();
const redirect = useClientStore((store) => store.redirect); const { redirect, setRedirect } = useClientStore((store) => ({
const setRedirect = useClientStore((store) => store.setRedirect); redirect: store.redirect,
setRedirect: store.setRedirect,
}));
const isOnline = useIsOnline();
// notify of client path changes // notify of client path changes
useEffect(() => { useEffect(() => {
if (!isOnline) return;
socketSendJson('set-client-path', pathname + search); socketSendJson('set-client-path', pathname + search);
}, [pathname, search]); }, [pathname, search, isOnline]);
// navigate to new path when received from server // navigate to new path when received from server
useEffect(() => { useEffect(() => {
@@ -26,7 +33,7 @@ export const useClientPath = () => {
// navigate if there is a path change // navigate if there is a path change
if (redirect !== pathname + search) { if (redirect !== pathname + search) {
navigate(redirect); navigate(redirect, { replace: true });
} }
}, [navigate, pathname, redirect, search, setRedirect]); }, [navigate, pathname, redirect, search, setRedirect]);
}; };
+10
View File
@@ -65,6 +65,7 @@ export const useMessagePreview = () => {
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external), showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null, timerType: state.eventNow?.timerType ?? null,
isTimeToEnd: state.eventNow?.isTimeToEnd ?? false,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
@@ -238,3 +239,12 @@ export const usePing = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
/** convert ping into a derived value which changes less often */
export const useIsOnline = () => {
const featureSelector = (state: RuntimeStore) => ({
isOnline: state.ping > 0,
});
return useRuntimeStore(featureSelector);
};
@@ -15,4 +15,5 @@ export type ViewExtendedTimer = {
clock: number; clock: number;
timerType: TimerType; timerType: TimerType;
isTimeToEnd: boolean;
}; };
+9 -3
View File
@@ -16,11 +16,17 @@ export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
/** /**
* Allows patching a property of the runtime store * Allows patching a property of the runtime store
* @param key
* @param value
*/ */
export function patchRuntime<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]): void { export function patchRuntimeProperty<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
const state = runtimeStore.getState(); const state = runtimeStore.getState();
state[key] = value; state[key] = value;
runtimeStore.setState({ ...state }); runtimeStore.setState({ ...state });
} }
/**
* Allows patching the entire runtime store
*/
export function patchRuntime(patch: Partial<RuntimeStore>) {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...patch });
}
@@ -17,6 +17,7 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
timeEnd: event.timeEnd, timeEnd: event.timeEnd,
timerType: event.timerType, timerType: event.timerType,
timeStrategy: event.timeStrategy, timeStrategy: event.timeStrategy,
isTimeToEnd: event.isTimeToEnd,
linkStart: event.linkStart, linkStart: event.linkStart,
endAction: event.endAction, endAction: event.endAction,
isPublic: event.isPublic, isPublic: event.isPublic,
-30
View File
@@ -1,30 +0,0 @@
/**
* Returns hostname
* @type {string}
*/
export const host = window?.location?.host;
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event, location) {
// we handle the link manually
event.preventDefault();
openLink(`http://${host}/${location}`);
}
+40
View File
@@ -0,0 +1,40 @@
import type { MouseEvent } from 'react';
import { baseURI, serverURL } from '../../externals';
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url: string) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event: MouseEvent, location: string) {
// we handle the link manually
event.preventDefault();
const destination = new URL(serverURL);
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
openLink(destination.toString());
}
export function linkToOtherHost(host: string, path?: string) {
const destination = new URL(serverURL);
destination.hostname = host;
if (path) {
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
}
return destination.toString();
}
+36 -22
View File
@@ -14,7 +14,7 @@ import {
} from '../stores/clientStore'; } from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore'; import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { patchRuntime, runtimeStore } from '../stores/runtime'; import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
export let websocket: WebSocket | null = null; export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -39,12 +39,13 @@ export const connectSocket = () => {
} }
socketSendJson('set-client-type', 'ontime'); socketSendJson('set-client-type', 'ontime');
setOnlineStatus(true);
socketSendJson('set-client-path', location.pathname + location.search);
}; };
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
setOnlineStatus(false);
if (shouldReconnect) { if (shouldReconnect) {
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
@@ -73,8 +74,8 @@ export const connectSocket = () => {
switch (type) { switch (type) {
case 'pong': { case 'pong': {
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5; const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
patchRuntime('ping', offset); patchRuntimeProperty('ping', offset);
updateDevTools({ ping: offset }, ['PING']); updateDevTools({ ping: offset });
break; break;
} }
case 'client-id': { case 'client-id': {
@@ -131,64 +132,65 @@ export const connectSocket = () => {
break; break;
} }
case 'ontime': { case 'ontime': {
runtimeStore.setState(payload as RuntimeStore); // eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload
if (!isProduction) { const { ping, ...serverPayload } = payload as Partial<RuntimeStore>;
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
} patchRuntime(serverPayload);
updateDevTools(serverPayload);
break; break;
} }
case 'ontime-clock': { case 'ontime-clock': {
patchRuntime('clock', payload); patchRuntimeProperty('clock', payload);
updateDevTools({ clock: payload }); updateDevTools({ clock: payload });
break; break;
} }
case 'ontime-timer': { case 'ontime-timer': {
patchRuntime('timer', payload); patchRuntimeProperty('timer', payload);
updateDevTools({ timer: payload }); updateDevTools({ timer: payload });
break; break;
} }
case 'ontime-onAir': { case 'ontime-onAir': {
patchRuntime('onAir', payload); patchRuntimeProperty('onAir', payload);
updateDevTools({ onAir: payload }); updateDevTools({ onAir: payload });
break; break;
} }
case 'ontime-message': { case 'ontime-message': {
patchRuntime('message', payload); patchRuntimeProperty('message', payload);
updateDevTools({ message: payload }); updateDevTools({ message: payload });
break; break;
} }
case 'ontime-runtime': { case 'ontime-runtime': {
patchRuntime('runtime', payload); patchRuntimeProperty('runtime', payload);
updateDevTools({ runtime: payload }); updateDevTools({ runtime: payload });
break; break;
} }
case 'ontime-eventNow': { case 'ontime-eventNow': {
patchRuntime('eventNow', payload); patchRuntimeProperty('eventNow', payload);
updateDevTools({ eventNow: payload }); updateDevTools({ eventNow: payload });
break; break;
} }
case 'ontime-currentBlock': { case 'ontime-currentBlock': {
patchRuntime('currentBlock', payload); patchRuntimeProperty('currentBlock', payload);
updateDevTools({ currentBlock: payload }); updateDevTools({ currentBlock: payload });
break; break;
} }
case 'ontime-publicEventNow': { case 'ontime-publicEventNow': {
patchRuntime('publicEventNow', payload); patchRuntimeProperty('publicEventNow', payload);
updateDevTools({ publicEventNow: payload }); updateDevTools({ publicEventNow: payload });
break; break;
} }
case 'ontime-eventNext': { case 'ontime-eventNext': {
patchRuntime('eventNext', payload); patchRuntimeProperty('eventNext', payload);
updateDevTools({ eventNext: payload }); updateDevTools({ eventNext: payload });
break; break;
} }
case 'ontime-publicEventNext': { case 'ontime-publicEventNext': {
patchRuntime('publicEventNext', payload); patchRuntimeProperty('publicEventNext', payload);
updateDevTools({ publicEventNext: payload }); updateDevTools({ publicEventNext: payload });
break; break;
} }
case 'ontime-auxtimer1': { case 'ontime-auxtimer1': {
patchRuntime('auxtimer1', payload); patchRuntimeProperty('auxtimer1', payload);
updateDevTools({ auxtimer1: payload }); updateDevTools({ auxtimer1: payload });
break; break;
} }
@@ -232,11 +234,23 @@ export const socketSendJson = (type: string, payload?: unknown) => {
); );
}; };
function updateDevTools(newData: Partial<RuntimeStore>, store = RUNTIME) { function updateDevTools(newData: Partial<RuntimeStore>) {
if (!isProduction) { if (!isProduction) {
ontimeQueryClient.setQueryData(store, (oldData: RuntimeStore) => ({ ontimeQueryClient.setQueryData(RUNTIME, (oldData: RuntimeStore) => ({
...oldData, ...oldData,
...newData, ...newData,
})); }));
} }
} }
/**
* Allows setting the status of the client
* We leverage the ping as an indication of the client's online status
* @example ping < 0 - client is offline
* @example ping > 0 -> client is online
*/
function setOnlineStatus(status: boolean) {
const derivedPing = status ? 1 : -1;
patchRuntimeProperty('ping', derivedPing);
updateDevTools({ ping: derivedPing });
}
+56 -9
View File
@@ -1,8 +1,9 @@
import { version } from '../../../package.json';
/** /**
* This file contains a list of constants that may need to be resolved at runtime * This file contains a list of constants that may need to be resolved at runtime
*/ */
import { version } from '../../../package.json';
export const githubUrl = 'https://www.github.com/cpvalente/ontime'; export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest'; export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no'; export const websiteUrl = 'https://www.getontime.no';
@@ -16,13 +17,59 @@ export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production'; export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction; export const isDev = !isProduction;
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
export const isOntimeCloud = Boolean(import.meta.env.VITE_IS_CLOUD); export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
// resolve protocol // resolve entrypoint URLs
const socketProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
// resolve port /**
const STATIC_PORT = 4001; * Resolve base
export const serverPort = isProduction ? window.location.port : STATIC_PORT; * @example '' for electron
export const serverURL = `${window.location.protocol}//${location.hostname}:${serverPort}`; * @example '/client-hash' for cloud
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`; */
export const baseURI = resolveBaseURI();
export const serverURL = resolveUrl('http', '');
export const websocketUrl = resolveUrl('ws', 'ws');
function resolveUrl(protocol: 'http' | 'ws', path: string) {
const url = new URL(window.location.origin);
// generate ws url
if (protocol === 'ws') {
// ensure we remain in a secure context
const isSecure = window.location.protocol === 'https:';
url.protocol = isSecure ? 'wss' : 'ws';
}
// make path name relative to the base URI
url.pathname = baseURI ? `${baseURI}/${path}` : path;
// in development mode, we use the React port for UI, but need the requests to target the server
if (isDev) {
// this is used as a fallback port for development
url.port = '4001';
}
const result = url.toString();
// prevent trailing slash
return result.endsWith('/') ? result.slice(0, -1) : result;
}
/**
* Resolves a base URI for a client that is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
* This is necessary for ontime cloud and should otherwise not affect the client
*/
function resolveBaseURI(): string {
// in ontime cloud, the base tag is set by the server
const baseHref = document.querySelector('base')?.getAttribute('href');
const base = baseHref ?? '';
// prevent a trailing slash from either an empty base or a base with a trailing slash
if (base.endsWith('/')) {
return base.slice(0, -1);
}
return base;
}
@@ -10,6 +10,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings'; import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent'; import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex'; import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './integrationUtils'; import { cycles } from './integrationUtils';
@@ -106,6 +107,9 @@ export default function OscIntegrations() {
</Button> </Button>
</div> </div>
</Panel.SubHeader> </Panel.SubHeader>
{isOntimeCloud && (
<Panel.Highlight>For security reasons OSC integrations are not available in the cloud service.</Panel.Highlight>
)}
<Panel.Divider /> <Panel.Divider />
@@ -88,7 +88,6 @@ export default function EditorSettingsForm() {
> >
<option value={TimerType.CountDown}>Count down</option> <option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option> <option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option> <option value={TimerType.Clock}>Clock</option>
<option value={TimerType.None}>None</option> <option value={TimerType.None}>None</option>
</Select> </Select>
@@ -2,8 +2,8 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import { openLink } from '../../../../common/utils/linkUtils'; import { linkToOtherHost, openLink } from '../../../../common/utils/linkUtils';
import { isLocalhost, serverPort } from '../../../../externals'; import { isLocalhost } from '../../../../externals';
import style from './NetworkInterfaces.module.scss'; import style from './NetworkInterfaces.module.scss';
@@ -14,11 +14,11 @@ export default function InfoNif() {
return ( return (
<div className={style.interfaces}> <div className={style.interfaces}>
{data.networkInterfaces?.map((nif) => { {data.networkInterfaces.map((nif) => {
// interfaces outside localhost wont have access // interfaces outside localhost wont have access
if (nif.name === 'localhost' && !isLocalhost) return null; if (nif.name === 'localhost' && !isLocalhost) return null;
const address = linkToOtherHost(nif.address);
const address = `http://${nif.address}:${serverPort}`;
return ( return (
<CopyTag <CopyTag
key={nif.name} key={nif.name}
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket'; import { usePing } from '../../../../common/hooks/useSocket';
import { socketSendJson } from '../../../../common/utils/socket'; import { socketSendJson } from '../../../../common/utils/socket';
import { isOntimeCloud } from '../../../../externals'; import { isDockerImage } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel'; import ClientControlPanel from '../client-control-panel/ClientControlPanel';
@@ -19,7 +19,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
<> <>
<Panel.Header>Network</Panel.Header> <Panel.Header>Network</Panel.Header>
<Panel.Section> <Panel.Section>
{isOntimeCloud && <OntimeCloudStats />} {isDockerImage && <OntimeCloudStats />}
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph> <Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
</Panel.Section> </Panel.Section>
<InfoNif /> <InfoNif />
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_LIST } from '../../../../common/api/constants'; import { PROJECT_LIST } from '../../../../common/api/constants';
import { createProject } from '../../../../common/api/db'; import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import { documentationUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
@@ -118,7 +119,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Input <Input
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
placeholder='www.getontime.no' placeholder={websiteUrl}
autoComplete='off' autoComplete='off'
{...register('publicUrl')} {...register('publicUrl')}
/> />
@@ -140,7 +141,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Input <Input
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
placeholder='http://docs.getontime.no' placeholder={documentationUrl}
autoComplete='off' autoComplete='off'
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
@@ -10,6 +10,7 @@ import { postProjectData, uploadProjectLogo } from '../../../../common/api/proje
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import useProjectData from '../../../../common/hooks-query/useProjectData'; import useProjectData from '../../../../common/hooks-query/useProjectData';
import { validateLogo } from '../../../../common/utils/uploadUtils'; import { validateLogo } from '../../../../common/utils/uploadUtils';
import { documentationUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
@@ -203,7 +204,7 @@ export default function ProjectData() {
<Input <Input
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
placeholder='www.getontime.no' placeholder={websiteUrl}
autoComplete='off' autoComplete='off'
{...register('publicUrl')} {...register('publicUrl')}
/> />
@@ -225,7 +226,7 @@ export default function ProjectData() {
<Input <Input
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
placeholder='http://docs.getontime.no' placeholder={documentationUrl}
autoComplete='off' autoComplete='off'
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
@@ -11,7 +11,7 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent'; import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
import { isLocalhost } from '../../../../externals'; import { isLocalhost, isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
export default function ShutdownPanel() { export default function ShutdownPanel() {
@@ -24,18 +24,28 @@ export default function ShutdownPanel() {
onClose(); onClose();
}; };
const canShutdown = isElectron || isLocalhost;
return ( return (
<> <>
<Panel.Header>Shutdown Ontime</Panel.Header> <Panel.Header>Shutdown Ontime</Panel.Header>
<Panel.Section> <Panel.Section>
<Panel.Paragraph> {isOntimeCloud ? (
This will shutdown the Ontime server. <br /> <Panel.Highlight>
The runtime state will be lost, but your project is kept for next time. For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
</Panel.Paragraph> </Panel.Highlight>
) : (
<Panel.Paragraph>
This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph>
)}
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}> <Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}>
Shutdown ontime Shutdown ontime
</Button> </Button>
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description> {!canShutdown && (
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
)}
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}> <AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay> <AlertDialogOverlay>
<AlertDialogContent> <AlertDialogContent>
@@ -49,7 +59,7 @@ export default function ShutdownPanel() {
<Button ref={cancelRef} onClick={onClose} variant='ontime-ghosted-white'> <Button ref={cancelRef} onClick={onClose} variant='ontime-ghosted-white'>
Cancel Cancel
</Button> </Button>
<Button colorScheme='red' onClick={sendShutdown} ml={4}> <Button colorScheme='red' onClick={sendShutdown} disabled={!canShutdown}>
Shutdown Shutdown
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>
@@ -11,6 +11,7 @@ export const namedImportMap = {
Duration: 'duration', Duration: 'duration',
Cue: 'cue', Cue: 'cue',
Title: 'title', Title: 'title',
'Time to end': 'time to end',
'Is Public': 'public', 'Is Public': 'public',
Skip: 'skip', Skip: 'skip',
Note: 'notes', Note: 'notes',
@@ -47,6 +48,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
duration: namedImportMap.Duration, duration: namedImportMap.Duration,
cue: namedImportMap.Cue, cue: namedImportMap.Cue,
title: namedImportMap.Title, title: namedImportMap.Title,
isTimeToEnd: namedImportMap['Time to end'],
isPublic: namedImportMap['Is Public'], isPublic: namedImportMap['Is Public'],
skip: namedImportMap.Skip, skip: namedImportMap.Skip,
note: namedImportMap.Note, note: namedImportMap.Note,
@@ -40,6 +40,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<th>Duration</th> <th>Duration</th>
<th>Warning Time</th> <th>Warning Time</th>
<th>Danger Time</th> <th>Danger Time</th>
<th>Is Time to End</th>
<th>Is Public</th> <th>Is Public</th>
<th>Skip</th> <th>Skip</th>
<th>Colour</th> <th>Colour</th>
@@ -71,6 +72,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
} }
eventIndex += 1; eventIndex += 1;
const colour = event.colour ? getAccessibleColour(event.colour) : {}; const colour = event.colour ? getAccessibleColour(event.colour) : {};
const isTimeToEnd = booleanToText(event.isTimeToEnd);
const isPublic = booleanToText(event.isPublic); const isPublic = booleanToText(event.isPublic);
const skip = booleanToText(event.skip); const skip = booleanToText(event.skip);
@@ -93,6 +95,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<td>{millisToString(event.duration)}</td> <td>{millisToString(event.duration)}</td>
<td>{millisToString(event.timeWarning)}</td> <td>{millisToString(event.timeWarning)}</td>
<td>{millisToString(event.timeDanger)}</td> <td>{millisToString(event.timeDanger)}</td>
<td className={style.center}>{isTimeToEnd && <Tag>{isTimeToEnd}</Tag>}</td>
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td> <td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td> <td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{event.colour}</td> <td style={{ ...colour }}>{event.colour}</td>
@@ -16,7 +16,7 @@ import { Corner } from '../../editors/editor-utils/EditorUtils';
import style from './MessageControl.module.scss'; import style from './MessageControl.module.scss';
export default function TimerPreview() { export default function TimerPreview() {
const { blink, blackout, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } = const { blink, blackout, isTimeToEnd, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
useMessagePreview(); useMessagePreview();
const { data } = useViewSettings(); const { data } = useViewSettings();
@@ -24,11 +24,11 @@ export default function TimerPreview() {
const main = (() => { const main = (() => {
if (showTimerMessage) return 'Message'; if (showTimerMessage) return 'Message';
if (timerType === TimerType.None) return timerPlaceholder;
if (phase === TimerPhase.Pending) return 'Standby to start'; if (phase === TimerPhase.Pending) return 'Standby to start';
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message'; if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
if (timerType === TimerType.TimeToEnd) return 'Time to end';
if (timerType === TimerType.Clock) return 'Clock'; if (timerType === TimerType.Clock) return 'Clock';
if (timerType === TimerType.None) return timerPlaceholder; if (isTimeToEnd) return 'Target event scheduled end';
return 'Timer'; return 'Timer';
})(); })();
@@ -74,12 +74,16 @@ export default function TimerPreview() {
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren> <Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} /> <IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
</Tooltip> </Tooltip>
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
</Tooltip>
<Tooltip label='Time type: None' openDelay={tooltipDelayMid} shouldWrapChildren> <Tooltip label='Time type: None' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoBan className={style.statusIcon} data-active={timerType === TimerType.None} /> <IoBan className={style.statusIcon} data-active={timerType === TimerType.None} />
</Tooltip> </Tooltip>
<Tooltip
label={isTimeToEnd ? 'Count to schedule' : 'Count from start'}
openDelay={tooltipDelayMid}
shouldWrapChildren
>
<IoFlag className={style.statusIcon} data-active={isTimeToEnd} />
</Tooltip>
</div> </div>
</div> </div>
); );
@@ -1,41 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`makeTable() > returns array of arrays with given fields 1`] = `
[
[
"Ontime · Rundown export",
],
[
"Project title: test title",
],
[
"Project description: test description",
],
[
"Time Start",
"Time End",
"Duration",
"ID",
"Colour",
"Cue",
"Title",
"Note",
"Is Public? (x)",
"Skip?",
"lighting",
],
[
"00:00:00",
"00:00:00",
"...",
"",
"",
"",
"test title 1",
"",
"x",
"",
"",
],
]
`;
@@ -1,120 +0,0 @@
$label-colour: $gray-700;
$active-colour: $gray-500;
@mixin label {
font-size: $inner-section-text-size;
color: $label-colour;
text-align: center;
align-self: end;
}
@mixin time {
font-family: 'Open Sans Light', $ontime-font-family;
font-size: 2rem;
text-align: center;
}
.header {
grid-area: header;
display: grid;
width: 100%;
padding: 0.25rem 1rem;
height: max-content;
column-gap: 2rem;
grid-template-areas: 'event playback timer clock actions';
grid-template-columns: 1fr auto auto auto auto;
align-items: center;
justify-items: center;
}
.event {
grid-area: event;
justify-self: start;
.title {
font-size: 1.75rem;
}
.eventNow {
justify-self: start;
font-size: 1.5rem;
}
}
.playback {
grid-area: playback;
display: flex;
flex-direction: column;
justify-items: center;
align-items: center;
.playbackLabel {
@include label;
}
svg {
font-size: 2rem;
height: 3rem;
color: $label-colour;
}
}
.timer {
grid-area: timer;
.timerLabel {
@include label;
}
.value {
@include time;
}
}
.clock {
grid-area: clock;
.clockLabel {
@include label;
}
.value {
grid-area: clock;
@include time;
}
}
.headerActions {
grid-area: actions;
display: flex;
align-items: center;
gap: 0.5rem;
color: $label-colour;
height: 100%;
font-size: 1rem;
.actionIcon,
.actionText {
cursor: pointer;
&.enabled {
color: $active-indicator;
}
&:hover {
color: $active-colour;
}
}
.actionIcon {
font-size: 1.25rem;
}
}
@media (min-width: 1200px) {
// in large screens we want to space the buttons
.headerActions {
padding-left: 10vw;
}
}
@@ -1,70 +0,0 @@
import { Tooltip } from '@chakra-ui/react';
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { Playback, ProjectData } from 'ontime-types';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { cx, enDash } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
import style from './CuesheetTableHeader.module.scss';
interface CuesheetTableHeaderProps {
handleExport: (headerData: ProjectData) => void;
featureData: {
playback: Playback;
selectedEventIndex: number | null;
numEvents: number;
titleNow: string | null;
};
}
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected);
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
const { data: project } = useProjectData();
const exportProject = () => {
if (project) {
handleExport(project);
}
};
const selected = !featureData.numEvents
? 'No events'
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : enDash}/${
featureData.numEvents ? featureData.numEvents : enDash
}`;
return (
<div className={style.header}>
<div className={style.event}>
<div className={style.title}>{project?.title || enDash}</div>
<div className={style.eventNow}>{featureData?.titleNow || enDash}</div>
</div>
<div className={style.playback}>
<div className={style.playbackLabel}>{selected}</div>
<PlaybackIcon state={featureData.playback} />
</div>
<CuesheetTableHeaderTimers />
<div className={style.headerActions}>
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
<span
onClick={() => toggleFollow()}
className={cx([style.actionIcon, followSelected ? style.enabled : null])}
>
<IoLocate />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
<span className={style.actionText} onClick={exportProject}>
Export CSV
</span>
</Tooltip>
</div>
</div>
);
}
@@ -1,23 +0,0 @@
import { useClock, useTimer } from '../../../common/hooks/useSocket';
import ClockTime from '../../viewers/common/clock-time/ClockTime';
import RunningTime from '../../viewers/common/running-time/RunningTime';
import style from './CuesheetTableHeader.module.scss';
export default function CuesheetTableHeaderTimers() {
const { current } = useTimer();
const { clock } = useClock();
return (
<>
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<RunningTime className={style.value} value={current} hideLeadingZero />
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<ClockTime className={style.value} value={clock} />
</div>
</>
);
}
@@ -1,106 +0,0 @@
import { memo, ReactNode } from 'react';
import { Button, Checkbox, Switch } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import style from './CuesheetTableSettings.module.scss';
// reusable button styles
const buttonProps = {
size: 'sm',
variant: 'ontime-subtle',
};
interface CuesheetTableSettingsProps {
columns: Column<OntimeRundownEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
}
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const {
followSelected,
showIndexColumn,
toggleFollow,
showPrevious,
togglePreviousVisibility,
showDelayBlock,
hideSeconds,
showDelayedTimes,
toggleIndexColumn,
toggleDelayedTimes,
toggleDelayVisibility,
toggleSecondsVisibility,
} = useCuesheetSettings();
return (
<div className={style.tableSettings}>
<div className={style.leftPanel}>
<div className={style.sectionTitle}>Toggle column visibility</div>
<div className={style.options}>
{columns.map((column) => {
const columnHeader = column.columnDef.header;
const visible = column.getIsVisible();
return (
<label key={`${column.id}-${visible}`} className={style.option}>
<Checkbox
variant='ontime-ondark'
defaultChecked={visible}
onChange={column.getToggleVisibilityHandler()}
/>
{columnHeader as ReactNode}
</label>
);
})}
</div>
<div className={style.sectionTitle}>Table Options</div>
<div className={style.options}>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={followSelected} onChange={() => toggleFollow()} />
Follow selected event
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
Show past events
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showIndexColumn} onChange={() => toggleIndexColumn()} />
Show Event Order
</label>
</div>
<div className={style.sectionTitle}>Delay Flow</div>
<div className={style.options}>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showDelayedTimes} onChange={() => toggleDelayedTimes()} />
Show delayed times
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
Show delay blocks
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={hideSeconds} onChange={() => toggleSecondsVisibility()} />
Hide seconds
</label>
</div>
</div>
<div className={style.rightPanel}>
<Button onClick={handleClearToggles} {...buttonProps}>
Show All
</Button>
<Button onClick={handleResetResizing} {...buttonProps}>
Reset Resizing
</Button>
<Button onClick={handleResetReordering} {...buttonProps}>
Reset Reordering
</Button>
</div>
</div>
);
}
export default memo(CuesheetTableSettings);
@@ -27,7 +27,7 @@
.title { .title {
font-size: 1rem; font-size: 1rem;
color: $label-gray; color: $ui-white;
} }
.label { .label {
@@ -27,6 +27,7 @@
} }
.tableContainer { .tableContainer {
height: 350px;
max-height: 350px; max-height: 350px;
overflow-y: auto; overflow-y: auto;
} }
@@ -53,7 +53,7 @@ export default function Welcome(props: WelcomeProps) {
}; };
return ( return (
<Modal isOpen onClose={handleClose} variant='ontime'> <Modal isOpen onClose={handleClose} closeOnOverlayClick={false} variant='ontime'>
<ModalOverlay /> <ModalOverlay />
<ModalContent maxWidth='max(640px, 40vw)'> <ModalContent maxWidth='max(640px, 40vw)'>
<ModalCloseButton /> <ModalCloseButton />
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types'; import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import Empty from '../../common/components/state/Empty'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket'; import { useOperator } from '../../common/hooks/useSocket';
@@ -108,7 +108,7 @@ export default function Operator() {
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending'; const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
if (missingData || isLoading) { if (missingData || isLoading) {
return <Empty text='Loading...' />; return <EmptyPage text='Loading...' />;
} }
// get fields which the user subscribed to // get fields which the user subscribed to
@@ -87,6 +87,10 @@ function _CuesheetOverview({ children }: { children: React.ReactNode }) {
function TitlesOverview() { function TitlesOverview() {
const { data } = useProjectData(); const { data } = useProjectData();
if (!data.title && !data.description) {
return null;
}
return ( return (
<div> <div>
<div className={style.title}>{data.title}</div> <div className={style.title}>{data.title}</div>
@@ -150,6 +150,7 @@ export default function RundownEntry(props: RundownEntryProps) {
if (data.type === SupportedEvent.Event) { if (data.type === SupportedEvent.Event) {
return ( return (
<EventBlock <EventBlock
eventId={data.id}
eventIndex={eventIndex} eventIndex={eventIndex}
cue={data.cue} cue={data.cue}
timeStart={data.timeStart} timeStart={data.timeStart}
@@ -157,7 +158,7 @@ export default function RundownEntry(props: RundownEntryProps) {
duration={data.duration} duration={data.duration}
timeStrategy={data.timeStrategy} timeStrategy={data.timeStrategy}
linkStart={data.linkStart} linkStart={data.linkStart}
eventId={data.id} isTimeToEnd={data.isTimeToEnd}
isPublic={data.isPublic} isPublic={data.isPublic}
endAction={data.endAction} endAction={data.endAction}
timerType={data.timerType} timerType={data.timerType}
@@ -24,13 +24,14 @@ import RundownIndicators from './RundownIndicators';
import style from './EventBlock.module.scss'; import style from './EventBlock.module.scss';
interface EventBlockProps { interface EventBlockProps {
eventId: string;
cue: string; cue: string;
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: MaybeString;
eventId: string; isTimeToEnd: boolean;
eventIndex: number; eventIndex: number;
isPublic: boolean; isPublic: boolean;
endAction: EndAction; endAction: EndAction;
@@ -68,6 +69,7 @@ export default function EventBlock(props: EventBlockProps) {
duration, duration,
timeStrategy, timeStrategy,
linkStart, linkStart,
isTimeToEnd,
isPublic = true, isPublic = true,
eventIndex, eventIndex,
endAction, endAction,
@@ -286,6 +288,7 @@ export default function EventBlock(props: EventBlockProps) {
timeEnd={timeEnd} timeEnd={timeEnd}
duration={duration} duration={duration}
linkStart={linkStart} linkStart={linkStart}
isTimeToEnd={isTimeToEnd}
timeStrategy={timeStrategy} timeStrategy={timeStrategy}
eventId={eventId} eventId={eventId}
eventIndex={eventIndex} eventIndex={eventIndex}
@@ -23,12 +23,13 @@ import EventBlockProgressBar from './composite/EventBlockProgressBar';
import style from './EventBlock.module.scss'; import style from './EventBlock.module.scss';
interface EventBlockInnerProps { interface EventBlockInnerProps {
eventId: string;
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: MaybeString;
eventId: string; isTimeToEnd: boolean;
eventIndex: number; eventIndex: number;
isPublic: boolean; isPublic: boolean;
endAction: EndAction; endAction: EndAction;
@@ -43,14 +44,15 @@ interface EventBlockInnerProps {
isRolling: boolean; isRolling: boolean;
} }
const EventBlockInner = (props: EventBlockInnerProps) => { function EventBlockInner(props: EventBlockInnerProps) {
const { const {
eventId,
timeStart, timeStart,
timeEnd, timeEnd,
duration, duration,
timeStrategy, timeStrategy,
linkStart, linkStart,
eventId, isTimeToEnd,
isPublic = true, isPublic = true,
endAction, endAction,
timerType, timerType,
@@ -91,7 +93,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
delay={delay} delay={delay}
timeStrategy={timeStrategy} timeStrategy={timeStrategy}
linkStart={linkStart} linkStart={linkStart}
timerType={timerType} isTimeToEnd={isTimeToEnd}
/> />
</div> </div>
<div className={style.titleSection}> <div className={style.titleSection}>
@@ -122,6 +124,11 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
<EndActionIcon action={endAction} className={style.statusIcon} /> <EndActionIcon action={endAction} className={style.statusIcon} />
</span> </span>
</Tooltip> </Tooltip>
<Tooltip label={`${isTimeToEnd ? 'Time to End' : 'Count from start'}`} openDelay={tooltipDelayMid}>
<span>
<IoFlag className={`${style.statusIcon} ${isTimeToEnd ? style.active : style.disabled}`} />
</span>
</Tooltip>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}> <Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}>
<span> <span>
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} /> <IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
@@ -131,7 +138,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
</div> </div>
</> </>
); );
}; }
export default memo(EventBlockInner); export default memo(EventBlockInner);
@@ -162,9 +169,5 @@ function TimerIcon(props: { type: TimerType; className: string }) {
if (type === TimerType.None) { if (type === TimerType.None) {
return <IoBan className={className} />; return <IoBan className={className} />;
} }
if (type === TimerType.TimeToEnd) {
const classes = cx([style.active, className]);
return <IoFlag className={classes} />;
}
return <IoArrowDown className={className} />; return <IoArrowDown className={className} />;
} }
@@ -16,7 +16,7 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 1rem;
overflow-y: auto; overflow-y: auto;
} }
@@ -38,6 +38,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
margin-top: 0.5rem;
} }
.decorated { .decorated {
@@ -63,6 +64,7 @@
gap: 0.5rem; gap: 0.5rem;
max-width: max-content; max-width: max-content;
cursor: pointer; cursor: pointer;
height: 30px; // manually match the height of a text input
} }
.inline { .inline {
@@ -83,6 +83,7 @@ export default function EventEditor() {
duration={event.duration} duration={event.duration}
timeStrategy={event.timeStrategy} timeStrategy={event.timeStrategy}
linkStart={event.linkStart} linkStart={event.linkStart}
isTimeToEnd={event.isTimeToEnd}
delay={event.delay ?? 0} delay={event.delay ?? 0}
isPublic={event.isPublic} isPublic={event.isPublic}
endAction={event.endAction} endAction={event.endAction}
@@ -18,6 +18,7 @@ interface EventEditorTimesProps {
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: MaybeString;
isTimeToEnd: boolean;
delay: number; delay: number;
isPublic: boolean; isPublic: boolean;
endAction: EndAction; endAction: EndAction;
@@ -26,9 +27,9 @@ interface EventEditorTimesProps {
timeDanger: number; timeDanger: number;
} }
type HandledActions = 'timerType' | 'endAction' | 'isPublic' | 'timeWarning' | 'timeDanger'; type HandledActions = 'isTimeToEnd' | 'timerType' | 'endAction' | 'isPublic' | 'timeWarning' | 'timeDanger';
const EventEditorTimes = (props: EventEditorTimesProps) => { function EventEditorTimes(props: EventEditorTimesProps) {
const { const {
eventId, eventId,
timeStart, timeStart,
@@ -36,6 +37,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
duration, duration,
timeStrategy, timeStrategy,
linkStart, linkStart,
isTimeToEnd,
delay, delay,
isPublic, isPublic,
endAction, endAction,
@@ -51,6 +53,11 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
return; return;
} }
if (field === 'isTimeToEnd') {
updateEvent({ id: eventId, isTimeToEnd: !(value as boolean) });
return;
}
if (field === 'timeWarning' || field === 'timeDanger') { if (field === 'timeWarning' || field === 'timeDanger') {
const newTime = parseUserTime(value as string); const newTime = parseUserTime(value as string);
updateEvent({ id: eventId, [field]: newTime }); updateEvent({ id: eventId, [field]: newTime });
@@ -71,95 +78,115 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
: ''; : '';
return ( return (
<div className={style.column}> <>
<div> <div className={style.column}>
<Editor.Label>Event schedule</Editor.Label> <div>
<div className={style.inline}> <Editor.Label>Event schedule</Editor.Label>
<TimeInputFlow <div className={style.inline}>
eventId={eventId} <TimeInputFlow
timeStart={timeStart} eventId={eventId}
timeEnd={timeEnd} timeStart={timeStart}
duration={duration} timeEnd={timeEnd}
timeStrategy={timeStrategy} duration={duration}
linkStart={linkStart} timeStrategy={timeStrategy}
delay={delay} linkStart={linkStart}
timerType={timerType} delay={delay}
/> isTimeToEnd={isTimeToEnd}
/>
</div>
<div className={style.delayLabel}>{delayLabel}</div>
</div> </div>
<div className={style.delayLabel}>{delayLabel}</div>
</div>
<div className={style.splitTwo}> <Editor.Title>Event behaviour</Editor.Title>
<div> <div className={style.splitTwo}>
<Editor.Label htmlFor='timeWarning'>Warning Time</Editor.Label> <div>
<TimeInput <Editor.Label htmlFor='endAction'>End Action</Editor.Label>
id='timeWarning' <Select
name='timeWarning' id='endAction'
submitHandler={handleSubmit} size='sm'
time={timeWarning} name='endAction'
placeholder='Duration' value={endAction}
/> onChange={(event) => handleSubmit('endAction', event.target.value)}
</div> variant='ontime'
<div> >
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label> <option value={EndAction.None}>None</option>
<Select <option value={EndAction.Stop}>Stop rundown</option>
size='sm' <option value={EndAction.LoadNext}>Load next event</option>
id='timerType' <option value={EndAction.PlayNext}>Play next event</option>
name='timerType' </Select>
value={timerType} </div>
onChange={(event) => handleSubmit('timerType', event.target.value)} <div>
variant='ontime' <Editor.Label htmlFor='timeToEnd'>Target Event Scheduled End</Editor.Label>
> <Editor.Label className={style.switchLabel}>
<option value={TimerType.CountDown}>Count down</option> <Switch
<option value={TimerType.CountUp}>Count up</option> id='timeToEnd'
<option value={TimerType.TimeToEnd}>Time to end</option> size='md'
<option value={TimerType.Clock}>Clock</option> isChecked={isTimeToEnd}
<option value={TimerType.None}>None</option> onChange={() => handleSubmit('isTimeToEnd', isTimeToEnd)}
</Select> variant='ontime'
</div> />
<div> {isTimeToEnd ? 'On' : 'Off'}
<Editor.Label htmlFor='timeDanger'>Danger Time</Editor.Label> </Editor.Label>
<TimeInput </div>
id='timeDanger'
name='timeDanger'
submitHandler={handleSubmit}
time={timeDanger}
placeholder='Duration'
/>
</div>
<div>
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
<Select
id='endAction'
size='sm'
name='endAction'
value={endAction}
onChange={(event) => handleSubmit('endAction', event.target.value)}
variant='ontime'
>
<option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load Next</option>
<option value={EndAction.PlayNext}>Play Next</option>
</Select>
</div> </div>
</div> </div>
<div className={style.column}>
<Editor.Title>Display options</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
<Select
size='sm'
id='timerType'
name='timerType'
value={timerType}
onChange={(event) => handleSubmit('timerType', event.target.value)}
variant='ontime'
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.Clock}>Clock</option>
<option value={TimerType.None}>None</option>
</Select>
</div>
<div>
<Editor.Label htmlFor='timeWarning'>Warning Time</Editor.Label>
<TimeInput
id='timeWarning'
name='timeWarning'
submitHandler={handleSubmit}
time={timeWarning}
placeholder='Duration'
/>
</div>
<div> <div>
<Editor.Label htmlFor='isPublic'>Event Visibility</Editor.Label> <Editor.Label htmlFor='isPublic'>Event Visibility</Editor.Label>
<Editor.Label className={style.switchLabel}> <Editor.Label className={style.switchLabel}>
<Switch <Switch
id='isPublic' id='isPublic'
size='md' size='md'
isChecked={isPublic} isChecked={isPublic}
onChange={() => handleSubmit('isPublic', isPublic)} onChange={() => handleSubmit('isPublic', isPublic)}
variant='ontime' variant='ontime'
/> />
{isPublic ? 'Public' : 'Private'} {isPublic ? 'Public' : 'Private'}
</Editor.Label> </Editor.Label>
</div>
<div>
<Editor.Label htmlFor='timeDanger'>Danger Time</Editor.Label>
<TimeInput
id='timeDanger'
name='timeDanger'
submitHandler={handleSubmit}
time={timeDanger}
placeholder='Duration'
/>
</div>
</div>
</div> </div>
</div> </>
); );
}; }
export default memo(EventEditorTimes); export default memo(EventEditorTimes);
@@ -29,6 +29,7 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => {
return ( return (
<div className={style.column}> <div className={style.column}>
<Editor.Title>Event data</Editor.Title>
<div className={style.splitTwo}> <div className={style.splitTwo}>
<div> <div>
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label> <Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
@@ -5,7 +5,7 @@ import { IoLink } from '@react-icons/all-files/io5/IoLink';
import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed'; import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed';
import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline'; import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink'; import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
import { MaybeString, TimerType, TimeStrategy } from 'ontime-types'; import { MaybeString, TimeStrategy } from 'ontime-types';
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton'; import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
@@ -16,19 +16,19 @@ import style from './TimeInputFlow.module.scss';
interface EventBlockTimerProps { interface EventBlockTimerProps {
eventId: string; eventId: string;
isTimeToEnd: boolean;
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: MaybeString;
delay: number; delay: number;
timerType: TimerType;
} }
type TimeActions = 'timeStart' | 'timeEnd' | 'duration'; type TimeActions = 'timeStart' | 'timeEnd' | 'duration';
const TimeInputFlow = (props: EventBlockTimerProps) => { function TimeInputFlow(props: EventBlockTimerProps) {
const { eventId, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, timerType } = props; const { eventId, isTimeToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay } = props;
const { updateEvent, updateTimer } = useEventAction(); const { updateEvent, updateTimer } = useEventAction();
// In sync with EventEditorTimes // In sync with EventEditorTimes
@@ -49,8 +49,8 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
warnings.push('Over midnight'); warnings.push('Over midnight');
} }
if (timerType === TimerType.TimeToEnd) { if (isTimeToEnd) {
warnings.push('Time to end'); warnings.push('Target event scheduled end');
} }
const hasDelay = delay !== 0; const hasDelay = delay !== 0;
@@ -128,6 +128,6 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
)} )}
</> </>
); );
}; }
export default memo(TimeInputFlow); export default memo(TimeInputFlow);
@@ -9,6 +9,7 @@ import {
Settings, Settings,
SimpleTimerState, SimpleTimerState,
SupportedEvent, SupportedEvent,
TimerType,
ViewSettings, ViewSettings,
} from 'ontime-types'; } from 'ontime-types';
import { useStore } from 'zustand'; import { useStore } from 'zustand';
@@ -74,16 +75,14 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
const selectedId = eventNow?.id ?? null; const selectedId = eventNow?.id ?? null;
const nextId = eventNext?.id ?? null; const nextId = eventNext?.id ?? null;
/******************************************/ /**
/*** + TimeManagerType ***/ * Contains an extended timer object with properties from the current event
/*** WRAP INFORMATION RELATED TO TIME ***/ */
/*** -------------------------------- ***/ const timeManagerType: ViewExtendedTimer = {
/******************************************/
const TimeManagerType = {
...timer, ...timer,
clock, clock,
timerType: eventNow?.timerType ?? null, timerType: eventNow?.timerType ?? TimerType.CountDown,
isTimeToEnd: eventNow?.isTimeToEnd ?? false,
}; };
return ( return (
@@ -108,7 +107,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
runtime={runtime} runtime={runtime}
selectedId={selectedId} selectedId={selectedId}
settings={settings} settings={settings}
time={TimeManagerType} time={timeManagerType}
viewSettings={viewSettings} viewSettings={viewSettings}
/> />
</> </>
@@ -5,16 +5,22 @@ import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type'
import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils'; import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>; type TimerTypeParams = Pick<ViewExtendedTimer, 'isTimeToEnd' | 'timerType' | 'current' | 'elapsed' | 'clock'>;
export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null { export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null {
if (!timerObject) { if (!timerObject) {
return null; return null;
} }
if (timerObject.isTimeToEnd) {
if (timerObject.current === null) {
return null;
}
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
}
switch (timerObject.timerType) { switch (timerObject.timerType) {
case TimerType.CountDown: case TimerType.CountDown:
case TimerType.TimeToEnd:
if (timerObject.current === null) { if (timerObject.current === null) {
return null; return null;
} }
@@ -124,7 +124,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
const isPlaying = time.playback !== Playback.Pause; const isPlaying = time.playback !== Playback.Pause;
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.timerType === TimerType.TimeToEnd; const shouldShowModifiers = time.timerType === TimerType.CountDown || time.isTimeToEnd;
const finished = time.phase === TimerPhase.Overtime; const finished = time.phase === TimerPhase.Overtime;
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage && !hideEndMessage; const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage && !hideEndMessage;
const showFinished = const showFinished =
@@ -149,6 +149,7 @@ $orange-active: #f60;
grid-area: schedule; grid-area: schedule;
font-family: monospace; font-family: monospace;
margin-right: 2vw; margin-right: 2vw;
overflow: hidden;
} }
.onAir { .onAir {
@@ -118,7 +118,7 @@ export default function Timer(props: TimerProps) {
const finished = time.phase === TimerPhase.Overtime; const finished = time.phase === TimerPhase.Overtime;
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0); const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.timerType === TimerType.TimeToEnd; const shouldShowModifiers = time.timerType === TimerType.CountDown || time.isTimeToEnd;
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage; const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage;
const showProgress = const showProgress =
eventNow !== null && eventNow !== null &&
+1
View File
@@ -57,6 +57,7 @@ $text-body-size: calc(1rem - 1px);
// media queries // media queries
$min-tablet: 500px; $min-tablet: 500px;
$small-screen: 800px;
.blink { .blink {
animation: blink 1s step-start infinite; animation: blink 1s step-start infinite;
@@ -45,12 +45,11 @@ $table-header-font-size: calc(1rem - 3px);
.tableHeader { .tableHeader {
position: sticky; position: sticky;
top: -1px; top: 0px;
z-index: 10; z-index: 10;
background-color: $ui-black;
font-size: $table-header-font-size; font-size: $table-header-font-size;
color: $gray-700; color: $label-gray;}
}
th { th {
background-color: $gray-1300; background-color: $gray-1300;
@@ -11,7 +11,7 @@ import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow'; import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow'; import EventRow from './cuesheet-table-elements/EventRow';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings'; import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import { useCuesheetSettings } from './store/CuesheetSettings'; import { useCuesheetSettings } from './store/cuesheetSettingsStore';
import useColumnManager from './useColumnManager'; import useColumnManager from './useColumnManager';
import style from './Cuesheet.module.scss'; import style from './Cuesheet.module.scss';
@@ -5,22 +5,22 @@ import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types'; import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu'; import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import Empty from '../../common/components/state/Empty'; import EmptyPage from '../../common/components/state/EmptyPage';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket'; import { useCuesheet } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown'; import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { CuesheetOverview } from '../overview/Overview'; import { CuesheetOverview } from '../../features/overview/Overview';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import { useCuesheetSettings } from './store/CuesheetSettings'; import { useCuesheetSettings } from './store/cuesheetSettingsStore';
import Cuesheet from './Cuesheet'; import Cuesheet from './Cuesheet';
import { makeCuesheetColumns } from './cuesheetCols'; import { makeCuesheetColumns } from './cuesheetCols';
import styles from './CuesheetWrapper.module.scss'; import styles from './CuesheetPage.module.scss';
export default function CuesheetWrapper() { export default function CuesheetPage() {
// TODO: can we use the normalised rundown for the table? // TODO: can we use the normalised rundown for the table?
const { data: flatRundown, status: rundownStatus } = useFlatRundown(); const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
@@ -79,7 +79,7 @@ export default function CuesheetWrapper() {
); );
if (!customFields || !flatRundown || rundownStatus !== 'success') { if (!customFields || !flatRundown || rundownStatus !== 'success') {
return <Empty text='Loading...' />; return <EmptyPage text='Loading...' />;
} }
return ( return (
@@ -87,14 +87,14 @@ export default function CuesheetWrapper() {
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} /> <ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<CuesheetOverview> <CuesheetOverview>
<IconButton <IconButton
aria-label='Toggle settings' aria-label='Toggle navigation'
variant='ontime-subtle-white' variant='ontime-subtle-white'
size='lg' size='lg'
icon={<IoApps />} icon={<IoApps />}
onClick={onOpen} onClick={onOpen}
/> />
<IconButton <IconButton
aria-label='Toggle navigation' aria-label='Toggle settings'
variant='ontime-subtle-white' variant='ontime-subtle-white'
size='lg' size='lg'
icon={<IoSettingsOutline />} icon={<IoSettingsOutline />}
@@ -1,11 +1,11 @@
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import CuesheetWrapper from './CuesheetWrapper'; import CuesheetPage from './CuesheetPage';
export default function ProtectedCuesheet() { export default function ProtectedCuesheet() {
return ( return (
<ProtectRoute permission='operator'> <ProtectRoute permission='operator'>
<CuesheetWrapper /> <CuesheetPage />
</ProtectRoute> </ProtectRoute>
); );
} }
@@ -1,4 +1,6 @@
import { makeCSV, makeTable, parseField } from '../cuesheetUtils'; import { ProjectData } from 'ontime-types';
import { makeCSV, makeTable, parseField } from '../cuesheet.utils';
describe('parseField()', () => { describe('parseField()', () => {
it('returns a string from given millis on timeStart, TimeEnd and duration', () => { it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
@@ -27,6 +29,7 @@ describe('parseField()', () => {
}); });
it('returns an empty string on undefined fields', () => { it('returns an empty string on undefined fields', () => {
// @ts-expect-error -- testing user data with missing fields
expect(parseField('title')).toBe(''); expect(parseField('title')).toBe('');
}); });
@@ -51,6 +54,7 @@ describe('makeTable()', () => {
const headerData = { const headerData = {
title: 'test title', title: 'test title',
description: 'test description', description: 'test description',
projectLogo: 'test logo',
}; };
const tableData = [ const tableData = [
{ {
@@ -66,8 +70,48 @@ describe('makeTable()', () => {
lighting: { label: 'test' }, lighting: { label: 'test' },
}; };
const table = makeTable(headerData, tableData, customFields); // @ts-expect-error -- testing user data with missing fields
expect(table).toMatchSnapshot(); const table = makeTable(headerData as ProjectData, tableData, customFields);
expect(table).not.toContain('test logo');
expect(table).toMatchInlineSnapshot(`
[
[
"Ontime · Rundown export",
],
[
"Project title: test title",
],
[
"Project description: test description",
],
[
"Time Start",
"Time End",
"Duration",
"ID",
"Colour",
"Cue",
"Title",
"Note",
"Is Public? (x)",
"Skip?",
"lighting",
],
[
"00:00:00",
"00:00:00",
"...",
"",
"",
"",
"test title 1",
"",
"x",
"",
"",
],
]
`);
}); });
}); });
@@ -1,24 +1,20 @@
.tableSettings { .tableSettings {
grid-area: settings; grid-area: settings;
padding: 1rem; padding: 0.5rem 1rem;
background-color: $ui-black;
display: flex; display: flex;
gap: 5rem;
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
}
.leftPanel { @media (max-width: $small-screen) {
display: flex; gap: 1rem;
flex-direction: column; }
width: 100%;
gap: 0.5rem;
} }
.sectionTitle { .sectionTitle {
color: $gray-700;
text-transform: uppercase; text-transform: uppercase;
} }
.options { .row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
column-gap: 1rem; column-gap: 1rem;
@@ -30,12 +26,4 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.rightPanel {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-left: 2rem;
}
@@ -0,0 +1,65 @@
import { memo, ReactNode } from 'react';
import { Button, Checkbox } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import * as Editor from '../../../features/editors/editor-utils/EditorUtils';
import style from './CuesheetTableSettings.module.scss';
// reusable button styles
const buttonProps = {
size: 'xs',
variant: 'ontime-subtle',
};
interface CuesheetTableSettingsProps {
columns: Column<OntimeRundownEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
}
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
return (
<div className={style.tableSettings}>
<div>
<Editor.Label className={style.sectionTitle}>Toggle column visibility</Editor.Label>
<div className={style.row}>
{columns.map((column) => {
const columnHeader = column.columnDef.header;
const visible = column.getIsVisible();
return (
<label key={`${column.id}-${visible}`} className={style.option}>
<Checkbox
variant='ontime-ondark'
defaultChecked={visible}
onChange={column.getToggleVisibilityHandler()}
/>
{columnHeader as ReactNode}
</label>
);
})}
</div>
</div>
<div className={style.column}>
<Editor.Label className={style.sectionTitle}>Reset Options</Editor.Label>
<div className={style.row}>
<Button onClick={handleClearToggles} {...buttonProps}>
Show All
</Button>
<Button onClick={handleResetResizing} {...buttonProps}>
Reset Resizing
</Button>
<Button onClick={handleResetReordering} {...buttonProps}>
Reset Reordering
</Button>
</div>
</div>
</div>
);
}
export default memo(CuesheetTableSettings);
@@ -4,10 +4,10 @@ import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types'; import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import RunningTime from '../viewers/common/running-time/RunningTime'; import RunningTime from '../../features/viewers/common/running-time/RunningTime';
import EditableCell from './cuesheet-table-elements/EditableCell'; import EditableCell from './cuesheet-table-elements/EditableCell';
import { useCuesheetSettings } from './store/CuesheetSettings'; import { useCuesheetSettings } from './store/cuesheetSettingsStore';
import style from './Cuesheet.module.scss'; import style from './Cuesheet.module.scss';
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { booleanFromLocalStorage } from '../../../common/utils/localStorage'; import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
interface CuesheetSettings { interface CuesheetSettingsStore {
showSettings: boolean; showSettings: boolean;
showIndexColumn: boolean; showIndexColumn: boolean;
followSelected: boolean; followSelected: boolean;
@@ -36,7 +36,7 @@ enum CuesheetKeys {
Seconds = 'ontime-cuesheet-hide-sceconds', Seconds = 'ontime-cuesheet-hide-sceconds',
} }
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({ export const useCuesheetSettings = create<CuesheetSettingsStore>()((set) => ({
showSettings: false, showSettings: false,
showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true), showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true),
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false), followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
@@ -1,6 +1,7 @@
import { ProjectData } from 'ontime-types'; import { ProjectData } from 'ontime-types';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
@@ -16,7 +17,7 @@ interface ProjectInfoProps {
isMirrored: boolean; isMirrored: boolean;
} }
export default function ProjectInfoProps(props: ProjectInfoProps) { export default function ProjectInfo(props: ProjectInfoProps) {
const { general, isMirrored } = props; const { general, isMirrored } = props;
useWindowTitle('Project info'); useWindowTitle('Project info');
@@ -25,6 +26,25 @@ export default function ProjectInfoProps(props: ProjectInfoProps) {
return <Empty text='No data found' />; return <Empty text='No data found' />;
} }
if (!general) {
return (
<>
<ViewParamsEditor viewOptions={projectInfoOptions} />
return <EmptyPage text='No data found' />;
</>
);
}
const isEmpty = Object.values(general).every((value) => !value);
if (isEmpty) {
return (
<>
<ViewParamsEditor viewOptions={projectInfoOptions} />
<EmptyPage text='The project has no data yet' />;
</>
);
}
return ( return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'> <div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor viewOptions={projectInfoOptions} /> <ViewParamsEditor viewOptions={projectInfoOptions} />
+2 -1
View File
@@ -11,6 +11,7 @@ const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN;
const isDev = process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'development';
export default defineConfig({ export default defineConfig({
base: './', // Ontime cloud: we use relative paths to allow them to reference a dynamic base set at runtime
plugins: [ plugins: [
react(), react(),
svgrPlugin(), svgrPlugin(),
@@ -33,7 +34,7 @@ export default defineConfig({
}), }),
compression({ compression({
algorithm: 'brotliCompress', algorithm: 'brotliCompress',
exclude: /\.(html)$/, // Exclude HTML files from compression so we can change the base property at runtime exclude: /\.(html)$/, // Ontime cloud: Exclude HTML files from compression so we can change the base property at runtime
}), }),
], ],
server: { server: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.9.1", "version": "3.9.5",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+18 -1
View File
@@ -29,6 +29,7 @@ function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, sho
const template = [ const template = [
...(isMac ? [makeMacMenu(askToQuit)] : []), ...(isMac ? [makeMacMenu(askToQuit)] : []),
makeFileMenu(serverUrl, redirectWindow, showDialog, download), makeFileMenu(serverUrl, redirectWindow, showDialog, download),
makeEditMenu(),
makeViewMenu(clientUrl), makeViewMenu(clientUrl),
makeSettingsMenu(redirectWindow), makeSettingsMenu(redirectWindow),
makeHelpMenu(redirectWindow), makeHelpMenu(redirectWindow),
@@ -61,6 +62,22 @@ function makeMacMenu(askToQuit) {
}; };
} }
/**
* Utility function generates the edit menu
* @returns {Object}
*/
function makeEditMenu() {
return {
label: 'Edit',
submenu: [
{ label: 'Cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
{ label: 'Select All', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
],
};
}
/** /**
* Utility function generates the file menu * Utility function generates the file menu
* @param {string} serverUrl - base url for the application * @param {string} serverUrl - base url for the application
@@ -253,7 +270,7 @@ function makeSettingsMenu(redirectWindow) {
click: () => redirectWindow('/editor?settings=network__log'), click: () => redirectWindow('/editor?settings=network__log'),
}, },
{ {
label: 'Manage cleints', label: 'Manage clients',
click: () => redirectWindow('/editor?settings=network__clients'), click: () => redirectWindow('/editor?settings=network__clients'),
}, },
], ],
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "3.9.1", "version": "3.9.5",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
+2 -2
View File
@@ -47,8 +47,8 @@ export class SocketServer implements IAdapter {
this.wss = null; this.wss = null;
} }
init(server: Server) { init(server: Server, prefix?: string) {
this.wss = new WebSocketServer({ path: '/ws', server, maxPayload: this.MAX_PAYLOAD }); this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
this.wss.on('connection', (ws) => { this.wss.on('connection', (ws) => {
const clientId = generateId(); const clientId = generateId();
+38 -27
View File
@@ -6,11 +6,11 @@ import expressStaticGzip from 'express-static-gzip';
import http, { type Server } from 'http'; import http, { type Server } from 'http';
import cors from 'cors'; import cors from 'cors';
import serverTiming from 'server-timing'; import serverTiming from 'server-timing';
import { extname, resolve } from 'path'; import { extname } from 'node:path';
// import utils // import utils
import { publicDir, srcDir } from './setup/index.js'; import { publicDir, srcDir, srcFiles } from './setup/index.js';
import { environment, isProduction, updateRouterPrefix } from './externals.js'; import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js'; import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
@@ -53,8 +53,14 @@ if (!canLog) {
console.log(`Ontime public directory at ${publicDir.root} `); console.log(`Ontime public directory at ${publicDir.root} `);
} }
// calls an update to the client router prefix /**
updateRouterPrefix(); * When running in Ontime cloud, the client is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
* This means:
* - changing the base path in the index.html file
* - prepending all express routes with the given prefix
*/
const prefix = updateRouterPrefix();
// Create express APP // Create express APP
const app = express(); const app = express();
@@ -75,20 +81,20 @@ app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '1mb' }));
// Implement route endpoints // Implement route endpoints
app.use('/data', appRouter); // router for application data app.use(`${prefix}/data`, appRouter); // router for application data
app.use('/api', integrationRouter); // router for integrations app.use(`${prefix}/api`, integrationRouter); // router for integrations
// serve static external files // serve static external files
app.use('/external', express.static(publicDir.externalDir)); app.use(`${prefix}/external`, express.static(publicDir.externalDir));
app.use('/user', express.static(publicDir.userDir)); app.use(`${prefix}/external`, (req, res) => {
// if the user reaches to the root, we show a 404
// if the user reaches to the root, we show a 404
app.use('/external', (req, res) => {
res.status(404).send(`${req.originalUrl} not found`); res.status(404).send(`${req.originalUrl} not found`);
}); });
app.use(`${prefix}/user`, express.static(publicDir.userDir));
// serve static - react, in dev/test mode we fetch the React app from module // serve static - react, in dev/test mode we fetch the React app from module
app.use( app.use(
prefix,
expressStaticGzip(srcDir.clientDir, { expressStaticGzip(srcDir.clientDir, {
enableBrotli: true, enableBrotli: true,
orderPreference: ['br'], orderPreference: ['br'],
@@ -111,8 +117,8 @@ app.use(
}), }),
); );
app.get('*', (_req, res) => { app.get(`${prefix}/*`, (_req, res) => {
res.sendFile(resolve(srcDir.clientDir, 'index.html')); res.sendFile(srcFiles.clientIndexHtml);
}); });
// Implement catch all // Implement catch all
@@ -176,7 +182,7 @@ export const startServer = async (
const { serverPort } = getDataProvider().getSettings(); const { serverPort } = getDataProvider().getSettings();
expressServer = http.createServer(app); expressServer = http.createServer(app);
socket.init(expressServer); socket.init(expressServer, prefix);
/** /**
* Module initialises the services and provides initial payload for the store * Module initialises the services and provides initial payload for the store
@@ -221,10 +227,10 @@ export const startServer = async (
expressServer.listen(serverPort, '0.0.0.0', () => { expressServer.listen(serverPort, '0.0.0.0', () => {
const nif = getNetworkInterfaces(); const nif = getNetworkInterfaces();
consoleSuccess(`Local: http://localhost:${serverPort}/editor`); consoleSuccess(`Local: http://localhost:${serverPort}${prefix}/editor`);
for (const key in nif) { for (const key in nif) {
const address = nif[key].address; const address = nif[key].address;
consoleSuccess(`Network: http://${address}:${serverPort}/editor`); consoleSuccess(`Network: http://${address}:${serverPort}${prefix}/editor`);
} }
}); });
@@ -243,16 +249,6 @@ export const startIntegrations = async () => {
// if a config is not provided, we use the persisted one // if a config is not provided, we use the persisted one
const { osc, http } = getDataProvider().getData(); const { osc, http } = getDataProvider().getData();
if (osc) {
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
try {
oscIntegration.init(osc);
integrationService.register(oscIntegration);
} catch (error) {
logger.error(LogOrigin.Tx, 'OSC Integration initialisation failed');
}
}
if (http) { if (http) {
logger.info(LogOrigin.Tx, 'Initialising HTTP Integration...'); logger.info(LogOrigin.Tx, 'Initialising HTTP Integration...');
try { try {
@@ -262,6 +258,21 @@ export const startIntegrations = async () => {
logger.error(LogOrigin.Tx, `HTTP Integration initialisation failed: ${error}`); logger.error(LogOrigin.Tx, `HTTP Integration initialisation failed: ${error}`);
} }
} }
if (isOntimeCloud) {
logger.info(LogOrigin.Tx, 'Skipping OSC in Cloud environment...');
return;
}
if (osc) {
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
try {
oscIntegration.init(osc);
integrationService.register(oscIntegration);
} catch (error) {
logger.error(LogOrigin.Tx, 'OSC Integration initialisation failed');
}
}
}; };
/** /**
@@ -37,6 +37,14 @@ export class SimpleTimer {
return this.state; return this.state;
} }
public addTime(millis: number): SimpleTimerState {
this.state.duration += millis;
// the value of current will be overridden when update is called,
// but if we are in pause or stop state it will not be changed so we do it here
this.state.current += millis;
return this.state;
}
public setDirection(direction: SimpleDirection, timeNow: number): SimpleTimerState { public setDirection(direction: SimpleDirection, timeNow: number): SimpleTimerState {
// if we are playing, we need to reset the targets // if we are playing, we need to reset the targets
if (this.state.playback === SimplePlayback.Start) { if (this.state.playback === SimplePlayback.Start) {
@@ -177,5 +177,60 @@ describe('SimpleTimer count-down', () => {
playback: SimplePlayback.Start, playback: SimplePlayback.Start,
}); });
}); });
test('adding time affects final result', () => {
timer.reset();
timer.setTime(1000);
timer.start(0);
timer.update(100);
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
timer.addTime(1000);
timer.update(200);
expect(timer.state).toMatchObject({ current: 1800, duration: 2000 });
timer.update(300);
expect(timer.state).toMatchObject({ current: 1700, duration: 2000 });
timer.stop();
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
});
test('adding time affects paused timer', () => {
timer.reset();
timer.setTime(1000);
timer.start(0);
timer.update(100);
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
timer.pause(200);
expect(timer.state).toMatchObject({ current: 900, duration: 1000 });
timer.addTime(1000);
timer.update(200);
expect(timer.state).toMatchObject({ current: 1900, duration: 2000 });
timer.start(300);
expect(timer.state).toMatchObject({ current: 1800, duration: 2000 });
});
test('adding time affects stopped timer, but returns to initial valuses when stopped again', () => {
timer.reset();
timer.setTime(1000);
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
timer.addTime(1000);
expect(timer.state).toMatchObject({ current: 2000, duration: 2000 });
timer.start(0);
timer.update(100);
expect(timer.state).toMatchObject({ current: 1900, duration: 2000 });
timer.stop();
expect(timer.state).toMatchObject({ current: 1000, duration: 1000 });
});
}); });
}); });
+11 -9
View File
@@ -3,7 +3,8 @@
*/ */
import { readFileSync, writeFileSync } from 'node:fs'; import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { srcFiles } from './setup/index.js';
// ================================================= // =================================================
// resolve running environment // resolve running environment
@@ -13,25 +14,26 @@ export const isTest = Boolean(process.env.IS_TEST);
export const environment = isTest ? 'test' : env; export const environment = isTest ? 'test' : env;
export const isDocker = env === 'docker'; export const isDocker = env === 'docker';
export const isProduction = isDocker || (env === 'production' && !isTest); export const isProduction = isDocker || (env === 'production' && !isTest);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
/** /**
* Updates the router prefix in the index.html file * Updates the router prefix in the index.html file
* This is only needed in the cloud environment where the client is not at the root segment * This is only needed in the cloud environment where the client is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer * ie: https://cloud.getontime.com/client-hash/timer
*/ */
export function updateRouterPrefix(prefix: string | undefined = process.env.ROUTER_PREFIX) { export function updateRouterPrefix(prefix: string | undefined = process.env.ROUTER_PREFIX): string {
if (!prefix) { if (!prefix) {
return; return '';
} }
const indexFile = resolve('.', 'client', 'index.html');
try { try {
const data = readFileSync(indexFile, { encoding: 'utf-8', flag: 'r' }).replace( const data = readFileSync(srcFiles.clientIndexHtml, { encoding: 'utf-8', flag: 'r' }).replace(
/<base href="[^"]*">/g, '<base href="/" />',
`<base href="${prefix}" />`, `<base href="/${prefix}/" />`,
); );
writeFileSync(indexFile, data, { encoding: 'utf-8', flag: 'w' }); writeFileSync(srcFiles.clientIndexHtml, data, { encoding: 'utf-8', flag: 'w' });
} catch (_error) { } catch (_error) {
/** unhandled */ /** unhandled */
} }
return `/${prefix}`;
} }
+14 -1
View File
@@ -10,6 +10,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.01', note: 'SF1.01',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 36000000, timeStart: 36000000,
@@ -34,6 +35,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.02', note: 'SF1.02',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 37500000, timeStart: 37500000,
@@ -58,6 +60,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.03', note: 'SF1.03',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 39000000, timeStart: 39000000,
@@ -82,6 +85,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.04', note: 'SF1.04',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 40500000, timeStart: 40500000,
@@ -106,6 +110,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.05', note: 'SF1.05',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 42000000, timeStart: 42000000,
@@ -135,6 +140,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.06', note: 'SF1.06',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 47100000, timeStart: 47100000,
@@ -159,6 +165,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.07', note: 'SF1.07',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 48600000, timeStart: 48600000,
@@ -183,6 +190,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.08', note: 'SF1.08',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 50100000, timeStart: 50100000,
@@ -207,6 +215,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.09', note: 'SF1.09',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 51600000, timeStart: 51600000,
@@ -231,6 +240,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.10', note: 'SF1.10',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 53100000, timeStart: 53100000,
@@ -260,6 +270,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.11', note: 'SF1.11',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 56100000, timeStart: 56100000,
@@ -284,9 +295,9 @@ export const demoDb: DatabaseModel = {
note: 'SF1.12', note: 'SF1.12',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 57600000, timeStart: 57600000,
timeEnd: 58800000, timeEnd: 58800000,
duration: 1200000, duration: 1200000,
@@ -309,6 +320,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.13', note: 'SF1.13',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 59100000, timeStart: 59100000,
@@ -333,6 +345,7 @@ export const demoDb: DatabaseModel = {
note: 'SF1.14', note: 'SF1.14',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
linkStart: null, linkStart: null,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
timeStart: 60600000, timeStart: 60600000,
@@ -15,6 +15,7 @@ export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockDuration, timeStrategy: TimeStrategy.LockDuration,
linkStart: null, linkStart: null,
isTimeToEnd: false,
timeStart: 0, timeStart: 0,
timeEnd: 0, timeEnd: 0,
duration: 0, duration: 0,
@@ -175,7 +175,7 @@ describe('getExpectedFinish()', () => {
const state = { const state = {
eventNow: { eventNow: {
timeEnd: 30, timeEnd: 30,
timerType: TimerType.TimeToEnd, isTimeToEnd: true,
}, },
timer: { timer: {
addedTime: 10, addedTime: 10,
@@ -195,7 +195,7 @@ describe('getExpectedFinish()', () => {
const state = { const state = {
eventNow: { eventNow: {
timeEnd: 600000, // 00:10:00 timeEnd: 600000, // 00:10:00
timerType: TimerType.TimeToEnd, isTimeToEnd: true,
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
@@ -351,7 +351,7 @@ describe('getCurrent()', () => {
const state = { const state = {
eventNow: { eventNow: {
timeEnd: 100, timeEnd: 100,
timerType: TimerType.TimeToEnd, isTimeToEnd: true,
}, },
clock: 30, clock: 30,
timer: { timer: {
@@ -376,7 +376,7 @@ describe('getCurrent()', () => {
const state = { const state = {
eventNow: { eventNow: {
timeEnd: 100, timeEnd: 100,
timerType: TimerType.TimeToEnd, isTimeToEnd: true,
}, },
clock: 30, clock: 30,
timer: { timer: {
@@ -401,7 +401,7 @@ describe('getCurrent()', () => {
const state = { const state = {
eventNow: { eventNow: {
timeEnd: 100, timeEnd: 100,
timerType: TimerType.TimeToEnd, isTimeToEnd: true,
}, },
clock: 30, clock: 30,
timer: { timer: {
@@ -427,7 +427,7 @@ describe('getCurrent()', () => {
eventNow: { eventNow: {
timeStart: 79200000, // 22:00:00 timeStart: 79200000, // 22:00:00
timeEnd: 600000, // 00:10:00 timeEnd: 600000, // 00:10:00
timerType: TimerType.TimeToEnd, isTimeToEnd: true,
}, },
clock: 79500000, // 22:05:00 clock: 79500000, // 22:05:00
timer: { timer: {
@@ -456,7 +456,7 @@ describe('getCurrent()', () => {
timeStart: 77400000, // 21:30:00 timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00 timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00 duration: 3600000, // 01:00:00
timerType: TimerType.TimeToEnd, isTimeToEnd: true,
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
@@ -909,7 +909,8 @@ describe('getRuntimeOffset()', () => {
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.TimeToEnd, timerType: TimerType.CountDown,
isTimeToEnd: true,
isPublic: true, isPublic: true,
skip: false, skip: false,
note: '', note: '',
@@ -961,7 +962,8 @@ describe('getRuntimeOffset()', () => {
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.TimeToEnd, timerType: TimerType.CountDown,
isTimeToEnd: true,
isPublic: true, isPublic: true,
skip: false, skip: false,
note: '', note: '',
@@ -1011,7 +1013,8 @@ describe('getRuntimeOffset()', () => {
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.TimeToEnd, // <--- but this is time to end timerType: TimerType.CountDown,
isTimeToEnd: true,
}, },
runtime: { runtime: {
selectedEventIndex: 0, selectedEventIndex: 0,
@@ -1,4 +1,4 @@
import { SimpleDirection, SimpleTimerState } from 'ontime-types'; import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types';
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js'; import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
import { eventStore } from '../../stores/EventStore.js'; import { eventStore } from '../../stores/EventStore.js';
@@ -59,7 +59,11 @@ export class AuxTimerService {
@broadcastReturn @broadcastReturn
addTime(millis: number) { addTime(millis: number) {
return this.timer.setTime(this.timer.state.current + millis); if (this.timer.state.playback === SimplePlayback.Start) {
this.timer.addTime(millis);
return this.timer.update(this.getTime());
}
return this.timer.addTime(millis);
} }
@broadcastReturn @broadcastReturn
@@ -1,12 +1,13 @@
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types'; import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import { copyFile, rename } from 'fs/promises'; import { copyFile } from 'fs/promises';
import { logger } from '../../classes/Logger.js'; import { logger } from '../../classes/Logger.js';
import { publicDir } from '../../setup/index.js'; import { publicDir } from '../../setup/index.js';
import { import {
appendToName, appendToName,
dockerSafeRename,
ensureDirectory, ensureDirectory,
generateUniqueFileName, generateUniqueFileName,
getFileNameFromPath, getFileNameFromPath,
@@ -93,7 +94,7 @@ async function handleCorruptedFile(filePath: string, fileName: string): Promise<
// and make a new file with the recovered data // and make a new file with the recovered data
const newPath = appendToName(filePath, '(recovered)'); const newPath = appendToName(filePath, '(recovered)');
await rename(filePath, newPath); await dockerSafeRename(filePath, newPath);
return getFileNameFromPath(newPath); return getFileNameFromPath(newPath);
} }
@@ -231,7 +232,7 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
} }
const pathToRenamed = getPathToProject(newFilename); const pathToRenamed = getPathToProject(newFilename);
await rename(projectFilePath, pathToRenamed); await dockerSafeRename(projectFilePath, pathToRenamed);
// Update the last loaded project config if current loaded project is the one being renamed // Update the last loaded project config if current loaded project is the one being renamed
const isLoaded = await isLastLoadedProject(originalFile); const isLoaded = await isLastLoadedProject(originalFile);
@@ -1,11 +1,16 @@
import { DatabaseModel, MaybeString, ProjectFile } from 'ontime-types'; import { DatabaseModel, MaybeString, ProjectFile } from 'ontime-types';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { copyFile, readFile, rename, stat } from 'fs/promises'; import { copyFile, readFile, stat } from 'fs/promises';
import { extname, join } from 'path'; import { extname, join } from 'path';
import { publicDir } from '../../setup/index.js'; import { publicDir } from '../../setup/index.js';
import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js'; import {
dockerSafeRename,
ensureDirectory,
getFilesFromFolder,
removeFileExtension,
} from '../../utils/fileManagement.js';
/** /**
* Handles the upload of a new project file * Handles the upload of a new project file
@@ -14,13 +19,13 @@ import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../
*/ */
export async function handleUploaded(filePath: string, name: string) { export async function handleUploaded(filePath: string, name: string) {
const newFilePath = join(publicDir.projectsDir, name); const newFilePath = join(publicDir.projectsDir, name);
await rename(filePath, newFilePath); await dockerSafeRename(filePath, newFilePath);
} }
export async function handleImageUpload(filePath: string, name: string): Promise<string> { export async function handleImageUpload(filePath: string, name: string): Promise<string> {
ensureDirectory(publicDir.logoDir); ensureDirectory(publicDir.logoDir);
const newFilePath = join(publicDir.logoDir, name); const newFilePath = join(publicDir.logoDir, name);
await rename(filePath, newFilePath); await dockerSafeRename(filePath, newFilePath);
return name; return name;
} }
@@ -86,7 +91,7 @@ export async function copyCorruptFile(filePath: string, name: string): Promise<v
*/ */
export async function moveCorruptFile(filePath: string, name: string): Promise<void> { export async function moveCorruptFile(filePath: string, name: string): Promise<void> {
const newPath = join(publicDir.corruptDir, name); const newPath = join(publicDir.corruptDir, name);
return rename(filePath, newPath); return dockerSafeRename(filePath, newPath);
} }
/** /**
@@ -565,6 +565,7 @@ describe('calculateRuntimeDelays', () => {
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
timeStart: 600000, timeStart: 600000,
@@ -591,6 +592,7 @@ describe('calculateRuntimeDelays', () => {
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
timeStart: 1200000, timeStart: 1200000,
@@ -617,6 +619,7 @@ describe('calculateRuntimeDelays', () => {
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
timeStart: 600000, timeStart: 600000,
@@ -643,6 +646,7 @@ describe('calculateRuntimeDelays', () => {
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
timeStart: 1200000, timeStart: 1200000,
@@ -678,6 +682,7 @@ describe('getDelayAt()', () => {
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
timeStart: 600000, timeStart: 600000,
@@ -705,6 +710,7 @@ describe('getDelayAt()', () => {
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
timeStart: 1200000, timeStart: 1200000,
@@ -732,6 +738,7 @@ describe('getDelayAt()', () => {
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
timeStart: 600000, timeStart: 600000,
@@ -759,6 +766,7 @@ describe('getDelayAt()', () => {
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
timeStart: 1200000, timeStart: 1200000,
@@ -812,6 +820,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
timeStart: 600000, timeStart: 600000,
@@ -839,6 +848,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
timeStart: 1200000, timeStart: 1200000,
@@ -866,6 +876,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
timeStart: 600000, timeStart: 600000,
@@ -893,6 +904,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
timeStart: 1200000, timeStart: 1200000,
@@ -31,6 +31,7 @@ describe('cellRequestFromEvent()', () => {
linkStart: null, linkStart: null,
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
duration: 10800000, duration: 10800000,
isPublic: false, isPublic: false,
skip: false, skip: false,
@@ -73,6 +74,7 @@ describe('cellRequestFromEvent()', () => {
timeEnd: 57600000, timeEnd: 57600000,
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
duration: 10800000, duration: 10800000,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
@@ -119,6 +121,7 @@ describe('cellRequestFromEvent()', () => {
timeEnd: 57600000, timeEnd: 57600000,
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
duration: 10800000, duration: 10800000,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
@@ -164,6 +167,7 @@ describe('cellRequestFromEvent()', () => {
timeEnd: 57600000, timeEnd: 57600000,
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
duration: 10800000, duration: 10800000,
@@ -195,6 +199,7 @@ describe('cellRequestFromEvent()', () => {
timeEnd: 57600000, timeEnd: 57600000,
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
duration: 10800000, duration: 10800000,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
@@ -227,6 +232,7 @@ describe('cellRequestFromEvent()', () => {
timeEnd: 57600000, timeEnd: 57600000,
endAction: EndAction.None, endAction: EndAction.None,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
isTimeToEnd: false,
duration: 10800000, duration: 10800000,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, linkStart: null,
+7 -7
View File
@@ -1,4 +1,4 @@
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types'; import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { dayInMs } from 'ontime-utils'; import { dayInMs } from 'ontime-utils';
import { RuntimeState } from '../stores/runtimeState.js'; import { RuntimeState } from '../stores/runtimeState.js';
@@ -19,7 +19,7 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
return null; return null;
} }
const { timerType, timeEnd } = state.eventNow; const { isTimeToEnd, timeEnd } = state.eventNow;
const { pausedAt } = state._timer; const { pausedAt } = state._timer;
const { clock } = state; const { clock } = state;
@@ -33,7 +33,7 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
const pausedTime = pausedAt != null ? clock - pausedAt : 0; const pausedTime = pausedAt != null ? clock - pausedAt : 0;
if (timerType === TimerType.TimeToEnd) { if (isTimeToEnd) {
return timeEnd + addedTime + pausedTime; return timeEnd + addedTime + pausedTime;
} }
@@ -62,11 +62,11 @@ export function getCurrent(state: RuntimeState): number {
} }
} }
const { startedAt, duration, addedTime } = state.timer; const { startedAt, duration, addedTime } = state.timer;
const { timerType, timeStart, timeEnd } = state.eventNow; const { isTimeToEnd, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer; const { pausedAt } = state._timer;
const { clock } = state; const { clock } = state;
if (timerType === TimerType.TimeToEnd) { if (isTimeToEnd) {
const isEventOverMidnight = timeStart > timeEnd; const isEventOverMidnight = timeStart > timeEnd;
const correctDay = isEventOverMidnight ? dayInMs : 0; const correctDay = isEventOverMidnight ? dayInMs : 0;
return correctDay - clock + timeEnd + addedTime; return correctDay - clock + timeEnd + addedTime;
@@ -131,7 +131,7 @@ export function getRuntimeOffset(state: RuntimeState): number {
} }
const { clock } = state; const { clock } = state;
const { timeStart, timerType } = state.eventNow; const { isTimeToEnd, timeStart } = state.eventNow;
const { addedTime, current, startedAt } = state.timer; const { addedTime, current, startedAt } = state.timer;
// if we havent started, but the timer is armed // if we havent started, but the timer is armed
@@ -142,7 +142,7 @@ export function getRuntimeOffset(state: RuntimeState): number {
const overtime = Math.min(current, 0); const overtime = Math.min(current, 0);
// in time-to-end, offset is overtime // in time-to-end, offset is overtime
if (timerType === TimerType.TimeToEnd) { if (isTimeToEnd) {
return overtime; return overtime;
} }
+2
View File
@@ -77,6 +77,8 @@ export const srcDir = {
} as const; } as const;
export const srcFiles = { export const srcFiles = {
/** Path to start index.html */
clientIndexHtml: join(srcDir.clientDir, 'index.html'),
/** Path to bundled CSS */ /** Path to bundled CSS */
cssOverride: join(srcDir.root, config.user, config.styles.directory, config.styles.filename), cssOverride: join(srcDir.root, config.user, config.styles.directory, config.styles.filename),
/** Path to bundled external readme */ /** Path to bundled external readme */
+102 -53
View File
@@ -463,7 +463,7 @@ describe('test event validator', () => {
const event = { const event = {
title: 'test', title: 'test',
}; };
const validated = createEvent(event, 'test'); const validated = createEvent(event, 1);
expect(validated).toEqual( expect(validated).toEqual(
expect.objectContaining({ expect.objectContaining({
@@ -471,12 +471,13 @@ describe('test event validator', () => {
note: expect.any(String), note: expect.any(String),
timeStart: expect.any(Number), timeStart: expect.any(Number),
timeEnd: expect.any(Number), timeEnd: expect.any(Number),
isTimeToEnd: expect.any(Boolean),
isPublic: expect.any(Boolean), isPublic: expect.any(Boolean),
skip: expect.any(Boolean), skip: expect.any(Boolean),
revision: expect.any(Number), revision: expect.any(Number),
type: expect.any(String), type: expect.any(String),
id: expect.any(String), id: expect.any(String),
cue: 'test', cue: '2',
colour: expect.any(String), colour: expect.any(String),
custom: expect.any(Object), custom: expect.any(Object),
}), }),
@@ -485,7 +486,7 @@ describe('test event validator', () => {
it('fails an empty object', () => { it('fails an empty object', () => {
const event = {}; const event = {};
const validated = createEvent(event, 'none'); const validated = createEvent(event, 1);
expect(validated).toEqual(null); expect(validated).toEqual(null);
}); });
@@ -495,7 +496,7 @@ describe('test event validator', () => {
note: '1899-12-30T08:00:10.000Z', note: '1899-12-30T08:00:10.000Z',
}; };
// @ts-expect-error -- we know this is wrong, testing imports outside domain // @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = createEvent(event, 'not-used'); const validated = createEvent(event, 1);
if (validated === null) { if (validated === null) {
throw new Error('unexpected value'); throw new Error('unexpected value');
} }
@@ -774,6 +775,7 @@ describe('getCustomFieldData()', () => {
duration: 'duration', duration: 'duration',
cue: 'cue', cue: 'cue',
title: 'title', title: 'title',
isTimeToEnd: 'time to end',
isPublic: 'public', isPublic: 'public',
skip: 'skip', skip: 'skip',
note: 'notes', note: 'notes',
@@ -824,6 +826,7 @@ describe('getCustomFieldData()', () => {
duration: 'duration', duration: 'duration',
cue: 'cue', cue: 'cue',
title: 'title', title: 'title',
isTimeToEnd: 'time to end',
isPublic: 'public', isPublic: 'public',
skip: 'skip', skip: 'skip',
note: 'notes', note: 'notes',
@@ -883,6 +886,7 @@ describe('parseExcel()', () => {
'Title', 'Title',
'End Action', 'End Action',
'Timer type', 'Timer type',
'Time to end',
'Public', 'Public',
'Skip', 'Skip',
'Notes', 'Notes',
@@ -905,7 +909,8 @@ describe('parseExcel()', () => {
'Guest Welcome', 'Guest Welcome',
'', '',
'', '',
'x', 'x', // <-- time to end
'x', // <-- public
'', '',
'Ballyhoo', 'Ballyhoo',
'a0', 'a0',
@@ -927,7 +932,8 @@ describe('parseExcel()', () => {
'A song from the hearth', 'A song from the hearth',
'load-next', 'load-next',
'clock', 'clock',
'', 'x', // <-- time to end
'', // <-- public
'x', 'x',
'Rainbow chase', 'Rainbow chase',
'b0', 'b0',
@@ -971,6 +977,7 @@ describe('parseExcel()', () => {
timerType: 'count-down', timerType: 'count-down',
endAction: 'none', endAction: 'none',
isPublic: true, isPublic: true,
isTimeToEnd: true,
skip: false, skip: false,
note: 'Ballyhoo', note: 'Ballyhoo',
custom: { custom: {
@@ -994,6 +1001,7 @@ describe('parseExcel()', () => {
timeEnd: 30600000, timeEnd: 30600000,
title: 'A song from the hearth', title: 'A song from the hearth',
timerType: 'clock', timerType: 'clock',
isTimeToEnd: true,
endAction: 'load-next', endAction: 'load-next',
isPublic: false, isPublic: false,
skip: true, skip: true,
@@ -1454,12 +1462,17 @@ describe('parseExcel()', () => {
timeDanger: 'danger time', timeDanger: 'danger time',
custom: {}, custom: {},
}; };
const result = parseExcel(testdata, {}, importMap); const result = parseExcel(testdata, {}, importMap);
expect(result.rundown.length).toBe(2); expect(result.rundown.length).toBe(2);
expect((result.rundown.at(0) as OntimeEvent).type).toBe(SupportedEvent.Event); expect(result.rundown[0]).toMatchObject({
expect((result.rundown.at(0) as OntimeEvent).timerType).toBe(TimerType.CountDown); type: SupportedEvent.Event,
expect((result.rundown.at(1) as OntimeEvent).type).toBe(SupportedEvent.Event); timerType: TimerType.CountDown,
expect((result.rundown.at(1) as OntimeEvent).timerType).toBe(TimerType.CountDown); });
expect(result.rundown[1]).toMatchObject({
type: SupportedEvent.Event,
timerType: TimerType.CountDown,
});
}); });
it('imports as events if timer type is empty or has whitespace', () => { it('imports as events if timer type is empty or has whitespace', () => {
@@ -1659,7 +1672,7 @@ describe('parseExcel()', () => {
expect((events.at(0) as OntimeEvent).colour).toEqual('#F00'); //<--trailing white space in Excel data expect((events.at(0) as OntimeEvent).colour).toEqual('#F00'); //<--trailing white space in Excel data
}); });
it('link start', () => { it('parses link start and checks that is applicable', () => {
const testData = [ const testData = [
[ [
'Time Start', 'Time Start',
@@ -1675,11 +1688,11 @@ describe('parseExcel()', () => {
'Timer type', 'Timer type',
], ],
['4:30:00', '9:45:00', 'A', 'load-next', '', '', 'Rainbow chase', '#F00', 102, '', 'count-down'], ['4:30:00', '9:45:00', 'A', 'load-next', '', '', 'Rainbow chase', '#F00', 102, '', 'count-down'],
['9:45:00', '10:56:00', 'C', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], ['9:45:00', '10:56:00', 'B', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'],
['10:00:00', '16:36:00', 'D', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102, 'x', 'count-down'], //<-- incorrect start times are overridden ['10:00:00', '16:36:00', 'C', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102, 'x', 'count-down'], // <-- incorrect start times are overridden
['21:45:00', '22:56:00', 'E', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, '', 'count-down'], ['21:45:00', '22:56:00', 'D', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, '', 'count-down'],
['', '', 'BLOCK', '', '', '', '', '', '', '', 'block'], ['', '', 'BLOCK', '', '', '', '', '', '', '', 'block'],
['00:0:00', '23:56:00', 'G', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], //<-- link past blocks ['00:0:00', '23:56:00', 'E', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], // <-- link past blocks
[], [],
]; ];
@@ -1709,30 +1722,46 @@ describe('parseExcel()', () => {
const { rundown, order } = cache.get(); const { rundown, order } = cache.get();
const firstId = order.at(0); // A const firstId = order.at(0); // A
const secondId = order.at(1); // C const secondId = order.at(1); // B
const thirdId = order.at(2); // D const thirdId = order.at(2); // C
const fourthId = order.at(3); // E const fourthId = order.at(3); // D
const fifhtId = order.at(4); // Block const fifthId = order.at(4); // Block
const sixthId = order.at(5); // G const sixthId = order.at(5); // E
if (!firstId || !secondId || !thirdId || !fourthId || !fifhtId || !sixthId) { if (!firstId || !secondId || !thirdId || !fourthId || !fifthId || !sixthId) {
throw new Error('Unexpected value'); throw new Error('Unexpected value');
} }
expect((rundown[firstId] as OntimeEvent).timeStart).toEqual(16200000); expect(rundown).toMatchObject({
[firstId]: {
expect((rundown[secondId] as OntimeEvent).timeStart).toEqual((rundown[firstId] as OntimeEvent).timeEnd); title: 'A',
expect((rundown[secondId] as OntimeEvent).linkStart).toEqual((rundown[firstId] as OntimeEvent).id); timeStart: 16200000,
},
expect((rundown[thirdId] as OntimeEvent).timeStart).toEqual((rundown[secondId] as OntimeEvent).timeEnd); [secondId]: {
expect((rundown[thirdId] as OntimeEvent).linkStart).toEqual((rundown[secondId] as OntimeEvent).id); title: 'B',
timeStart: (rundown[firstId] as OntimeEvent).timeEnd,
expect((rundown[fourthId] as OntimeEvent).timeStart).toEqual(78300000); linkStart: (rundown[firstId] as OntimeEvent).id,
},
expect((rundown[fifhtId] as OntimeEvent).type).toEqual(SupportedEvent.Block); [thirdId]: {
title: 'C',
expect((rundown[sixthId] as OntimeEvent).timeStart).toEqual((rundown[fourthId] as OntimeEvent).timeEnd); timeStart: (rundown[secondId] as OntimeEvent).timeEnd,
expect((rundown[sixthId] as OntimeEvent).linkStart).toEqual((rundown[fourthId] as OntimeEvent).id); linkStart: (rundown[secondId] as OntimeEvent).id,
},
[fourthId]: {
title: 'D',
timeStart: 78300000,
linkStart: null,
},
[fifthId]: {
title: 'BLOCK',
type: SupportedEvent.Block,
},
[sixthId]: {
title: 'E',
timeStart: (rundown[fourthId] as OntimeEvent).timeEnd,
linkStart: (rundown[fourthId] as OntimeEvent).id,
},
});
}); });
it('#971 BUG: parses time fields and booleans', () => { it('#971 BUG: parses time fields and booleans', () => {
@@ -1762,7 +1791,7 @@ describe('parseExcel()', () => {
'false', 'false',
'Setup', 'Setup',
'', '',
'time-to-end', 'count-down',
'none', 'none',
'15', '15',
'00:05:00', '00:05:00',
@@ -1778,7 +1807,7 @@ describe('parseExcel()', () => {
'false', 'false',
'Meeting 1', 'Meeting 1',
'', '',
'time-to-end', 'count-down',
'none', 'none',
15, 15,
'00:05:00', '00:05:00',
@@ -1794,7 +1823,7 @@ describe('parseExcel()', () => {
'false', 'false',
'Meeting 2', 'Meeting 2',
'', '',
'time-to-end', 'count-down',
'none', 'none',
'13', '13',
'5', '5',
@@ -1810,7 +1839,7 @@ describe('parseExcel()', () => {
'true', 'true',
'Lunch', 'Lunch',
'', '',
'time-to-end', 'count-down',
'none', 'none',
13, 13,
5, 5,
@@ -1839,26 +1868,46 @@ describe('parseExcel()', () => {
const parsedData = parseExcel(testData, {}); const parsedData = parseExcel(testData, {});
const { rundown } = parsedData; const { rundown } = parsedData;
// '15' as a string is parsed by smart time entry as minutes
expect(rundown[0]).toMatchObject({
cue: 'SETUP',
timeWarning: 15 * MILLIS_PER_MINUTE,
});
// elements in bug report // elements in bug report
// 15 is a number, in which case we parse it as a minutes value // 15 is a number, in which case we parse it as a minutes value
expect((rundown.at(1) as OntimeEvent).timeWarning).toBe(15 * MILLIS_PER_MINUTE); expect(rundown[1]).toMatchObject({
cue: 'MEET1',
timeWarning: 15 * MILLIS_PER_MINUTE,
});
// in the case where a string is passed, we need to check whether it is an ISO 8601 date // in the case where a string is passed, we need to check whether it is an ISO 8601 date
expect((rundown.at(2) as OntimeEvent).duration).toBe(60 * MILLIS_PER_MINUTE); expect(rundown[2]).toMatchObject({
expect((rundown.at(2) as OntimeEvent).timeDanger).toBe(5 * MILLIS_PER_MINUTE); cue: 'MEET2',
duration: 60 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
expect((rundown.at(3) as OntimeEvent).timeWarning).toBe(13 * MILLIS_PER_MINUTE); expect(rundown[3]).toMatchObject({
expect((rundown.at(3) as OntimeEvent).timeDanger).toBe(5 * MILLIS_PER_MINUTE); cue: 'lunch',
timeWarning: 13 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
expect((rundown.at(4) as OntimeEvent).duration).toBe(90 * MILLIS_PER_MINUTE); expect(rundown[4]).toMatchObject({
expect((rundown.at(4) as OntimeEvent).linkStart).toBe(false); cue: 'MEET3',
expect((rundown.at(4) as OntimeEvent).timeWarning).toBe(11 * MILLIS_PER_MINUTE); duration: 90 * MILLIS_PER_MINUTE,
expect((rundown.at(4) as OntimeEvent).timeDanger).toBe(5 * MILLIS_PER_MINUTE); linkStart: false,
timeWarning: 11 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
expect((rundown.at(5) as OntimeEvent).duration).toBe(30 * MILLIS_PER_MINUTE); expect(rundown[5]).toMatchObject({
cue: 'MEET4',
// if we get a boolean, we should just use that duration: 30 * MILLIS_PER_MINUTE,
expect((rundown.at(5) as OntimeEvent).linkStart).toBe(true); timeWarning: 11 * MILLIS_PER_MINUTE,
expect((rundown.at(5) as OntimeEvent).timeWarning).toBe(11 * MILLIS_PER_MINUTE); // if we get a boolean, we should just use that
linkStart: true,
});
}); });
}); });
@@ -405,28 +405,6 @@ describe('sanitiseCustomFields()', () => {
}); });
describe('parseRundown() linking', () => { describe('parseRundown() linking', () => {
const blankEvent: OntimeEvent = {
id: '',
type: SupportedEvent.Event,
cue: '',
title: '',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockDuration,
timeStart: 0,
timeEnd: 0,
duration: 0,
isPublic: false,
skip: false,
colour: '',
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
};
it('returns linked events', () => { it('returns linked events', () => {
const data: Partial<DatabaseModel> = { const data: Partial<DatabaseModel> = {
rundown: [ rundown: [
@@ -445,12 +423,11 @@ describe('parseRundown() linking', () => {
customFields: {}, customFields: {},
}; };
const expected: OntimeRundown = [
{ ...blankEvent, id: '1', cue: '0' },
{ ...blankEvent, id: '2', cue: '1', linkStart: '1' },
];
const result = parseRundown(data); const result = parseRundown(data);
expect(result.rundown).toEqual(expected); expect(result.rundown[1]).toMatchObject({
id: '2',
linkStart: '1',
});
}); });
it('returns unlinked if no previous', () => { it('returns unlinked if no previous', () => {
@@ -466,9 +443,11 @@ describe('parseRundown() linking', () => {
customFields: {}, customFields: {},
}; };
const expected: OntimeRundown = [{ ...blankEvent, id: '2', cue: '0' }];
const result = parseRundown(data); const result = parseRundown(data);
expect(result.rundown).toEqual(expected); expect(result.rundown[0]).toMatchObject({
id: '2',
linkStart: null,
});
}); });
it('returns linked events past blocks and delays', () => { it('returns linked events past blocks and delays', () => {
@@ -505,14 +484,65 @@ describe('parseRundown() linking', () => {
customFields: {}, customFields: {},
}; };
const expected: OntimeRundown = [
{ ...blankEvent, id: '1', cue: '0' },
{ id: 'delay1', type: SupportedEvent.Delay, duration: 0 },
{ ...blankEvent, id: '2', cue: '1', linkStart: '1' },
{ id: 'block1', type: SupportedEvent.Block, title: '' },
{ ...blankEvent, id: '3', cue: '2', linkStart: '2' },
];
const result = parseRundown(data); const result = parseRundown(data);
expect(result.rundown).toEqual(expected); expect(result.rundown[0]).toMatchObject({
id: '1',
cue: '1',
});
// skip delay
expect(result.rundown[2]).toMatchObject({
id: '2',
cue: '2',
linkStart: '1',
});
// skip block
expect(result.rundown[4]).toMatchObject({
id: '3',
cue: '3',
linkStart: '2',
});
});
});
describe('parseRundown() migrations', () => {
const legacyEvent = {
id: '1',
type: SupportedEvent.Event,
cue: '',
title: '',
note: '',
endAction: EndAction.None,
timerType: 'time-to-end',
linkStart: null,
timeStrategy: TimeStrategy.LockDuration,
timeStart: 0,
timeEnd: 0,
duration: 0,
isPublic: false,
skip: false,
colour: '',
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
};
it('migrates an event with time-to-end', () => {
const result = parseRundown({ rundown: [legacyEvent] as OntimeRundown });
expect(result.rundown[0]).toMatchObject({
id: '1',
timerType: TimerType.CountDown,
isTimeToEnd: true,
});
});
it('migrates an event without time-to-end', () => {
const countdownEvent = { ...legacyEvent, timerType: TimerType.CountDown };
const result = parseRundown({ rundown: [countdownEvent] as OntimeRundown });
expect(result.rundown[0]).toMatchObject({
id: '1',
timerType: TimerType.CountDown,
isTimeToEnd: false,
});
}); });
}); });
@@ -1,33 +0,0 @@
import { cleanURL } from '../url.js';
describe('url is correctly formatted', () => {
it('has no leading spaces', () => {
const test = ' http://testing';
const expected = 'http://testing';
expect(cleanURL(test)).toBe(expected);
});
it('has no trailing spaces', () => {
const test = 'http://testing ';
const expected = 'http://testing';
expect(cleanURL(test)).toBe(expected);
});
it('doesnt contain spaces', () => {
const test = 'http://t e s t i n g';
const expected = 'http://t%20e%20s%20t%20i%20n%20g';
expect(cleanURL(test)).toBe(expected);
});
it('only contains allowed characters', () => {
const test = 'http://<>[]{}|^';
const expected = 'http://';
expect(cleanURL(test)).toBe(expected);
});
it('begins with http://', () => {
const test = 'ontime.com';
const expected = 'http://ontime.com';
expect(cleanURL(test)).toBe(expected);
});
});
+11 -2
View File
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync } from 'fs'; import { existsSync, mkdirSync, PathLike } from 'fs';
import { readdir, copyFile } from 'fs/promises'; import { readdir, copyFile, unlink } from 'fs/promises';
import { basename, extname, join, parse } from 'path'; import { basename, extname, join, parse } from 'path';
/** /**
@@ -105,3 +105,12 @@ export async function copyDirectory(src: string, dest: string) {
} }
} }
} }
/**
* workaround avoids origin errors in docker deployments
* EXDEV cross-device link not permitted
*/
export async function dockerSafeRename(oldPath: PathLike, newPath: PathLike) {
await copyFile(oldPath, newPath);
await unlink(oldPath);
}

Some files were not shown because too many files have changed in this diff Show More