mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 14:39:06 +00:00
Merge branch 'master' into port-colision
This commit is contained in:
@@ -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
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.8.0",
|
"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
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.8.0",
|
"version": "3.9.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
"@fontsource/open-sans": "^5.0.28",
|
"@fontsource/open-sans": "^5.0.28",
|
||||||
"@mantine/hooks": "^7.13.3",
|
"@mantine/hooks": "^7.13.3",
|
||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
"@sentry/react": "^8.19.0",
|
"@sentry/react": "^8.43.0",
|
||||||
"@tanstack/react-query": "^5.17.9",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
"@tanstack/react-query-devtools": "^5.17.9",
|
"@tanstack/react-query-devtools": "^5.62.7",
|
||||||
"@tanstack/react-table": "^8.11.3",
|
"@tanstack/react-table": "^8.20.5",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.2.0",
|
"axios": "^1.2.0",
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
@@ -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",
|
||||||
@@ -92,4 +92,4 @@
|
|||||||
"vite-tsconfig-paths": "^4.3.1",
|
"vite-tsconfig-paths": "^4.3.1",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
.drawerFooter {
|
.drawerFooter {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
gap: $section-spacing;
|
gap: $section-spacing;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -8,6 +8,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infoLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $element-spacing;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
background-color: $gray-1100;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: $inner-section-text-size;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: $info-blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
DrawerOverlay,
|
DrawerOverlay,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
|
||||||
|
|
||||||
|
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||||
|
|
||||||
import ParamInput from './ParamInput';
|
import ParamInput from './ParamInput';
|
||||||
import { isSection, ViewOption } from './types';
|
import { isSection, ViewOption } from './types';
|
||||||
@@ -87,6 +90,8 @@ interface EditFormDrawerProps {
|
|||||||
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
|
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
|
||||||
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { data: viewSettings } = useViewSettings();
|
||||||
|
|
||||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,8 +120,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 (
|
||||||
@@ -129,6 +132,12 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
|||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<DrawerBody>
|
<DrawerBody>
|
||||||
|
{viewSettings.overrideStyles && (
|
||||||
|
<div className={style.infoLabel}>
|
||||||
|
<IoAlertCircle />
|
||||||
|
This view style is being modified by a custom CSS file. <br />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
||||||
{viewOptions.map((option) => {
|
{viewOptions.map((option) => {
|
||||||
if (isSection(option)) {
|
if (isSection(option)) {
|
||||||
|
|||||||
@@ -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]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -238,3 +238,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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}`);
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { Tooltip } from '@chakra-ui/react';
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
import { Playback, TimerPhase } from 'ontime-types';
|
import { Playback, TimerPhase } from 'ontime-types';
|
||||||
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
|
import { dayInMs, millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||||
|
import { formatDuration } from '../../../../common/utils/time';
|
||||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||||
|
|
||||||
import style from './PlaybackTimer.module.scss';
|
import style from './PlaybackTimer.module.scss';
|
||||||
@@ -13,22 +14,12 @@ interface PlaybackTimerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveAddedTimeLabel(addedTime: number) {
|
function resolveAddedTimeLabel(addedTime: number) {
|
||||||
function resolveClosestUnit(ms: number) {
|
|
||||||
if (ms < 6000) {
|
|
||||||
return `${millisToSeconds(ms)} seconds`;
|
|
||||||
} else if (ms < 12000) {
|
|
||||||
return '1 minute';
|
|
||||||
} else {
|
|
||||||
return `${millisToMinutes(ms)} minutes`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addedTime > 0) {
|
if (addedTime > 0) {
|
||||||
return `Added ${resolveClosestUnit(addedTime)}`;
|
return `Added ${formatDuration(addedTime, false)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addedTime < 0) {
|
if (addedTime < 0) {
|
||||||
return `Removed ${resolveClosestUnit(addedTime)}`;
|
return `Removed ${formatDuration(Math.abs(addedTime), false)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -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,3 +0,0 @@
|
|||||||
.progressOverride {
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { ChangeEvent, memo, useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { getHotkeyHandler } from '@mantine/hooks';
|
|
||||||
|
|
||||||
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
|
|
||||||
|
|
||||||
interface EditableCellProps {
|
|
||||||
value: string;
|
|
||||||
handleUpdate: (newValue: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EditableCell = (props: EditableCellProps) => {
|
|
||||||
const { value: initialValue, handleUpdate } = props;
|
|
||||||
|
|
||||||
// We need to keep and update the state of the cell normally
|
|
||||||
const [value, setValue] = useState(initialValue);
|
|
||||||
const ref = useRef<HTMLAreaElement>();
|
|
||||||
const onChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => setValue(event.target.value), []);
|
|
||||||
|
|
||||||
// We'll only update the external data when the input is blurred
|
|
||||||
const onBlur = useCallback(() => handleUpdate(value), [handleUpdate, value]);
|
|
||||||
|
|
||||||
//TODO: maybe we can unify this with `useReactiveTextInput`
|
|
||||||
const onKeyDown = getHotkeyHandler([
|
|
||||||
['mod + Enter', () => ref.current?.blur()],
|
|
||||||
[
|
|
||||||
'Escape',
|
|
||||||
() => {
|
|
||||||
setValue(initialValue);
|
|
||||||
setTimeout(() => ref.current?.blur());
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If the initialValue is changed external, sync it up with our state
|
|
||||||
useEffect(() => {
|
|
||||||
setValue(initialValue);
|
|
||||||
}, [initialValue]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AutoTextArea
|
|
||||||
size='sm'
|
|
||||||
value={value}
|
|
||||||
inputref={ref}
|
|
||||||
onChange={onChange}
|
|
||||||
onBlur={onBlur}
|
|
||||||
rows={1}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
transition='none'
|
|
||||||
spellCheck={false}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(EditableCell);
|
|
||||||
-120
@@ -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);
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
|
||||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
|
||||||
import RunningTime from '../viewers/common/running-time/RunningTime';
|
|
||||||
|
|
||||||
import EditableCell from './cuesheet-table-elements/EditableCell';
|
|
||||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
|
||||||
|
|
||||||
import style from './Cuesheet.module.scss';
|
|
||||||
|
|
||||||
function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
|
|
||||||
const cellValue = row.getValue();
|
|
||||||
return cellValue ? <IoCheckmark className={style.check} /> : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
|
||||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
|
||||||
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
|
|
||||||
const cellValue = (getValue() as number | null) ?? 0;
|
|
||||||
const delayValue = (original as OntimeEvent)?.delay ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={style.time}>
|
|
||||||
<DelayIndicator delayValue={delayValue} />
|
|
||||||
<RunningTime value={cellValue} hideSeconds={hideSeconds} />
|
|
||||||
{delayValue !== 0 && showDelayedTimes && (
|
|
||||||
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideSeconds} />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
|
|
||||||
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
|
|
||||||
const cellValue = (getValue() as number | null) ?? 0;
|
|
||||||
|
|
||||||
return <RunningTime value={cellValue} hideSeconds={hideSeconds} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
|
||||||
const update = useCallback(
|
|
||||||
(newValue: string) => {
|
|
||||||
// @ts-expect-error -- we inject this into react-table
|
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue);
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
|
||||||
[column.id, row.index],
|
|
||||||
);
|
|
||||||
|
|
||||||
const event = row.original;
|
|
||||||
if (!isOntimeEvent(event)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// events dont necessarily contain all custom fields
|
|
||||||
const initialValue = event.custom[column.id] ?? '';
|
|
||||||
|
|
||||||
return <EditableCell value={initialValue} handleUpdate={update} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
|
|
||||||
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
|
|
||||||
accessorKey: key,
|
|
||||||
id: key,
|
|
||||||
header: customFields[key].label,
|
|
||||||
meta: { colour: customFields[key].colour },
|
|
||||||
cell: MakeCustomField,
|
|
||||||
size: 250,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
accessorKey: 'cue',
|
|
||||||
id: 'cue',
|
|
||||||
header: 'Cue',
|
|
||||||
cell: (row) => row.getValue(),
|
|
||||||
size: 75,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'isPublic',
|
|
||||||
id: 'isPublic',
|
|
||||||
header: 'Public',
|
|
||||||
cell: makePublic,
|
|
||||||
size: 45,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'timeStart',
|
|
||||||
id: 'timeStart',
|
|
||||||
header: 'Start',
|
|
||||||
cell: MakeTimer,
|
|
||||||
size: 75,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'timeEnd',
|
|
||||||
id: 'timeEnd',
|
|
||||||
header: 'End',
|
|
||||||
cell: MakeTimer,
|
|
||||||
size: 75,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'duration',
|
|
||||||
id: 'duration',
|
|
||||||
header: 'Duration',
|
|
||||||
cell: MakeDuration,
|
|
||||||
size: 75,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'title',
|
|
||||||
id: 'title',
|
|
||||||
header: 'Title',
|
|
||||||
cell: (row) => row.getValue(),
|
|
||||||
size: 250,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'note',
|
|
||||||
id: 'note',
|
|
||||||
header: 'Note',
|
|
||||||
cell: (row) => row.getValue(),
|
|
||||||
size: 250,
|
|
||||||
},
|
|
||||||
...dynamicCustomFields,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description set default column order
|
|
||||||
*/
|
|
||||||
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
|
|
||||||
'isPublic',
|
|
||||||
'cue',
|
|
||||||
'timeStart',
|
|
||||||
'timeEnd',
|
|
||||||
'duration',
|
|
||||||
'title',
|
|
||||||
'note',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description set default hidden columns
|
|
||||||
*/
|
|
||||||
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [];
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
|
|
||||||
|
|
||||||
interface CuesheetSettings {
|
|
||||||
showSettings: boolean;
|
|
||||||
showIndexColumn: boolean;
|
|
||||||
followSelected: boolean;
|
|
||||||
showPrevious: boolean;
|
|
||||||
showDelayBlock: boolean;
|
|
||||||
showDelayedTimes: boolean;
|
|
||||||
hideSeconds: boolean;
|
|
||||||
|
|
||||||
toggleSettings: (newValue?: boolean) => void;
|
|
||||||
toggleFollow: (newValue?: boolean) => void;
|
|
||||||
togglePreviousVisibility: (newValue?: boolean) => void;
|
|
||||||
toggleIndexColumn: (newValue?: boolean) => void;
|
|
||||||
toggleDelayVisibility: (newValue?: boolean) => void;
|
|
||||||
toggleDelayedTimes: (newValue?: boolean) => void;
|
|
||||||
toggleSecondsVisibility: (newValue?: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(oldValue: boolean, value?: boolean) {
|
|
||||||
if (typeof value === 'undefined') {
|
|
||||||
return !oldValue;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CuesheetKeys {
|
|
||||||
Follow = 'ontime-cuesheet-follow-selected',
|
|
||||||
DelayVisibility = 'ontime-cuesheet-show-delay',
|
|
||||||
PreviousVisibility = 'ontime-cuesheet-show-previous',
|
|
||||||
ColumnIndex = 'ontime-cuesheet-show-index-column',
|
|
||||||
DelayedTimes = 'ontime-cuesheet-show-delayed',
|
|
||||||
Seconds = 'ontime-cuesheet-hide-sceconds',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
|
||||||
showSettings: false,
|
|
||||||
showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true),
|
|
||||||
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
|
|
||||||
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
|
|
||||||
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
|
|
||||||
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
|
|
||||||
hideSeconds: booleanFromLocalStorage(CuesheetKeys.Seconds, false),
|
|
||||||
|
|
||||||
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
|
|
||||||
toggleFollow: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const followSelected = toggle(state.followSelected, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.Follow, String(followSelected));
|
|
||||||
return { followSelected };
|
|
||||||
}),
|
|
||||||
toggleIndexColumn: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const showIndexColumn = toggle(state.showIndexColumn, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.ColumnIndex, String(showIndexColumn));
|
|
||||||
return { showIndexColumn };
|
|
||||||
}),
|
|
||||||
togglePreviousVisibility: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const showPrevious = toggle(state.showPrevious, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.PreviousVisibility, String(showPrevious));
|
|
||||||
return { showPrevious };
|
|
||||||
}),
|
|
||||||
toggleDelayVisibility: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const showDelayBlock = toggle(state.showDelayBlock, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.DelayVisibility, String(showDelayBlock));
|
|
||||||
return { showDelayBlock };
|
|
||||||
}),
|
|
||||||
toggleDelayedTimes: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const showDelayedTimes = toggle(state.showDelayedTimes, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
|
|
||||||
return { showDelayedTimes };
|
|
||||||
}),
|
|
||||||
toggleSecondsVisibility: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const hideSeconds = toggle(state.hideSeconds, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.Seconds, String(hideSeconds));
|
|
||||||
return { hideSeconds };
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { KeyboardEvent, useState } from 'react';
|
import { KeyboardEvent, useState } from 'react';
|
||||||
import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react';
|
import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react';
|
||||||
import { useDebouncedCallback } from '@mantine/hooks';
|
import { useDebouncedCallback } from '@mantine/hooks';
|
||||||
import { isOntimeEvent, SupportedEvent } from 'ontime-types';
|
import { SupportedEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { useEventSelection } from '../../rundown/useEventSelection';
|
import { useEventSelection } from '../../rundown/useEventSelection';
|
||||||
|
|
||||||
@@ -65,14 +65,15 @@ export default function Finder(props: FinderProps) {
|
|||||||
{error && <li className={style.error}>{error}</li>}
|
{error && <li className={style.error}>{error}</li>}
|
||||||
{results.length === 0 && <li className={style.empty}>No results</li>}
|
{results.length === 0 && <li className={style.empty}>No results</li>}
|
||||||
{results.length > 0 &&
|
{results.length > 0 &&
|
||||||
results.map((event, index) => {
|
results.map((entry, index) => {
|
||||||
const isSelected = selected === index;
|
const isSelected = selected === index;
|
||||||
const displayIndex = event.type === SupportedEvent.Block ? '-' : event.index;
|
const displayIndex = entry.type === SupportedEvent.Event ? entry.eventIndex : '-';
|
||||||
const colour = event.type === SupportedEvent.Event ? event.colour : '';
|
const displayCue = entry.type === SupportedEvent.Event ? entry.cue : '';
|
||||||
|
const colour = entry.type === SupportedEvent.Event ? entry.colour : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={event.id}
|
key={entry.id}
|
||||||
className={style.entry}
|
className={style.entry}
|
||||||
data-selected={isSelected}
|
data-selected={isSelected}
|
||||||
data-index={index}
|
data-index={index}
|
||||||
@@ -82,8 +83,8 @@ export default function Finder(props: FinderProps) {
|
|||||||
<div className={style.index} style={{ '--color': colour }}>
|
<div className={style.index} style={{ '--color': colour }}>
|
||||||
{displayIndex}
|
{displayIndex}
|
||||||
</div>
|
</div>
|
||||||
{isOntimeEvent(event) && <div className={style.cue}>{event.cue}</div>}
|
<div className={style.cue}>{displayCue}</div>
|
||||||
<div className={style.title}>{event.title}</div>
|
<div className={style.title}>{entry.title}</div>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && <span>Go ⏎</span>}
|
{isSelected && <span>Go ⏎</span>}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeEvent, useState } from 'react';
|
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||||
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEvent } from 'ontime-types';
|
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
||||||
@@ -28,6 +28,17 @@ export default function useFinder() {
|
|||||||
const { data } = useFlatRundown();
|
const { data } = useFlatRundown();
|
||||||
const [results, setResults] = useState<FilterableEntry[]>([]);
|
const [results, setResults] = useState<FilterableEntry[]>([]);
|
||||||
const [error, setError] = useState<MaybeString>(null);
|
const [error, setError] = useState<MaybeString>(null);
|
||||||
|
const lastSearchString = useRef('');
|
||||||
|
|
||||||
|
/** clear results when source data changes */
|
||||||
|
useEffect(() => {
|
||||||
|
setResults([]);
|
||||||
|
setError(null);
|
||||||
|
// fake a submit event to re-run the search
|
||||||
|
if (lastSearchString.current) {
|
||||||
|
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
/** Returns a single item with a matching index */
|
/** Returns a single item with a matching index */
|
||||||
const searchByIndex = (searchString: string) => {
|
const searchByIndex = (searchString: string) => {
|
||||||
@@ -155,6 +166,7 @@ export default function useFinder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchValue = event.target.value.toLowerCase();
|
const searchValue = event.target.value.toLowerCase();
|
||||||
|
lastSearchString.current = searchValue;
|
||||||
|
|
||||||
if (searchValue.startsWith('index ')) {
|
if (searchValue.startsWith('index ')) {
|
||||||
const searchString = searchValue.replace('index ', '').trim();
|
const searchString = searchValue.replace('index ', '').trim();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -256,10 +256,12 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
|
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastEntry: PlayableEvent | undefined; // used by indicators
|
// last event is used to calculate relative timings
|
||||||
let thisEntry: PlayableEvent | undefined;
|
let lastEvent: PlayableEvent | undefined; // used by indicators
|
||||||
let previousEventId: string | undefined;
|
let thisEvent: PlayableEvent | undefined;
|
||||||
let thisId = previousEventId;
|
// previous entry is used to infer position in the rundown for new events
|
||||||
|
let previousEntryId: string | undefined;
|
||||||
|
let thisId = previousEntryId;
|
||||||
|
|
||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
// all events before the current selected are in the past
|
// all events before the current selected are in the past
|
||||||
@@ -272,63 +274,64 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
||||||
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
|
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
|
||||||
<div className={style.list}>
|
<div className={style.list}>
|
||||||
{statefulEntries.map((eventId, index) => {
|
{statefulEntries.map((entryId, index) => {
|
||||||
// we iterate through a stateful copy of order to make the operations smoother
|
// we iterate through a stateful copy of order to make the operations smoother
|
||||||
// this means that this can be out of sync with order until the useEffect runs
|
// this means that this can be out of sync with order until the useEffect runs
|
||||||
// instead of writing all the logic guards, we simply short circuit rendering here
|
// instead of writing all the logic guards, we simply short circuit rendering here
|
||||||
const event = rundown[eventId];
|
const entry = rundown[entryId];
|
||||||
if (!event) {
|
if (!entry) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
eventIndex = 0;
|
eventIndex = 0;
|
||||||
}
|
}
|
||||||
if (isOntimeEvent(event)) {
|
previousEntryId = thisId;
|
||||||
|
thisId = entryId;
|
||||||
|
if (isOntimeEvent(entry)) {
|
||||||
// event indexes are 1 based in frontend
|
// event indexes are 1 based in frontend
|
||||||
eventIndex++;
|
eventIndex++;
|
||||||
previousEventId = thisId;
|
lastEvent = thisEvent;
|
||||||
lastEntry = thisEntry;
|
|
||||||
|
|
||||||
if (isPlayableEvent(event)) {
|
if (isPlayableEvent(entry)) {
|
||||||
// populate previous entry
|
// populate previous entry
|
||||||
if (isNewLatest(event.timeStart, event.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
|
if (isNewLatest(entry.timeStart, entry.timeEnd, lastEvent?.timeStart, lastEvent?.timeEnd)) {
|
||||||
thisEntry = event;
|
thisEvent = entry;
|
||||||
}
|
}
|
||||||
thisId = eventId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const isFirst = index === 0;
|
const isFirst = index === 0;
|
||||||
const isLast = index === order.length - 1;
|
const isLast = index === order.length - 1;
|
||||||
const isLoaded = featureData?.selectedEventId === event.id;
|
const isLoaded = featureData?.selectedEventId === entry.id;
|
||||||
const isNext = featureData?.nextEventId === event.id;
|
const isNext = featureData?.nextEventId === entry.id;
|
||||||
const hasCursor = event.id === cursor;
|
const hasCursor = entry.id === cursor;
|
||||||
if (isLoaded) {
|
if (isLoaded) {
|
||||||
isPast = false;
|
isPast = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={event.id}>
|
<Fragment key={entry.id}>
|
||||||
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEventId} />}
|
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEntryId} />}
|
||||||
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
|
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
|
||||||
{isOntimeEvent(event) && <div className={style.entryIndex}>{eventIndex}</div>}
|
{isOntimeEvent(entry) && <div className={style.entryIndex}>{eventIndex}</div>}
|
||||||
<div className={style.entry} key={event.id} ref={hasCursor ? cursorRef : undefined}>
|
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||||
<RundownEntry
|
<RundownEntry
|
||||||
type={event.type}
|
type={entry.type}
|
||||||
isPast={isPast}
|
isPast={isPast}
|
||||||
eventIndex={eventIndex}
|
eventIndex={eventIndex}
|
||||||
data={event}
|
data={entry}
|
||||||
loaded={isLoaded}
|
loaded={isLoaded}
|
||||||
hasCursor={hasCursor}
|
hasCursor={hasCursor}
|
||||||
isNext={isNext}
|
isNext={isNext}
|
||||||
previousStart={lastEntry?.timeStart}
|
previousStart={lastEvent?.timeStart}
|
||||||
previousEnd={lastEntry?.timeEnd}
|
previousEnd={lastEvent?.timeEnd}
|
||||||
previousEventId={previousEventId}
|
previousEntryId={previousEntryId}
|
||||||
|
previousEventId={lastEvent?.id}
|
||||||
playback={isLoaded ? featureData.playback : undefined}
|
playback={isLoaded ? featureData.playback : undefined}
|
||||||
isRolling={featureData.playback === Playback.Roll}
|
isRolling={featureData.playback === Playback.Roll}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={event.id} />}
|
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={entry.id} />}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface RundownEntryProps {
|
|||||||
isNext: boolean;
|
isNext: boolean;
|
||||||
previousStart?: number;
|
previousStart?: number;
|
||||||
previousEnd?: number;
|
previousEnd?: number;
|
||||||
|
previousEntryId?: string;
|
||||||
previousEventId?: string;
|
previousEventId?: string;
|
||||||
playback?: Playback; // we only care about this if this event is playing
|
playback?: Playback; // we only care about this if this event is playing
|
||||||
isRolling: boolean; // we need to know even if not related to this event
|
isRolling: boolean; // we need to know even if not related to this event
|
||||||
@@ -48,6 +49,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
isNext,
|
isNext,
|
||||||
previousStart,
|
previousStart,
|
||||||
previousEnd,
|
previousEnd,
|
||||||
|
previousEntryId,
|
||||||
previousEventId,
|
previousEventId,
|
||||||
playback,
|
playback,
|
||||||
isRolling,
|
isRolling,
|
||||||
@@ -84,7 +86,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
case 'event-before': {
|
case 'event-before': {
|
||||||
const newEvent = { type: SupportedEvent.Event };
|
const newEvent = { type: SupportedEvent.Event };
|
||||||
const options = {
|
const options = {
|
||||||
after: previousEventId,
|
after: previousEntryId,
|
||||||
};
|
};
|
||||||
return addEvent(newEvent, options);
|
return addEvent(newEvent, options);
|
||||||
}
|
}
|
||||||
@@ -92,13 +94,13 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
|
return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
|
||||||
}
|
}
|
||||||
case 'delay-before': {
|
case 'delay-before': {
|
||||||
return addEvent({ type: SupportedEvent.Delay }, { after: previousEventId });
|
return addEvent({ type: SupportedEvent.Delay }, { after: previousEntryId });
|
||||||
}
|
}
|
||||||
case 'block': {
|
case 'block': {
|
||||||
return addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
return addEvent({ type: SupportedEvent.Block }, { after: data.id });
|
||||||
}
|
}
|
||||||
case 'block-before': {
|
case 'block-before': {
|
||||||
return addEvent({ type: SupportedEvent.Block }, { after: previousEventId });
|
return addEvent({ type: SupportedEvent.Block }, { after: previousEntryId });
|
||||||
}
|
}
|
||||||
case 'swap': {
|
case 'swap': {
|
||||||
const { value } = payload as FieldValue;
|
const { value } = payload as FieldValue;
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a string to semantically verify if it represents a true value
|
||||||
|
* Used in the context of parsing search params and local storage items which can be strings or null
|
||||||
|
*/
|
||||||
export function isStringBoolean(text: string | null) {
|
export function isStringBoolean(text: string | null) {
|
||||||
if (text === null) {
|
if (text === null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ $action-text-color: $blue-400;
|
|||||||
$ontime-color: #ff7597;
|
$ontime-color: #ff7597;
|
||||||
$error-red: $red-500;
|
$error-red: $red-500;
|
||||||
$warning-orange: $orange-500;
|
$warning-orange: $orange-500;
|
||||||
|
$info-blue: $blue-500;
|
||||||
$opacity-disabled: 0.4;
|
$opacity-disabled: 0.4;
|
||||||
$active-red: $red-700;
|
$active-red: $red-700;
|
||||||
|
|
||||||
@@ -57,6 +58,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;
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ export const ontimeInputGhosted = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ontimeInputTransparent = {
|
||||||
|
field: {
|
||||||
|
...commonStyles,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
_hover: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.10)', // $white-10
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const ontimeTextAreaFilled = {
|
export const ontimeTextAreaFilled = {
|
||||||
...commonStyles,
|
...commonStyles,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { ontimeTab } from './ontimeTab';
|
|||||||
import {
|
import {
|
||||||
ontimeInputFilled,
|
ontimeInputFilled,
|
||||||
ontimeInputGhosted,
|
ontimeInputGhosted,
|
||||||
|
ontimeInputTransparent,
|
||||||
ontimeTextAreaFilled,
|
ontimeTextAreaFilled,
|
||||||
ontimeTextAreaTransparent,
|
ontimeTextAreaTransparent,
|
||||||
} from './ontimeTextInputs';
|
} from './ontimeTextInputs';
|
||||||
@@ -78,6 +79,7 @@ const theme = extendTheme({
|
|||||||
variants: {
|
variants: {
|
||||||
'ontime-filled': { ...ontimeInputFilled },
|
'ontime-filled': { ...ontimeInputFilled },
|
||||||
'ontime-ghosted': { ...ontimeInputGhosted },
|
'ontime-ghosted': { ...ontimeInputGhosted },
|
||||||
|
'ontime-transparent': { ...ontimeInputTransparent },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Kbd: {
|
Kbd: {
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ export const TranslationContext = createContext<TranslationContextValue>({
|
|||||||
export const TranslationProvider = ({ children }: PropsWithChildren) => {
|
export const TranslationProvider = ({ children }: PropsWithChildren) => {
|
||||||
const { data } = useSettings();
|
const { data } = useSettings();
|
||||||
|
|
||||||
console.log('....', data.language)
|
|
||||||
|
|
||||||
const getLocalizedString = useCallback(
|
const getLocalizedString = useCallback(
|
||||||
(key: keyof typeof langEn, lang = data?.language || 'en'): string => {
|
(key: keyof typeof langEn, lang = data?.language || 'en'): string => {
|
||||||
if (lang in translationsList) {
|
if (lang in translationsList) {
|
||||||
|
|||||||
+3
-4
@@ -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;
|
||||||
+33
-19
@@ -1,7 +1,14 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
import Color from 'color';
|
import Color from 'color';
|
||||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
import {
|
||||||
|
CustomFieldLabel,
|
||||||
|
isOntimeBlock,
|
||||||
|
isOntimeDelay,
|
||||||
|
isOntimeEvent,
|
||||||
|
OntimeRundown,
|
||||||
|
OntimeRundownEntry,
|
||||||
|
} from 'ontime-types';
|
||||||
|
|
||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
import { getAccessibleColour } from '../../common/utils/styleUtils';
|
import { getAccessibleColour } from '../../common/utils/styleUtils';
|
||||||
@@ -11,7 +18,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 { useCuesheetOptions } from './cuesheet.options';
|
||||||
import useColumnManager from './useColumnManager';
|
import useColumnManager from './useColumnManager';
|
||||||
|
|
||||||
import style from './Cuesheet.module.scss';
|
import style from './Cuesheet.module.scss';
|
||||||
@@ -19,13 +26,21 @@ import style from './Cuesheet.module.scss';
|
|||||||
interface CuesheetProps {
|
interface CuesheetProps {
|
||||||
data: OntimeRundown;
|
data: OntimeRundown;
|
||||||
columns: ColumnDef<OntimeRundownEntry>[];
|
columns: ColumnDef<OntimeRundownEntry>[];
|
||||||
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
|
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: string) => void;
|
||||||
|
handleUpdateCustom: (rowIndex: number, accessor: CustomFieldLabel, payload: string) => void;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
currentBlockId: string | null;
|
currentBlockId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId, currentBlockId }: CuesheetProps) {
|
export default function Cuesheet({
|
||||||
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
|
data,
|
||||||
|
columns,
|
||||||
|
handleUpdate,
|
||||||
|
handleUpdateCustom,
|
||||||
|
selectedId,
|
||||||
|
currentBlockId,
|
||||||
|
}: CuesheetProps) {
|
||||||
|
const { followSelected, hideDelays, hidePast, hideIndexColumn } = useCuesheetOptions();
|
||||||
const {
|
const {
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
columnOrder,
|
columnOrder,
|
||||||
@@ -51,6 +66,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId, curr
|
|||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
handleUpdate,
|
handleUpdate,
|
||||||
|
handleUpdateCustom,
|
||||||
},
|
},
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
onColumnSizingChange: setColumnSizing,
|
onColumnSizingChange: setColumnSizing,
|
||||||
@@ -94,17 +110,15 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId, curr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showSettings && (
|
<CuesheetTableSettings
|
||||||
<CuesheetTableSettings
|
columns={allLeafColumns}
|
||||||
columns={allLeafColumns}
|
handleResetResizing={resetColumnResizing}
|
||||||
handleResetResizing={resetColumnResizing}
|
handleResetReordering={resetColumnOrder}
|
||||||
handleResetReordering={resetColumnOrder}
|
handleClearToggles={setAllVisible}
|
||||||
handleClearToggles={setAllVisible}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||||
<table className={style.cuesheet}>
|
<table className={style.cuesheet}>
|
||||||
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} showIndexColumn={showIndexColumn} />
|
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} showIndexColumn={!hideIndexColumn} />
|
||||||
<tbody>
|
<tbody>
|
||||||
{rowModel.rows.map((row) => {
|
{rowModel.rows.map((row) => {
|
||||||
const key = row.original.id;
|
const key = row.original.id;
|
||||||
@@ -114,17 +128,17 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId, curr
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeBlock(row.original)) {
|
if (isOntimeBlock(row.original)) {
|
||||||
if (isPast && !showPrevious && key !== currentBlockId) {
|
if (isPast && hidePast && key !== currentBlockId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return <BlockRow key={key} title={row.original.title} />;
|
return <BlockRow key={key} title={row.original.title} />;
|
||||||
}
|
}
|
||||||
if (isOntimeDelay(row.original)) {
|
if (isOntimeDelay(row.original)) {
|
||||||
if (isPast && !showPrevious) {
|
if (isPast && hidePast) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const delayVal = row.original.duration;
|
const delayVal = row.original.duration;
|
||||||
if (!showDelayBlock || delayVal === 0) {
|
if (hideDelays || delayVal === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +148,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId, curr
|
|||||||
eventIndex++;
|
eventIndex++;
|
||||||
const isSelected = key === selectedId;
|
const isSelected = key === selectedId;
|
||||||
|
|
||||||
if (isPast && !showPrevious) {
|
if (isPast && hidePast) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +173,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId, curr
|
|||||||
selectedRef={isSelected ? selectedRef : undefined}
|
selectedRef={isSelected ? selectedRef : undefined}
|
||||||
skip={row.original.skip}
|
skip={row.original.skip}
|
||||||
colour={row.original.colour}
|
colour={row.original.colour}
|
||||||
showIndexColumn={showIndexColumn}
|
showIndexColumn={!hideIndexColumn}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => {
|
{row.getVisibleCells().map((cell) => {
|
||||||
return (
|
return (
|
||||||
+2
-1
@@ -5,9 +5,10 @@
|
|||||||
padding: 1rem 0.5rem;
|
padding: 1rem 0.5rem;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 3rem auto 1fr;
|
grid-template-rows: 3rem auto auto 1fr;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'overview'
|
'overview'
|
||||||
|
'progress'
|
||||||
'settings'
|
'settings'
|
||||||
'table';
|
'table';
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
+58
-33
@@ -1,44 +1,51 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
||||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||||
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
|
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } 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 ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
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 Cuesheet from './Cuesheet';
|
import Cuesheet from './Cuesheet';
|
||||||
|
import { cuesheetOptions } from './cuesheet.options';
|
||||||
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();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
|
||||||
const { updateCustomField } = useEventAction();
|
const { updateCustomField, updateEvent } = useEventAction();
|
||||||
const featureData = useCuesheet();
|
const featureData = useCuesheet();
|
||||||
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
|
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
|
||||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
|
||||||
|
|
||||||
useWindowTitle('Cuesheet');
|
useWindowTitle('Cuesheet');
|
||||||
|
|
||||||
|
/** Handles showing the view params edit drawer */
|
||||||
|
const showEditFormDrawer = useCallback(() => {
|
||||||
|
searchParams.set('edit', 'true');
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles updating a field
|
* Handles updating a custom field
|
||||||
* Currently, only custom fields can be updated from the cuesheet
|
|
||||||
*/
|
*/
|
||||||
const handleUpdate = useCallback(
|
const handleUpdateCustom = useCallback(
|
||||||
async (rowIndex: number, accessor: CustomFieldLabel, payload: unknown) => {
|
async (rowIndex: number, accessor: CustomFieldLabel, payload: string) => {
|
||||||
if (!flatRundown || rundownStatus !== 'success') {
|
if (!flatRundown || rundownStatus !== 'success') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -53,52 +60,68 @@ export default function CuesheetWrapper() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skip if there is no value change
|
||||||
const previousValue = event.custom[accessor];
|
const previousValue = event.custom[accessor];
|
||||||
|
|
||||||
if (previousValue === payload) {
|
if (previousValue === payload) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
updateCustomField(event.id, accessor, payload);
|
||||||
// check if value is valid
|
|
||||||
// in anticipation to different types of event here
|
|
||||||
if (typeof payload !== 'string') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanup
|
|
||||||
const cleanVal = payload.trim();
|
|
||||||
|
|
||||||
// submit
|
|
||||||
try {
|
|
||||||
await updateCustomField(event.id, accessor, cleanVal);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[flatRundown, rundownStatus, updateCustomField],
|
[flatRundown, rundownStatus, updateCustomField],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles updating all other string fields
|
||||||
|
*/
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
async (rowIndex: number, accessor: keyof OntimeEvent, payload: string) => {
|
||||||
|
if (!flatRundown || rundownStatus !== 'success') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowIndex == null || accessor == null || payload == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if value is the same
|
||||||
|
const event = flatRundown[rowIndex];
|
||||||
|
if (!event || !isOntimeEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip if there is no value change
|
||||||
|
const previousValue = event[accessor];
|
||||||
|
if (previousValue === payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEvent({ id: event.id, [accessor]: payload });
|
||||||
|
},
|
||||||
|
[flatRundown, rundownStatus, updateEvent],
|
||||||
|
);
|
||||||
|
|
||||||
if (!customFields || !flatRundown || rundownStatus !== 'success') {
|
if (!customFields || !flatRundown || rundownStatus !== 'success') {
|
||||||
return <Empty text='Loading...' />;
|
return <EmptyPage text='Loading...' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
||||||
|
<ViewParamsEditor viewOptions={cuesheetOptions} />
|
||||||
<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 />}
|
||||||
onClick={() => toggleSettings()}
|
onClick={showEditFormDrawer}
|
||||||
/>
|
/>
|
||||||
</CuesheetOverview>
|
</CuesheetOverview>
|
||||||
<CuesheetProgress />
|
<CuesheetProgress />
|
||||||
@@ -106,6 +129,8 @@ export default function CuesheetWrapper() {
|
|||||||
data={flatRundown}
|
data={flatRundown}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
handleUpdate={handleUpdate}
|
handleUpdate={handleUpdate}
|
||||||
|
handleUpdateCustom={handleUpdateCustom}
|
||||||
|
//TODO: stabilizer selectedEventId and currentBlockId
|
||||||
selectedId={featureData.selectedEventId}
|
selectedId={featureData.selectedEventId}
|
||||||
currentBlockId={featureData.currentBlockId}
|
currentBlockId={featureData.currentBlockId}
|
||||||
/>
|
/>
|
||||||
+2
-2
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+47
-3
@@ -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",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.progressOverride {
|
||||||
|
height: 1rem;
|
||||||
|
grid-area: progress;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { memo, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
|
||||||
|
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
|
|
||||||
|
interface MultiLineCellProps {
|
||||||
|
initialValue: string;
|
||||||
|
handleUpdate: (newValue: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiLineCell = (props: MultiLineCellProps) => {
|
||||||
|
const { initialValue, handleUpdate } = props;
|
||||||
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
|
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
||||||
|
|
||||||
|
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||||
|
submitOnCtrlEnter: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AutoTextArea
|
||||||
|
inputref={ref}
|
||||||
|
rows={1}
|
||||||
|
size='sm'
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
transition='none'
|
||||||
|
variant='ontime-transparent'
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(MultiLineCell);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { memo, useCallback, useRef } from 'react';
|
||||||
|
import { Input } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
|
|
||||||
|
interface SingleLineCellProps {
|
||||||
|
initialValue: string;
|
||||||
|
handleUpdate: (newValue: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SingleLineCell = (props: SingleLineCellProps) => {
|
||||||
|
const { initialValue, handleUpdate } = props;
|
||||||
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
|
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
||||||
|
|
||||||
|
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||||
|
submitOnCtrlEnter: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
size='sx'
|
||||||
|
variant='ontime-transparent'
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(SingleLineCell);
|
||||||
+7
-19
@@ -1,24 +1,20 @@
|
|||||||
.tableSettings {
|
.tableSettings {
|
||||||
grid-area: settings;
|
grid-area: settings;
|
||||||
padding: 1rem;
|
padding-inline: 0.5rem;
|
||||||
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);
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||||
|
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In the specific case of the cuesheet options
|
||||||
|
* we save the user preferences in the local storage
|
||||||
|
*/
|
||||||
|
export const cuesheetOptions: ViewOption[] = [
|
||||||
|
{ section: 'Table options' },
|
||||||
|
{
|
||||||
|
id: 'hideTableSeconds',
|
||||||
|
title: 'Hide seconds in table',
|
||||||
|
description: 'Whether to hide seconds in the time fields displayed in the table',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'followSelected',
|
||||||
|
title: 'Follow selected event',
|
||||||
|
description: 'Whether the view should automatically scroll to the selected event',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hidePast',
|
||||||
|
title: 'Hide Past Events',
|
||||||
|
description: 'Whether to hide events that have passed',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideIndexColumn',
|
||||||
|
title: 'Hide index column',
|
||||||
|
description: 'Whether the hide the event indexes in the table',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{ section: 'Delay flow' },
|
||||||
|
{
|
||||||
|
id: 'showDelayedTimes',
|
||||||
|
title: 'Show delayed times',
|
||||||
|
description: 'Whether the time fields should include delays',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideDelays',
|
||||||
|
title: 'Hide delays',
|
||||||
|
description: 'Whether to hide the rows containing scheduled delays',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type CuesheetOptions = {
|
||||||
|
hideTableSeconds: boolean;
|
||||||
|
followSelected: boolean;
|
||||||
|
hidePast: boolean;
|
||||||
|
hideIndexColumn: boolean;
|
||||||
|
showDelayedTimes: boolean;
|
||||||
|
hideDelays: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility extract the view options from URL Params
|
||||||
|
* the names and fallbacks are manually matched with cuesheetOptions
|
||||||
|
*/
|
||||||
|
export function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
|
||||||
|
// we manually make an object that matches the key above
|
||||||
|
return {
|
||||||
|
hideTableSeconds: isStringBoolean(searchParams.get('hideTableSeconds')),
|
||||||
|
followSelected: isStringBoolean(searchParams.get('followSelected')),
|
||||||
|
hidePast: isStringBoolean(searchParams.get('hidePast')),
|
||||||
|
hideIndexColumn: isStringBoolean(searchParams.get('hideIndexColumn')),
|
||||||
|
showDelayedTimes: isStringBoolean(searchParams.get('showDelayedTimes')),
|
||||||
|
hideDelays: isStringBoolean(searchParams.get('hideDelays')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook exposes the cuesheet view options
|
||||||
|
*/
|
||||||
|
export function useCuesheetOptions(): CuesheetOptions {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Checkbox } from '@chakra-ui/react';
|
||||||
|
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
||||||
|
import RunningTime from '../../features/viewers/common/running-time/RunningTime';
|
||||||
|
|
||||||
|
import MultiLineCell from './cuesheet-table-elements/MultiLineCell';
|
||||||
|
import SingleLineCell from './cuesheet-table-elements/SingleLineCell';
|
||||||
|
import { useCuesheetOptions } from './cuesheet.options';
|
||||||
|
|
||||||
|
import style from './Cuesheet.module.scss';
|
||||||
|
|
||||||
|
function MakePublic({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
|
const update = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
// @ts-expect-error -- we inject this into react-table
|
||||||
|
table.options.meta?.handleUpdate(row.index, column.id, event.target.checked);
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||||
|
[column.id, row.index],
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = row.original;
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isChecked = event.isPublic;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Checkbox variant='ontime-ondark' onChange={update} isChecked={isChecked} style={{ verticalAlign: 'middle' }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
|
const { showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
|
||||||
|
const cellValue = (getValue() as number | null) ?? 0;
|
||||||
|
const delayValue = (original as OntimeEvent)?.delay ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={style.time}>
|
||||||
|
<DelayIndicator delayValue={delayValue} />
|
||||||
|
<RunningTime value={cellValue} hideSeconds={hideTableSeconds} />
|
||||||
|
{delayValue !== 0 && showDelayedTimes && (
|
||||||
|
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideTableSeconds} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
|
const { hideTableSeconds } = useCuesheetOptions();
|
||||||
|
const cellValue = (getValue() as number | null) ?? 0;
|
||||||
|
|
||||||
|
return <RunningTime value={cellValue} hideSeconds={hideTableSeconds} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
|
const update = useCallback(
|
||||||
|
(newValue: string) => {
|
||||||
|
// @ts-expect-error -- we inject this into react-table
|
||||||
|
table.options.meta?.handleUpdate(row.index, column.id, newValue);
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||||
|
[column.id, row.index],
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = row.original;
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? '';
|
||||||
|
|
||||||
|
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
|
const update = useCallback(
|
||||||
|
(newValue: string) => {
|
||||||
|
// @ts-expect-error -- we inject this into react-table
|
||||||
|
table.options.meta?.handleUpdate(row.index, column.id, newValue);
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||||
|
[column.id, row.index],
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = row.original;
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? '';
|
||||||
|
|
||||||
|
return <SingleLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
|
const update = useCallback(
|
||||||
|
(newValue: string) => {
|
||||||
|
// @ts-expect-error -- we inject this into react-table
|
||||||
|
table.options.meta?.handleUpdateCustom(row.index, column.id, newValue);
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||||
|
[column.id, row.index],
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = row.original;
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValue = event.custom[column.id] ?? '';
|
||||||
|
|
||||||
|
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
|
||||||
|
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
|
||||||
|
accessorKey: key,
|
||||||
|
id: key,
|
||||||
|
header: customFields[key].label,
|
||||||
|
meta: { colour: customFields[key].colour },
|
||||||
|
cell: MakeCustomField,
|
||||||
|
size: 250,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessorKey: 'cue',
|
||||||
|
id: 'cue',
|
||||||
|
header: 'Cue',
|
||||||
|
cell: (row) => row.getValue(),
|
||||||
|
size: 75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'isPublic',
|
||||||
|
id: 'isPublic',
|
||||||
|
header: 'Public',
|
||||||
|
cell: MakePublic,
|
||||||
|
size: 45,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'timeStart',
|
||||||
|
id: 'timeStart',
|
||||||
|
header: 'Start',
|
||||||
|
cell: MakeTimer,
|
||||||
|
size: 75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'timeEnd',
|
||||||
|
id: 'timeEnd',
|
||||||
|
header: 'End',
|
||||||
|
cell: MakeTimer,
|
||||||
|
size: 75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'duration',
|
||||||
|
id: 'duration',
|
||||||
|
header: 'Duration',
|
||||||
|
cell: MakeDuration,
|
||||||
|
size: 75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'title',
|
||||||
|
id: 'title',
|
||||||
|
header: 'Title',
|
||||||
|
cell: MakeSingleLineField,
|
||||||
|
size: 250,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'note',
|
||||||
|
id: 'note',
|
||||||
|
header: 'Note',
|
||||||
|
cell: MakeMultiLineField,
|
||||||
|
size: 250,
|
||||||
|
},
|
||||||
|
...dynamicCustomFields,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.8.0",
|
"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",
|
||||||
|
|||||||
@@ -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'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "3.8.0",
|
"version": "3.9.5",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
deleteEvent,
|
deleteEvent,
|
||||||
editEvent,
|
editEvent,
|
||||||
reorderEvent,
|
reorderEvent,
|
||||||
setFrozenState,
|
|
||||||
swapEvents,
|
swapEvents,
|
||||||
} from '../../services/rundown-service/RundownService.js';
|
} from '../../services/rundown-service/RundownService.js';
|
||||||
import {
|
import {
|
||||||
@@ -127,17 +126,6 @@ export async function rundownBatchPut(req: Request, res: Response<MessageRespons
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rundownFrozenPost(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
|
||||||
try {
|
|
||||||
const { frozen } = req.body;
|
|
||||||
setFrozenState(frozen);
|
|
||||||
res.status(200).send({ message: 'Rundown frozen state updated.' });
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rundownReorder(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
|
export async function rundownReorder(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
|
||||||
if (failEmptyObjects(req.body, res)) {
|
if (failEmptyObjects(req.body, res)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { eventStore } from '../../stores/EventStore.js';
|
|
||||||
|
|
||||||
export const preventIfFrozen = function (req, res, next) {
|
|
||||||
if (eventStore.get('frozen')) {
|
|
||||||
res.status(403).send({ message: 'Rundown is frozen' });
|
|
||||||
} else {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
rundownApplyDelay,
|
rundownApplyDelay,
|
||||||
rundownBatchPut,
|
rundownBatchPut,
|
||||||
rundownDelete,
|
rundownDelete,
|
||||||
rundownFrozenPost,
|
|
||||||
rundownGetAll,
|
rundownGetAll,
|
||||||
rundownGetById,
|
rundownGetById,
|
||||||
rundownGetNormalised,
|
rundownGetNormalised,
|
||||||
@@ -19,14 +18,12 @@ import {
|
|||||||
paramsMustHaveEventId,
|
paramsMustHaveEventId,
|
||||||
rundownArrayOfIds,
|
rundownArrayOfIds,
|
||||||
rundownBatchPutValidator,
|
rundownBatchPutValidator,
|
||||||
rundownFrozenPostValidator,
|
|
||||||
rundownGetPaginatedQueryParams,
|
rundownGetPaginatedQueryParams,
|
||||||
rundownPostValidator,
|
rundownPostValidator,
|
||||||
rundownPutValidator,
|
rundownPutValidator,
|
||||||
rundownReorderValidator,
|
rundownReorderValidator,
|
||||||
rundownSwapValidator,
|
rundownSwapValidator,
|
||||||
} from './rundown.validation.js';
|
} from './rundown.validation.js';
|
||||||
import { preventIfFrozen } from './rundown.middleware.js';
|
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
@@ -36,14 +33,13 @@ router.get('/normalised', rundownGetNormalised);
|
|||||||
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
|
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
|
||||||
|
|
||||||
router.post('/', rundownPostValidator, rundownPost);
|
router.post('/', rundownPostValidator, rundownPost);
|
||||||
router.post('/frozen', rundownFrozenPostValidator, rundownFrozenPost);
|
|
||||||
|
|
||||||
router.put('/', rundownPutValidator, rundownPut);
|
router.put('/', rundownPutValidator, rundownPut);
|
||||||
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
|
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
|
||||||
|
|
||||||
router.patch('/reorder/', rundownReorderValidator, preventIfFrozen, rundownReorder);
|
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
|
||||||
router.patch('/swap', rundownSwapValidator, preventIfFrozen, rundownSwap);
|
router.patch('/swap', rundownSwapValidator, rundownSwap);
|
||||||
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
|
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
|
||||||
|
|
||||||
router.delete('/', rundownArrayOfIds, preventIfFrozen, deletesEventById);
|
router.delete('/', rundownArrayOfIds, deletesEventById);
|
||||||
router.delete('/all', preventIfFrozen, rundownDelete);
|
router.delete('/all', rundownDelete);
|
||||||
|
|||||||
@@ -21,15 +21,6 @@ export const rundownPutValidator = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const rundownFrozenPostValidator = [
|
|
||||||
body('frozen').isBoolean().exists(),
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const rundownBatchPutValidator = [
|
export const rundownBatchPutValidator = [
|
||||||
body('data').isObject().exists(),
|
body('data').isObject().exists(),
|
||||||
body('ids').isArray().exists(),
|
body('ids').isArray().exists(),
|
||||||
|
|||||||
@@ -241,6 +241,11 @@ const actionHandlers: Record<string, ActionHandler> = {
|
|||||||
const timeInMs = numberOrError(command.duration) * 1000;
|
const timeInMs = numberOrError(command.duration) * 1000;
|
||||||
reply.payload = auxTimerService.setTime(timeInMs);
|
reply.payload = auxTimerService.setTime(timeInMs);
|
||||||
}
|
}
|
||||||
|
if ('addtime' in command) {
|
||||||
|
// convert addTime in seconds to ms
|
||||||
|
const timeInMs = numberOrError(command.addtime) * 1000;
|
||||||
|
reply.payload = auxTimerService.addTime(timeInMs);
|
||||||
|
}
|
||||||
if ('direction' in command) {
|
if ('direction' in command) {
|
||||||
if (command.direction === SimpleDirection.CountUp || command.direction === SimpleDirection.CountDown) {
|
if (command.direction === SimpleDirection.CountUp || command.direction === SimpleDirection.CountDown) {
|
||||||
reply.payload = auxTimerService.setDirection(command.direction);
|
reply.payload = auxTimerService.setDirection(command.direction);
|
||||||
|
|||||||
+38
-28
@@ -6,11 +6,11 @@ import expressStaticGzip from 'express-static-gzip';
|
|||||||
import http, { Server } from 'http';
|
import http, { 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
|
||||||
@@ -183,7 +189,7 @@ export const startServer = async (
|
|||||||
const portError = resultPort !== desiredPort;
|
const portError = resultPort !== desiredPort;
|
||||||
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
|
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
|
||||||
|
|
||||||
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
|
||||||
@@ -209,7 +215,6 @@ export const startServer = async (
|
|||||||
playback: SimplePlayback.Stop,
|
playback: SimplePlayback.Stop,
|
||||||
direction: SimpleDirection.CountDown,
|
direction: SimpleDirection.CountDown,
|
||||||
},
|
},
|
||||||
frozen: false,
|
|
||||||
ping: -1,
|
ping: -1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,10 +233,10 @@ export const startServer = async (
|
|||||||
runtimeService.init(maybeRestorePoint);
|
runtimeService.init(maybeRestorePoint);
|
||||||
|
|
||||||
const nif = getNetworkInterfaces();
|
const nif = getNetworkInterfaces();
|
||||||
consoleSuccess(`Local: http://localhost:${resultPort}/editor`);
|
consoleSuccess(`Local: http://localhost:${resultPort}${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}:${resultPort}/editor`);
|
consoleSuccess(`Network: http://${address}:${resultPort}${prefix}/editor`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnMessage = `Ontime is listening on port ${resultPort}`;
|
const returnMessage = `Ontime is listening on port ${resultPort}`;
|
||||||
@@ -253,16 +258,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 {
|
||||||
@@ -272,6 +267,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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
export const timerConfig = {
|
export const timerConfig = {
|
||||||
skipLimit: 1000, // threshold of skip for recalculating
|
skipLimit: 1000, // threshold of skip for recalculating, values lower than updateRate can cause issues with rolling over midnight
|
||||||
updateRate: 32, // how often do we update the timer
|
updateRate: 32, // how often do we update the timer
|
||||||
notificationRate: 1000, // how often do we notify clients and integrations
|
notificationRate: 1000, // how often do we notify clients and integrations
|
||||||
triggerAhead: 10, // how far ahead do we trigger the end event
|
triggerAhead: 10, // how far ahead do we trigger the end event
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -57,6 +57,15 @@ export class AuxTimerService {
|
|||||||
return this.timer.setTime(duration);
|
return this.timer.setTime(duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@broadcastReturn
|
||||||
|
addTime(millis: number) {
|
||||||
|
if (this.timer.state.playback === SimplePlayback.Start) {
|
||||||
|
this.timer.addTime(millis);
|
||||||
|
return this.timer.update(this.getTime());
|
||||||
|
}
|
||||||
|
return this.timer.addTime(millis);
|
||||||
|
}
|
||||||
|
|
||||||
@broadcastReturn
|
@broadcastReturn
|
||||||
private update() {
|
private update() {
|
||||||
return this.timer.update(this.getTime());
|
return this.timer.update(this.getTime());
|
||||||
|
|||||||
@@ -128,18 +128,18 @@ export class OscIntegration implements IIntegration<OscSubscription, OSCSettings
|
|||||||
}
|
}
|
||||||
|
|
||||||
private shutdownTX() {
|
private shutdownTX() {
|
||||||
logger.info(LogOrigin.Rx, 'Shutting down OSC integration');
|
if (this.oscClient) {
|
||||||
if (this.oscServer) {
|
logger.info(LogOrigin.Tx, 'Shutting down OSC integration');
|
||||||
this.oscServer?.shutdown();
|
this.oscClient?.close();
|
||||||
this.oscServer = null;
|
this.oscClient = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private shutdownRX() {
|
private shutdownRX() {
|
||||||
logger.info(LogOrigin.Tx, 'Shutting down OSC integration');
|
if (this.oscServer) {
|
||||||
if (this.oscClient) {
|
logger.info(LogOrigin.Rx, 'Shutting down OSC integration');
|
||||||
this.oscClient?.close();
|
this.oscServer?.shutdown();
|
||||||
this.oscClient = null;
|
this.oscServer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { runtimeService } from '../runtime-service/RuntimeService.js';
|
|||||||
|
|
||||||
import * as cache from './rundownCache.js';
|
import * as cache from './rundownCache.js';
|
||||||
import { getPlayableEvents, getTimedEvents } from './rundownUtils.js';
|
import { getPlayableEvents, getTimedEvents } from './rundownUtils.js';
|
||||||
import { eventStore } from '../../stores/EventStore.js';
|
|
||||||
|
|
||||||
type PatchWithId = (Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string };
|
type PatchWithId = (Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string };
|
||||||
|
|
||||||
@@ -275,7 +274,3 @@ export async function initRundown(rundown: Readonly<OntimeRundown>, customFields
|
|||||||
// notify timer of change
|
// notify timer of change
|
||||||
notifyChanges({ timer: true, external: true, reload: true });
|
notifyChanges({ timer: true, external: true, reload: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setFrozenState(state: boolean) {
|
|
||||||
eventStore.set('frozen', state);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -99,10 +99,15 @@ class RuntimeService {
|
|||||||
});
|
});
|
||||||
this.handleLoadNext();
|
this.handleLoadNext();
|
||||||
this.rollLoaded(keepOffset);
|
this.rollLoaded(keepOffset);
|
||||||
} else if (skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit)) {
|
} else if (
|
||||||
|
// if there is no previous clock, we could not have skipped
|
||||||
|
RuntimeService.previousState?.clock &&
|
||||||
|
skippedOutOfEvent(newState, RuntimeService.previousState.clock, timerConfig.skipLimit)
|
||||||
|
) {
|
||||||
// if we have skipped out of the event, we will recall roll
|
// if we have skipped out of the event, we will recall roll
|
||||||
// to push the playback to the right place
|
// to push the playback to the right place
|
||||||
// this comes with the caveat that we will lose our runtime data
|
// this comes with the caveat that we will lose our runtime data
|
||||||
|
logger.warning(LogOrigin.Playback, 'Time skip detected, reloading roll');
|
||||||
this.roll(true);
|
this.roll(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* @description Cleans given url
|
|
||||||
* @param {string} url - URL to be checked
|
|
||||||
* @returns {string} Sanitized url
|
|
||||||
*/
|
|
||||||
export const cleanURL = (url: string): string => {
|
|
||||||
// trim whitespaces
|
|
||||||
let sanitised = url.trim();
|
|
||||||
|
|
||||||
// clear any whitespaces
|
|
||||||
sanitised = sanitised.split(' ').join('%20');
|
|
||||||
|
|
||||||
// contain only allowed characters
|
|
||||||
sanitised = sanitised.replace(/([@\s<>[\]{}|\\^])+/g, '');
|
|
||||||
|
|
||||||
// starts with http://
|
|
||||||
if (!sanitised.startsWith('http://')) sanitised = `http://${sanitised}`;
|
|
||||||
|
|
||||||
return sanitised;
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
version: "3"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ontime:
|
ontime:
|
||||||
container_name: ontime
|
container_name: ontime
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ const fileToUpload = 'e2e/tests/fixtures/test-db.json';
|
|||||||
|
|
||||||
test('project file upload', async ({ page }) => {
|
test('project file upload', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/editor');
|
await page.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
|
// close the welcome modal if it is open
|
||||||
|
await page.keyboard.down('Escape');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Edit' }).click();
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user