Merge branch 'v3' of https://github.com/cpvalente/ontime into bp/edit-multiple-events

This commit is contained in:
Bianca Procopio
2024-04-07 16:28:13 -06:00
104 changed files with 1115 additions and 1002 deletions
+1
View File
@@ -19,6 +19,7 @@
"rules": { "rules": {
"no-useless-concat": "warn", "no-useless-concat": "warn",
"prefer-template": "warn", "prefer-template": "warn",
"no-throw-literal": "error",
"no-console": [ "no-console": [
"warn", "warn",
{ {
+1 -1
View File
@@ -7,7 +7,7 @@ import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor')); const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet')); const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/Operator')); const Operator = lazy(() => import('./features/operator/OperatorExport'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer')); const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { debounce } from '../../utils/debounce';
import style from './NavigationMenu.module.scss';
interface FloatingNavigationProps {
toggleMenu: () => void;
toggleSettings: () => void;
}
export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props;
const [showButton, setShowButton] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return (
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
className={style.navButton}
data-testid='navigation__toggle-menu'
>
<IoApps />
</button>
<button
className={style.button}
onClick={toggleSettings}
aria-label='toggle settings'
data-testid='navigation__toggle-settings'
>
<IoSettingsOutline />
</button>
</div>
);
}
@@ -45,8 +45,8 @@ $button-size: 3rem;
.link { .link {
@include action-link; @include action-link;
justify-content: space-between;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
gap: 0.5rem;
&:hover { &:hover {
background-color: $menu-hover-bg; background-color: $menu-hover-bg;
@@ -69,7 +69,7 @@ $button-size: 3rem;
} }
.linkIcon { .linkIcon {
display: inline-block; margin-left: auto;
transform: rotate(45deg); transform: rotate(45deg);
} }
@@ -1,5 +1,6 @@
import { memo, PropsWithChildren, useEffect, useRef, useState } from 'react'; import { memo, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import { import {
Drawer, Drawer,
DrawerBody, DrawerBody,
@@ -9,81 +10,126 @@ import {
DrawerOverlay, DrawerOverlay,
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps'; import { useFullscreen } from '@mantine/hooks';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside'; import useClickOutside from '../../hooks/useClickOutside';
import { debounce } from '../../utils/debounce'; import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import RenameClientModal from './rename-client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
interface NavigationMenuProps { interface NavigationMenuProps {
editCallback: () => void; isOpen: boolean;
onClose: () => void;
} }
function NavigationMenu(props: PropsWithChildren<NavigationMenuProps>) { function NavigationMenu(props: NavigationMenuProps) {
const { children, editCallback } = props; const { isOpen, onClose } = props;
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => onClose); useClickOutside(menuRef, () => onClose);
const toggleMenu = () => (isOpen ? onClose() : onOpen());
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return createPortal( return createPortal(
<div id='navigation-menu-portal' ref={menuRef}> <div id='navigation-menu-portal' ref={menuRef}>
<div className={`${style.buttonContainer} ${!showButton && !isOpen ? style.hidden : ''}`}> <RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} />
<button <Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
onClick={toggleMenu} <DrawerOverlay />
aria-label='toggle menu' <DrawerContent>
className={style.navButton} <DrawerHeader>
data-testid='navigation__toggle-menu' <DrawerCloseButton size='lg' />
> Ontime
<IoApps /> </DrawerHeader>
</button> <DrawerBody padding={0}>
<button <div className={style.buttonsContainer}>
className={style.button} <div
onClick={editCallback} className={style.link}
aria-label='toggle settings' tabIndex={0}
data-testid='navigation__toggle-settings' role='button'
> onClick={toggle}
<IoSettingsOutline /> onKeyDown={(event) => {
</button> isKeyEnter(event) && toggle();
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'> }}
<DrawerOverlay /> >
<DrawerContent> Toggle Fullscreen
<DrawerHeader> {fullscreen ? <IoContract /> : <IoExpand />}
<DrawerCloseButton size='lg' /> </div>
Ontime <div
</DrawerHeader> className={style.link}
<DrawerBody padding={0}>{children}</DrawerBody> tabIndex={0}
</DrawerContent> role='button'
</Drawer> onClick={() => toggleMirror()}
</div> onKeyDown={(event) => {
isKeyEnter(event) && toggleMirror();
}}
>
Flip Screen
<IoSwapVertical />
</div>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={onRenameOpen}
onKeyDown={(event) => {
isKeyEnter(event) && onRenameOpen();
}}
>
Rename Client
</div>
</div>
<hr className={style.separator} />
<Link
to='/editor'
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline />
Editor
<IoArrowUp className={style.linkIcon} />
</Link>
<Link
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline />
Cuesheet
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
<IoLockClosedOutline />
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}
>
{route.label}
<IoArrowUp className={style.linkIcon} />
</Link>
))}
</DrawerBody>
</DrawerContent>
</Drawer>
</div>, </div>,
document.body, document.body,
); );
@@ -1,91 +1,16 @@
import { memo } from 'react'; import { memo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { navigatorConstants } from '../../../viewerConfig';
import { isKeyEnter } from '../../utils/keyEvent';
import RenameClientModal from './rename-client-modal/RenameClientModal';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
import style from './NavigationMenu.module.scss';
interface ProductionNavigationMenuProps { interface ProductionNavigationMenuProps {
handleSettings: () => void; isMenuOpen: boolean;
onMenuClose: () => void;
} }
function ProductionNavigationMenu({ handleSettings }: ProductionNavigationMenuProps) { function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
const location = useLocation(); const { isMenuOpen, onMenuClose } = props;
const { fullscreen, toggle } = useFullscreen();
const { isOpen, onOpen, onClose } = useDisclosure();
return ( return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
<NavigationMenu editCallback={handleSettings}>
<RenameClientModal isOpen={isOpen} onClose={onClose} />
<div className={style.buttonsContainer}>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={toggle}
onKeyDown={(event) => {
isKeyEnter(event) && toggle();
}}
>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</div>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={onOpen}
onKeyDown={(event) => {
isKeyEnter(event) && onOpen();
}}
>
Rename Client
</div>
</div>
<hr className={style.separator} />
<Link
to='/editor'
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
tabIndex={0}
>
Editor
<IoArrowUp className={style.linkIcon} />
</Link>
<Link
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
Cuesheet
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : ''}`}
tabIndex={0}
>
{route.label}
<IoArrowUp className={style.linkIcon} />
</Link>
))}
</NavigationMenu>
);
} }
export default memo(ProductionNavigationMenu); export default memo(ProductionNavigationMenu);
@@ -1,86 +1,26 @@
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react'; import { useDisclosure } from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig'; import FloatingNavigation from './FloatingNavigation';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import RenameClientModal from './rename-client-modal/RenameClientModal';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
import style from './NavigationMenu.module.scss';
function ViewNavigationMenu() { function ViewNavigationMenu() {
const location = useLocation();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const showEditFormDrawer = useCallback(() => { const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true'); searchParams.set('edit', 'true');
setSearchParams(searchParams); setSearchParams(searchParams);
}, [searchParams, setSearchParams]); }, [searchParams, setSearchParams]);
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
return ( return (
<NavigationMenu editCallback={showEditFormDrawer}> <>
<RenameClientModal isOpen={isOpen} onClose={onClose} /> <FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
<div className={style.buttonsContainer}> <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
<div </>
className={style.link}
tabIndex={0}
role='button'
onClick={toggle}
onKeyDown={(event) => {
isKeyEnter(event) && toggle();
}}
>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</div>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={() => toggleMirror()}
onKeyDown={(event) => {
isKeyEnter(event) && toggleMirror();
}}
>
Flip Screen
<IoSwapVertical />
</div>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={onOpen}
onKeyDown={(event) => {
isKeyEnter(event) && onOpen();
}}
>
Rename Client
</div>
</div>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}
>
{route.label}
<IoArrowUp className={style.linkIcon} />
</Link>
))}
</NavigationMenu>
); );
} }
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react'; import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark'; import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import style from './ProtectRoute.module.scss'; import style from './ProtectRoute.module.scss';
@@ -39,8 +39,8 @@ export default function PinPage(props: PinPageProps) {
return ( return (
<div className={style.container}> <div className={style.container}>
{`Ontime ${permission || ''}`} {`Ontime ${permission}`}
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}> <div className={failed ? style.pin__failed : style.pin}>
<PinInput <PinInput
type='alphanumeric' type='alphanumeric'
size='lg' size='lg'
@@ -57,8 +57,15 @@ export default function PinPage(props: PinPageProps) {
<PinInputField /> <PinInputField />
<PinInputField /> <PinInputField />
</PinInput> </PinInput>
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} /> <IconButton
</HStack> variant='ontime-filled'
aria-label='Enter'
size='lg'
isRound
icon={<IoCheckmark />}
onClick={validate}
/>
</div>
</div> </div>
); );
} }
@@ -1,27 +1,31 @@
.container { .container {
display: grid; display: flex;
place-content: center; flex-direction: column;
height: 100vh; align-items: center;
padding-bottom: 30vh; padding-top: 25vh;
background: $bg-container-l1;
color: $ontime-color; color: $ontime-color;
font-family: $ontime-font-family;
font-weight: 200; font-weight: 200;
text-align: center; font-size: 3rem;
font-size: 3vw;
} }
.pin, .pin,
.pin__failed { .pin__failed {
padding: 20px; display: flex;
gap: 0.125em;
padding-block: 0.5em;
input { input {
border-radius: 50%; border-radius: 99px;
border-color: $gray-500;
&:hover {
border-color: $blue-500;
}
} }
button { button {
margin-left: 20px; margin-left: 1em;
} }
} }
@@ -33,9 +37,9 @@
@keyframes colourFade { @keyframes colourFade {
from { from {
background: $action-blue; background: $red-500;
} }
to { to {
background: rgba($action-blue, 0); background: rgba($red-500, 0);
} }
} }
@@ -1,14 +1,9 @@
.drawerFooter { .drawerFooter {
display: flex; display: flex;
justify-content: start; justify-content: end;
gap: $section-spacing; gap: $section-spacing;
button[type='reset'] { button {
padding: 0 2em;
margin-right: auto;
}
button[type='submit'] {
padding: 0 2em; padding: 0 2em;
} }
} }
@@ -16,6 +11,9 @@
.label { .label {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray; color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem
} }
.columnSection { .columnSection {
@@ -52,15 +52,16 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
} }
}, [searchParams, onOpen]); }, [searchParams, onOpen]);
const onCloseWithoutSaving = () => { const handleClose = () => {
onClose();
searchParams.delete('edit'); searchParams.delete('edit');
setSearchParams(searchParams); setSearchParams(searchParams);
onClose();
}; };
const resetParams = () => { const resetParams = () => {
setSearchParams(); setSearchParams();
onClose();
}; };
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => { const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -69,10 +70,12 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
handleClose();
}; };
return ( return (
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} variant='ontime' size='lg'> <Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
<DrawerOverlay /> <DrawerOverlay />
<DrawerContent> <DrawerContent>
<DrawerHeader> <DrawerHeader>
@@ -96,10 +99,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
<DrawerFooter className={style.drawerFooter}> <DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'> <Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Reset Reset to default
</Button>
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
Cancel
</Button> </Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'> <Button variant='ontime-filled' form='edit-params-form' type='submit'>
Save Save
@@ -11,7 +11,7 @@ export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS, queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields, queryFn: getCustomFields,
placeholderData: placeholder, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval, refetchInterval: queryRefetchInterval,
@@ -11,14 +11,13 @@ export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS, queryKey: HTTP_SETTINGS,
queryFn: getHTTP, queryFn: getHTTP,
placeholderData: httpPlaceholder, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt: number) => attempt * 2500, retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
}); });
// we need to jump through some hoops because of the type op port
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch }; return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
} }
@@ -10,7 +10,7 @@ export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({ const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO, queryKey: APP_INFO,
queryFn: getInfo, queryFn: getInfo,
placeholderData: ontimePlaceholderInfo, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -11,7 +11,7 @@ export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS, queryKey: OSC_SETTINGS,
queryFn: getOSC, queryFn: getOSC,
placeholderData: oscPlaceholderSettings, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt: number) => attempt * 2500, retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -9,7 +9,7 @@ export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA, queryKey: PROJECT_DATA,
queryFn: getProjectData, queryFn: getProjectData,
placeholderData: projectDataPlaceholder, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -14,7 +14,7 @@ export function useProjectList() {
const { data, status, refetch } = useQuery({ const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST, queryKey: PROJECT_LIST,
queryFn: getProjects, queryFn: getProjects,
placeholderData: placeholderProjectList, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt: number) => attempt * 2500, retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -13,7 +13,7 @@ export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({ const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchNormalisedRundown, queryFn: fetchNormalisedRundown,
placeholderData: cachedRundownPlaceholder, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval, refetchInterval: queryRefetchInterval,
@@ -10,7 +10,7 @@ export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS, queryKey: APP_SETTINGS,
queryFn: getSettings, queryFn: getSettings,
placeholderData: ontimePlaceholderSettings, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -8,7 +8,7 @@ export default function useUrlPresets() {
const { data, status, isError, refetch } = useQuery({ const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS, queryKey: URL_PRESETS,
queryFn: getUrlPresets, queryFn: getUrlPresets,
placeholderData: [], placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -9,12 +9,12 @@ export default function useViewSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: VIEW_SETTINGS, queryKey: VIEW_SETTINGS,
queryFn: getView, queryFn: getView,
placeholderData: viewsSettingsPlaceholder, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
}); });
return { data, status, isError, refetch, isFetching }; return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
} }
@@ -0,0 +1,11 @@
import { useEffect } from 'react';
/**
* Sets tab title
* @param title
*/
export function useWindowTitle(title: string) {
useEffect(() => {
document.title = `ontime - ${title}`;
}, []);
}
@@ -3,7 +3,6 @@ import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage'; import { booleanFromLocalStorage } from '../utils/localStorage';
type EditorSettings = { type EditorSettings = {
showQuickEntry: boolean;
linkPrevious: boolean; linkPrevious: boolean;
defaultPublic: boolean; defaultPublic: boolean;
defaultDuration: string; defaultDuration: string;
@@ -12,14 +11,12 @@ type EditorSettings = {
type EditorSettingsStore = { type EditorSettingsStore = {
eventSettings: EditorSettings; eventSettings: EditorSettings;
setLocalEventSettings: (newState: EditorSettings) => void; setLocalEventSettings: (newState: EditorSettings) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setLinkPrevious: (linkPrevious: boolean) => void; setLinkPrevious: (linkPrevious: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void; setDefaultPublic: (defaultPublic: boolean) => void;
setDefaultDuration: (defaultDuration: string) => void; setDefaultDuration: (defaultDuration: string) => void;
}; };
enum EditorSettingsKeys { enum EditorSettingsKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
LinkPrevious = 'ontime-link-previous', LinkPrevious = 'ontime-link-previous',
DefaultPublic = 'ontime-default-public', DefaultPublic = 'ontime-default-public',
DefaultDuration = 'ontime-default-duration', DefaultDuration = 'ontime-default-duration',
@@ -27,7 +24,6 @@ enum EditorSettingsKeys {
export const useEditorSettings = create<EditorSettingsStore>((set) => ({ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
eventSettings: { eventSettings: {
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, true), linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, true), defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, true),
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? '00:10:00', defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? '00:10:00',
@@ -35,18 +31,11 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
setLocalEventSettings: (value) => setLocalEventSettings: (value) =>
set(() => { set(() => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(value.linkPrevious)); localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(value.linkPrevious));
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic)); localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value }; return { eventSettings: value };
}), }),
setShowQuickEntry: (showQuickEntry) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setLinkPrevious: (linkPrevious) => setLinkPrevious: (linkPrevious) =>
set((state) => { set((state) => {
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious)); localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
@@ -13,7 +13,7 @@ export default function GeneralPanel({ location }: PanelBaseProps) {
return ( return (
<> <>
<Panel.Header>Settings</Panel.Header> <Panel.Header>App Settings</Panel.Header>
<div ref={manageRef}> <div ref={manageRef}>
<GeneralPanelForm /> <GeneralPanelForm />
</div> </div>
@@ -7,7 +7,6 @@ import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() { export default function EditorSettingsForm() {
const eventSettings = useEditorSettings((state) => state.eventSettings); const eventSettings = useEditorSettings((state) => state.eventSettings);
const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry);
const setLinkPrevious = useEditorSettings((state) => state.setLinkPrevious); const setLinkPrevious = useEditorSettings((state) => state.setLinkPrevious);
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic); const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
const setDefaultDuration = useEditorSettings((state) => state.setDefaultDuration); const setDefaultDuration = useEditorSettings((state) => state.setDefaultDuration);
@@ -19,53 +18,82 @@ export default function EditorSettingsForm() {
<Panel.Card> <Panel.Card>
<Panel.SubHeader>Editor settings</Panel.SubHeader> <Panel.SubHeader>Editor settings</Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.ListGroup> <Panel.Section>
<Panel.ListItem> <Panel.Title>Rundown options</Panel.Title>
<Panel.Field <Panel.ListGroup>
title='Show quick entry' <Panel.ListItem>
description='Whether the quick entry buttons show under selected event' <Panel.Field
/> title='Default duration'
<Switch description='When creating a new event, what is the default duration'
variant='ontime' />
size='lg' <TimeInput<'defaultDuration'>
defaultChecked={eventSettings.showQuickEntry} name='defaultDuration'
onChange={(event) => setShowQuickEntry(event.target.checked)} submitHandler={(_field, value) => setDefaultDuration(value)}
/> time={durationInMs}
</Panel.ListItem> placeholder='00:10:00'
<Panel.ListItem> />
<Panel.Field </Panel.ListItem>
title='Link previous' <Panel.ListItem>
description='New events start time will be linked to the previous event' <Panel.Field
/> title='Link previous'
<Switch description='New events start time will be linked to the previous event'
variant='ontime' />
size='lg' <Switch
defaultChecked={eventSettings.linkPrevious} variant='ontime'
onChange={(event) => setLinkPrevious(event.target.checked)} size='lg'
/> defaultChecked={eventSettings.linkPrevious}
</Panel.ListItem> onChange={(event) => setLinkPrevious(event.target.checked)}
<Panel.ListItem> />
<Panel.Field </Panel.ListItem>
title='Default duration' <Panel.ListItem>
description='When creating a new event, what is the default duration' <Panel.Field title='Default public' description='New events will be public' />
/> <Switch
<TimeInput<'defaultDuration'> variant='ontime'
name='defaultDuration' size='lg'
submitHandler={(_field, value) => setDefaultDuration(value)} defaultChecked={eventSettings.defaultPublic}
time={durationInMs} onChange={(event) => setDefaultPublic(event.target.checked)}
placeholder='00:10:00' />
/> </Panel.ListItem>
</Panel.ListItem> </Panel.ListGroup>
<Panel.ListItem> </Panel.Section>
<Panel.Field title='Default public' description='New events will be public' /> <Panel.Section>
<Switch <Panel.Title>Play mode</Panel.Title>
variant='ontime' <Panel.ListGroup>
size='lg' <Panel.ListItem>
defaultChecked={eventSettings.defaultPublic} <Panel.Field
onChange={(event) => setDefaultPublic(event.target.checked)} title='Show quick entry'
/> description='Whether the quick entry buttons show above / under selected event'
</Panel.ListItem> />
</Panel.ListGroup> <Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Follow playback'
description='Whether view automatically follows the event being played'
/>
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Section>
<Panel.Title>Edit mode</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Show quick entry'
description='Whether the quick entry buttons show above / under selected event'
/>
<Switch variant='ontime' size='lg' defaultChecked isDisabled />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Follow playback'
description='Whether view automatically follows the event being played'
/>
<Switch variant='ontime' size='lg' defaultChecked={false} isDisabled />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
); );
@@ -43,8 +43,7 @@ export default function ProjectList() {
<thead> <thead>
<tr> <tr>
<th className={style.containCell}>Project Name</th> <th className={style.containCell}>Project Name</th>
<th>Date Created</th> <th>Last Used</th>
<th>Date Modified</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -53,7 +52,6 @@ export default function ProjectList() {
<ProjectListItem <ProjectListItem
key={project.filename} key={project.filename}
filename={project.filename} filename={project.filename}
createdAt={project.createdAt}
updatedAt={project.updatedAt} updatedAt={project.updatedAt}
onToggleEditMode={handleToggleEditMode} onToggleEditMode={handleToggleEditMode}
onSubmit={handleClear} onSubmit={handleClear}
@@ -21,7 +21,6 @@ export type EditMode = 'rename' | 'duplicate' | null;
interface ProjectListItemProps { interface ProjectListItemProps {
current?: boolean; current?: boolean;
filename: string; filename: string;
createdAt: string;
updatedAt: string; updatedAt: string;
onToggleEditMode: (editMode: EditMode, filename: string | null) => void; onToggleEditMode: (editMode: EditMode, filename: string | null) => void;
onSubmit: () => void; onSubmit: () => void;
@@ -32,14 +31,13 @@ interface ProjectListItemProps {
export default function ProjectListItem({ export default function ProjectListItem({
current, current,
createdAt, updatedAt,
editingFilename, editingFilename,
editingMode, editingMode,
filename, filename,
onRefetch, onRefetch,
onSubmit, onSubmit,
onToggleEditMode, onToggleEditMode,
updatedAt,
}: ProjectListItemProps) { }: ProjectListItemProps) {
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
@@ -102,7 +100,6 @@ export default function ProjectListItem({
) : ( ) : (
<> <>
<td className={style.containCell}>{filename}</td> <td className={style.containCell}>{filename}</td>
<td>{new Date(createdAt).toLocaleString()}</td>
<td>{new Date(updatedAt).toLocaleString()}</td> <td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}> <td className={style.actionButton}>
<ActionMenu <ActionMenu
@@ -1,9 +1,10 @@
import { ChangeEvent, useEffect, useState } from 'react'; import { ChangeEvent, useEffect, useState } from 'react';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input, Spinner } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark'; import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline'; import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
import { getWorksheetNames } from '../../../../common/api/sheets'; import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { openLink } from '../../../../common/utils/linkUtils'; import { openLink } from '../../../../common/utils/linkUtils';
import * as Panel from '../PanelUtils'; import * as Panel from '../PanelUtils';
@@ -29,7 +30,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
const sheetId = useSheetStore((state) => state.sheetId); const sheetId = useSheetStore((state) => state.sheetId);
const setSheetId = useSheetStore((state) => state.setSheetId); const setSheetId = useSheetStore((state) => state.setSheetId);
const setWorksheets = useSheetStore((state) => state.setWorksheets); const setWorksheets = useSheetStore((state) => state.setWorksheets);
const patchStepData = useSheetStore((state) => state.patchStepData);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus); const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus); const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
@@ -91,8 +92,13 @@ export default function GSheetSetup(props: GSheetSetupProps) {
setAuthenticationStatus(result.authenticated); setAuthenticationStatus(result.authenticated);
if (result.authenticated !== 'pending') { if (result.authenticated !== 'pending') {
if (result.authenticated == 'authenticated') { if (result.authenticated == 'authenticated') {
const names = await getWorksheetNames(result.sheetId); try {
setWorksheets(names); const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
} catch (error) {
const message = maybeAxiosError(error);
patchStepData({ worksheet: { available: false, error: message } });
}
} }
setLoading(''); setLoading('');
return; return;
@@ -184,6 +190,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
) : ( ) : (
<Panel.ListGroup> <Panel.ListGroup>
<div className={style.buttonRow}> <div className={style.buttonRow}>
{isAuthenticating && <Spinner />}
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'> <CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'} {authKey ? authKey : 'Upload files to generate Auth Key'}
</CopyTag> </CopyTag>
@@ -192,8 +199,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
size='sm' size='sm'
leftIcon={<IoShieldCheckmarkOutline />} leftIcon={<IoShieldCheckmarkOutline />}
onClick={handleAuthenticate} onClick={handleAuthenticate}
isDisabled={!canAuthenticate || isLoading} isDisabled={!canAuthenticate}
isLoading={loading === 'authenticate' || isAuthenticating}
> >
Authenticate Authenticate
</Button> </Button>
@@ -26,6 +26,7 @@
.buttonRow { .buttonRow {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
align-items: center;
justify-content: end; justify-content: end;
} }
@@ -2,7 +2,7 @@ import { ChangeEvent, useRef, useState } from 'react';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline'; import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline'; import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { ImportMap, unpackError } from 'ontime-utils'; import { getErrorMessage, ImportMap } from 'ontime-utils';
import { import {
getWorksheetNames as getWorksheetNamesExcel, getWorksheetNames as getWorksheetNamesExcel,
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
setImportFlow('excel'); setImportFlow('excel');
setHasFile('done'); setHasFile('done');
} catch (error) { } catch (error) {
const errorMessage = unpackError(error); const errorMessage = getErrorMessage(error);
setError(`Error uploading file: ${errorMessage}`); setError(`Error uploading file: ${errorMessage}`);
setWorksheets(null); setWorksheets(null);
setHasFile('none'); setHasFile('none');
@@ -15,7 +15,7 @@ export const settingPanels: Readonly<SettingsOption[]> = [
}, },
{ {
id: 'general', id: 'general',
label: 'General', label: 'App Settings',
secondary: [ secondary: [
{ id: 'general__manage', label: 'Manage Ontime settings' }, { id: 'general__manage', label: 'Manage Ontime settings' },
{ id: 'general__view', label: 'View settings' }, { id: 'general__view', label: 'View settings' },
@@ -59,6 +59,7 @@ export const settingPanels: Readonly<SettingsOption[]> = [
] as const; ] as const;
export type SettingsOptionId = (typeof settingPanels)[number]['id']; export type SettingsOptionId = (typeof settingPanels)[number]['id'];
export interface PanelBaseProps { export interface PanelBaseProps {
location?: string; location?: string;
} }
@@ -52,7 +52,7 @@ export default function MessageControl() {
onClick={() => setMessage.timerBlink(!blink)} onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink' data-testid='toggle timer blink'
> >
Blink message Blink
</Button> </Button>
<Button <Button
size='sm' size='sm'
@@ -66,7 +66,7 @@ export default function MessageControl() {
</Button> </Button>
</div> </div>
<InputRow <InputRow
label='External Message (readonly)' label='External Message (read only)'
placeholder={enDash} placeholder={enDash}
readonly readonly
text={message.external.text || ''} text={message.external.text || ''}
@@ -1,7 +1,7 @@
.tableWrapper { .tableWrapper {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
padding: 1rem; padding: 1rem 0.5rem;
display: grid; display: grid;
grid-template-rows: 3rem auto 1fr; grid-template-rows: 3rem auto 1fr;
@@ -1,10 +1,14 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types'; import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu'; import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
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 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 Overview from '../overview/Overview'; import Overview from '../overview/Overview';
@@ -20,16 +24,14 @@ export default function CuesheetWrapper() {
// 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 { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { updateCustomField } = useEventAction(); const { updateCustomField } = 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); const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
// Set window title useWindowTitle('Cuesheet');
useEffect(() => {
document.title = 'ontime - Cuesheet';
}, []);
/** /**
* Handles updating a field * Handles updating a field
@@ -82,9 +84,24 @@ export default function CuesheetWrapper() {
return ( return (
<div className={styles.tableWrapper} data-testid='cuesheet'> <div className={styles.tableWrapper} data-testid='cuesheet'>
<Overview /> <ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<Overview>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoSettingsOutline />}
onClick={() => toggleSettings()}
/>
</Overview>
<CuesheetProgress /> <CuesheetProgress />
<ProductionNavigationMenu handleSettings={() => toggleSettings()} />
<Cuesheet <Cuesheet
data={flatRundown} data={flatRundown}
columns={columns} columns={columns}
@@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useEffect } from 'react';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { import {
closestCenter, closestCenter,
@@ -31,6 +31,12 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups } = props; const { headerGroups } = props;
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder); const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
useEffect(() => {
if (!localStorage.getItem('table-order')) {
saveColumnOrder(initialColumnOrder);
}
}, [saveColumnOrder]);
const handleOnDragEnd = (event: DragEndEvent) => { const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event; const { delta, active, over } = event;
+30 -13
View File
@@ -1,7 +1,11 @@
import { lazy, useCallback, useEffect } from 'react'; import { lazy, useCallback, useEffect } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu'; import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import useElectronEvent from '../../common/hooks/useElectronEvent'; import useElectronEvent from '../../common/hooks/useElectronEvent';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import AppSettings from '../app-settings/AppSettings'; import AppSettings from '../app-settings/AppSettings';
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation'; import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
import Overview from '../overview/Overview'; import Overview from '../overview/Overview';
@@ -13,16 +17,17 @@ const TimerControl = lazy(() => import('../control/playback/TimerControlExport')
const MessageControl = lazy(() => import('../control/message/MessageControlExport')); const MessageControl = lazy(() => import('../control/message/MessageControlExport'));
export default function Editor() { export default function Editor() {
const { isOpen, setLocation, close } = useAppSettingsNavigation(); const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
const { isElectron } = useElectronEvent(); const { isElectron } = useElectronEvent();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const handleSettings = useCallback(() => { const toggleSettings = useCallback(() => {
if (isOpen) { if (isSettingsOpen) {
close(); close();
} else { } else {
setLocation('project'); setLocation('project');
} }
}, [close, isOpen, setLocation]); }, [close, isSettingsOpen, setLocation]);
// Handle keyboard shortcuts // Handle keyboard shortcuts
const handleKeyPress = useCallback( const handleKeyPress = useCallback(
@@ -34,13 +39,13 @@ export default function Editor() {
if (event.ctrlKey || event.metaKey) { if (event.ctrlKey || event.metaKey) {
// ctrl + , (settings) // ctrl + , (settings)
if (event.key === ',') { if (event.key === ',') {
handleSettings(); toggleSettings();
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }
} }
}, },
[handleSettings], [toggleSettings],
); );
// register ctrl + , to open settings // register ctrl + , to open settings
@@ -55,15 +60,28 @@ export default function Editor() {
}; };
}, [handleKeyPress, isElectron]); }, [handleKeyPress, isElectron]);
// Set window title useWindowTitle('Editor');
useEffect(() => {
document.title = 'ontime - Editor';
}, []);
return ( return (
<div className={styles.mainContainer} data-testid='event-editor'> <div className={styles.mainContainer} data-testid='event-editor'>
<ProductionNavigationMenu handleSettings={handleSettings} /> <ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
{isOpen ? ( <Overview>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
size='lg'
icon={<IoSettingsOutline />}
onClick={toggleSettings}
/>
</Overview>
{isSettingsOpen ? (
<AppSettings /> <AppSettings />
) : ( ) : (
<div id='panels' className={styles.panelContainer}> <div id='panels' className={styles.panelContainer}>
@@ -74,7 +92,6 @@ export default function Editor() {
<Rundown /> <Rundown />
</div> </div>
)} )}
<Overview />
</div> </div>
); );
} }
+3 -12
View File
@@ -3,12 +3,12 @@ import { useSearchParams } from 'react-router-dom';
import { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types'; import { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
import { getOperatorOptions } from '../../common/components/view-params-editor/constants'; import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
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';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown'; import useRundown from '../../common/hooks-query/useRundown';
@@ -41,7 +41,7 @@ export default function Operator() {
const timeoutId = useRef<NodeJS.Timeout | null>(null); const timeoutId = useRef<NodeJS.Timeout | null>(null);
const featureData = useOperator(); const featureData = useOperator();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { data: settings } = useSettings(); const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false); const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -57,10 +57,7 @@ export default function Operator() {
topOffset: selectedOffset, topOffset: selectedOffset,
}); });
// Set window title useWindowTitle('Operator');
useEffect(() => {
document.title = 'ontime - Operator';
}, []);
// reset scroll if nothing is selected // reset scroll if nothing is selected
useEffect(() => { useEffect(() => {
@@ -116,11 +113,6 @@ export default function Operator() {
[searchParams], [searchParams],
); );
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const missingData = !data || !customFields || !projectData; const missingData = !data || !customFields || !projectData;
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending'; const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
@@ -146,7 +138,6 @@ export default function Operator() {
return ( return (
<div className={style.operatorContainer}> <div className={style.operatorContainer}>
<ProductionNavigationMenu handleSettings={showEditFormDrawer} />
<ViewParamsEditor paramFields={operatorOptions} /> <ViewParamsEditor paramFields={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />} {editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
@@ -0,0 +1,29 @@
import { useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from '../../common/components/navigation-menu/FloatingNavigation';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import Operator from './Operator';
export default function OperatorExport() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onOpen, onClose } = useDisclosure();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const toggleMenu = isOpen ? onClose : onOpen;
return (
<ProtectRoute permission='operator'>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
<ProductionNavigationMenu isMenuOpen={isOpen} onMenuClose={onClose} />
<Operator />
</ProtectRoute>
);
}
@@ -1,10 +1,20 @@
.overview { .overview {
grid-area: overview; grid-area: overview;
font-size: $inner-section-text-size;
display: flex;
}
.nav {
display: flex;
gap: 0.5rem;
}
.info {
flex: 1;
padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
font-size: $inner-section-text-size;
padding: 0 1rem;
} }
.title { .title {
+19 -11
View File
@@ -12,7 +12,7 @@ import style from './Overview.module.scss';
export default memo(Overview); export default memo(Overview);
function Overview() { function Overview({ children }: { children: React.ReactNode }) {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview(); const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
@@ -24,15 +24,23 @@ function Overview() {
return ( return (
<div className={style.overview}> <div className={style.overview}>
<ErrorBoundary> <ErrorBoundary>
<TitlesOverview /> <div className={style.nav}>{children}</div>
<div className={style.column}> <div className={style.info}>
<TimeRow label='Planned start' value={formatedTime(plannedStart)} className={style.start} /> <TitlesOverview />
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} /> <div>
</div> <TimeRow label='Planned start' value={formatedTime(plannedStart)} className={style.start} />
<RuntimeOverview /> <TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
<div className={style.column}> </div>
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} /> <RuntimeOverview />
<TimeRow label='Expected end' value={expectedEndText} className={style.end} daySpan={maybeExpectedDaySpan} /> <div>
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
/>
</div>
</div> </div>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
@@ -43,7 +51,7 @@ function TitlesOverview() {
const { data } = useProjectData(); const { data } = useProjectData();
return ( return (
<div className={style.titles}> <div>
<div className={style.title}>{data.title}</div> <div className={style.title}>{data.title}</div>
<div className={style.description}>{data.description}</div> <div className={style.description}>{data.description}</div>
</div> </div>
+13 -3
View File
@@ -39,7 +39,6 @@ export default function Rundown({ data }: RundownProps) {
const eventSettings = useEditorSettings((state) => state.eventSettings); const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic; const defaultPublic = eventSettings.defaultPublic;
const linkPrevious = eventSettings.linkPrevious; const linkPrevious = eventSettings.linkPrevious;
const showQuickEntry = eventSettings.showQuickEntry;
// cursor // cursor
const { cursor, mode: appMode, setCursor } = useAppMode(); const { cursor, mode: appMode, setCursor } = useAppMode();
@@ -219,6 +218,8 @@ export default function Rundown({ data }: RundownProps) {
// all events before the current selected are in the past // all events before the current selected are in the past
let isPast = Boolean(featureData?.selectedEventId); let isPast = Boolean(featureData?.selectedEventId);
const isEditMode = appMode === AppMode.Edit;
return ( return (
<div className={style.eventContainer} ref={scrollRef} data-testid='rundown'> <div className={style.eventContainer} ref={scrollRef} data-testid='rundown'>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}> <DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
@@ -248,6 +249,7 @@ export default function Rundown({ data }: RundownProps) {
thisId = eventId; thisId = eventId;
} }
} }
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 === event.id;
const isNext = featureData?.nextEventId === event.id; const isNext = featureData?.nextEventId === event.id;
@@ -258,6 +260,14 @@ export default function Rundown({ data }: RundownProps) {
return ( return (
<Fragment key={event.id}> <Fragment key={event.id}>
{isEditMode && (hasCursor || isFirst) && (
<QuickAddBlock
showKbd={hasCursor ? 'above' : 'none'}
previousEventId={previousEventId}
disableAddDelay={isOntimeDelay(event)}
disableAddBlock={isOntimeBlock(event)}
/>
)}
<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(event) && <div className={style.entryIndex}>{eventIndex}</div>}
<div className={style.entry} key={event.id} ref={hasCursor ? cursorRef : undefined}> <div className={style.entry} key={event.id} ref={hasCursor ? cursorRef : undefined}>
@@ -277,9 +287,9 @@ export default function Rundown({ data }: RundownProps) {
/> />
</div> </div>
</div> </div>
{((showQuickEntry && hasCursor) || isLast) && ( {isEditMode && (hasCursor || isLast) && (
<QuickAddBlock <QuickAddBlock
showKbd={hasCursor} showKbd={hasCursor ? 'below' : 'none'}
previousEventId={event.id} previousEventId={event.id}
disableAddDelay={isOntimeDelay(event)} disableAddDelay={isOntimeDelay(event)}
disableAddBlock={isOntimeBlock(event)} disableAddBlock={isOntimeBlock(event)}
@@ -13,7 +13,18 @@ import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock'; import EventBlock from './event-block/EventBlock';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update' | 'swap'; export type EventItemActions =
| 'set-cursor'
| 'event'
| 'event-before'
| 'delay'
| 'delay-before'
| 'block'
| 'block-before'
| 'delete'
| 'clone'
| 'update'
| 'swap';
interface RundownEntryProps { interface RundownEntryProps {
type: SupportedEvent; type: SupportedEvent;
@@ -83,12 +94,27 @@ export default function RundownEntry(props: RundownEntryProps) {
}; };
return addEvent(newEvent, options); return addEvent(newEvent, options);
} }
case 'event-before': {
const newEvent = { type: SupportedEvent.Event };
const options = {
after: previousEventId,
defaultPublic,
linkPrevious,
};
return addEvent(newEvent, options);
}
case 'delay': { case 'delay': {
return addEvent({ type: SupportedEvent.Delay }, { after: data.id }); return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
} }
case 'delay-before': {
return addEvent({ type: SupportedEvent.Delay }, { after: previousEventId });
}
case 'block': { case 'block': {
return addEvent({ type: SupportedEvent.Block }, { after: data.id }); return addEvent({ type: SupportedEvent.Block }, { after: data.id });
} }
case 'block-before': {
return addEvent({ type: SupportedEvent.Block }, { after: previousEventId });
}
case 'swap': { case 'swap': {
const { value } = payload as FieldValue; const { value } = payload as FieldValue;
return swapEvents({ from: value as string, to: data.id }); return swapEvents({ from: value as string, to: data.id });
@@ -163,9 +189,9 @@ export default function RundownEntry(props: RundownEntryProps) {
/> />
); );
} else if (data.type === SupportedEvent.Block) { } else if (data.type === SupportedEvent.Block) {
return <BlockBlock data={data} hasCursor={hasCursor} actionHandler={actionHandler} />; return <BlockBlock data={data} hasCursor={hasCursor} onDelete={() => actionHandler('delete')} />;
} else if (data.type === SupportedEvent.Delay) { } else if (data.type === SupportedEvent.Delay) {
return <DelayBlock data={data} hasCursor={hasCursor} actionHandler={actionHandler} />; return <DelayBlock data={data} hasCursor={hasCursor} />;
} }
return null; return null;
} }
@@ -1,32 +1,24 @@
import { useEffect, useRef } from 'react'; import { useRef } from 'react';
import { IconButton } from '@chakra-ui/react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo'; import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { OntimeBlock, OntimeEvent } from 'ontime-types'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { OntimeBlock } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import EditableBlockTitle from '../common/EditableBlockTitle'; import EditableBlockTitle from '../common/EditableBlockTitle';
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
import type { EventItemActions } from '../RundownEntry';
import style from './BlockBlock.module.scss'; import style from './BlockBlock.module.scss';
interface BlockBlockProps { interface BlockBlockProps {
data: OntimeBlock; data: OntimeBlock;
hasCursor: boolean; hasCursor: boolean;
actionHandler: ( onDelete: () => void;
action: EventItemActions,
payload?:
| number
| {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
},
) => void;
} }
export default function BlockBlock(props: BlockBlockProps) { export default function BlockBlock(props: BlockBlockProps) {
const { data, hasCursor, actionHandler } = props; const { data, hasCursor, onDelete } = props;
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const { const {
@@ -45,12 +37,6 @@ export default function BlockBlock(props: BlockBlockProps) {
transition, transition,
}; };
useEffect(() => {
if (hasCursor) {
handleRef?.current?.focus();
}
}, [hasCursor]);
const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]); const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]);
return ( return (
@@ -59,7 +45,14 @@ export default function BlockBlock(props: BlockBlockProps) {
<IoReorderTwo /> <IoReorderTwo />
</span> </span>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' /> <EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<BlockActionMenu className={style.actionMenu} enableDelete actionHandler={actionHandler} /> <IconButton
aria-label='Delete'
size='sm'
icon={<IoTrash />}
variant='ontime-subtle'
color='#FA5656'
onClick={onDelete}
/>
</div> </div>
); );
} }
@@ -5,32 +5,21 @@ import { CSS } from '@dnd-kit/utilities';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark'; import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoClose } from '@react-icons/all-files/io5/IoClose'; import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo'; import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { OntimeDelay, OntimeEvent } from 'ontime-types'; import { OntimeDelay } from 'ontime-types';
import DelayInput from '../../../common/components/input/delay-input/DelayInput'; import DelayInput from '../../../common/components/input/delay-input/DelayInput';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
import type { EventItemActions } from '../RundownEntry';
import style from './DelayBlock.module.scss'; import style from './DelayBlock.module.scss';
interface DelayBlockProps { interface DelayBlockProps {
data: OntimeDelay; data: OntimeDelay;
hasCursor: boolean; hasCursor: boolean;
actionHandler: (
action: EventItemActions,
payload?:
| number
| {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
},
) => void;
} }
export default function DelayBlock(props: DelayBlockProps) { export default function DelayBlock(props: DelayBlockProps) {
const { data, hasCursor, actionHandler } = props; const { data, hasCursor } = props;
const { applyDelay, deleteEvent } = useEventAction(); const { applyDelay, deleteEvent } = useEventAction();
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -79,7 +68,6 @@ export default function DelayBlock(props: DelayBlockProps) {
<Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-subtle-white'> <Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-subtle-white'>
Cancel Cancel
</Button> </Button>
<BlockActionMenu enableDelete actionHandler={actionHandler} />
</div> </div>
</div> </div>
); );
@@ -9,7 +9,7 @@ $skip-opacity: 0.2;
display: grid; display: grid;
grid-template-areas: grid-template-areas:
'binder ... ... ...' 'binder ... ... ...'
'binder pb-actions times actions' 'binder pb-actions times ...'
'binder pb-actions title title' 'binder pb-actions title title'
'binder pb-actions estatus estatus' 'binder pb-actions estatus estatus'
'binder ... ... ...'; 'binder ... ... ...';
@@ -55,7 +55,6 @@ $skip-opacity: 0.2;
outline: 1px solid $block-cursor-color; outline: 1px solid $block-cursor-color;
} }
/* we stop the eventActions from having opacity to fix issue with dropdown drawing order */
&.past:not(.skip) { &.past:not(.skip) {
.timerNote, .timerNote,
.statusElements, .statusElements,
@@ -153,12 +152,6 @@ $skip-opacity: 0.2;
} }
} }
.eventActions {
grid-area: actions;
height: 100%;
text-align: end;
}
.progressBg { .progressBg {
grid-area: progb; grid-area: progb;
border-radius: 1px; border-radius: 1px;
@@ -2,16 +2,18 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline'; import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
import { IoPeople } from '@react-icons/all-files/io5/IoPeople'; import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline'; import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo'; import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useAppMode } from '../../../common/stores/appModeStore'; import { useAppMode } from '../../../common/stores/appModeStore';
import copyToClipboard from '../../../common/utils/copyToClipboard';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry'; import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping'; import { useEventIdSwapping } from '../useEventIdSwapping';
@@ -96,31 +98,25 @@ export default function EventBlock(props: EventBlockProps) {
selectedEvents.size > 1 selectedEvents.size > 1
? [ ? [
{ {
label: 'Visiblity', label: 'Make public',
group: [ icon: IoPeople,
{ onClick: () =>
label: 'Make public', actionHandler('update', {
icon: IoPeople, field: 'isPublic',
onClick: () => value: true,
actionHandler('update', { }),
field: 'isPublic', },
value: true, {
}), label: 'Make private',
}, icon: IoPeopleOutline,
{ onClick: () =>
label: 'Make private', actionHandler('update', {
icon: IoPeopleOutline, field: 'isPublic',
onClick: () => value: false,
actionHandler('update', { }),
field: 'isPublic',
value: false,
}),
},
],
}, },
] ]
: [ : [
{ label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
{ {
label: 'Toggle public', label: 'Toggle public',
icon: IoPeopleOutline, icon: IoPeopleOutline,
@@ -145,6 +141,14 @@ export default function EventBlock(props: EventBlockProps) {
}, },
isDisabled: selectedEventId == null || selectedEventId === eventId, isDisabled: selectedEventId == null || selectedEventId === eventId,
}, },
{ withDivider: true, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') },
{ withDivider: true, label: 'Event before', icon: IoAdd, onClick: () => actionHandler('event-before') },
{ label: 'Event after', icon: IoAdd, onClick: () => actionHandler('event') },
{ label: 'Block before', icon: IoRemoveCircleOutline, onClick: () => actionHandler('block-before') },
{ label: 'Block after', icon: IoRemoveCircleOutline, onClick: () => actionHandler('block') },
{ label: 'Delay before', icon: IoTimerOutline, onClick: () => actionHandler('delay-before') },
{ label: 'Delay after', icon: IoTimerOutline, onClick: () => actionHandler('delay') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
], ],
); );
@@ -281,7 +285,6 @@ export default function EventBlock(props: EventBlockProps) {
loaded={loaded} loaded={loaded}
playback={playback} playback={playback}
isRolling={isRolling} isRolling={isRolling}
actionHandler={actionHandler}
/> />
)} )}
</div> </div>
@@ -14,10 +14,8 @@ import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontim
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig'; import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle'; import EditableBlockTitle from '../common/EditableBlockTitle';
import { EventItemActions } from '../RundownEntry';
import TimeInputFlow from '../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../time-input-flow/TimeInputFlow';
import BlockActionMenu from './composite/BlockActionMenu';
import EventBlockPlayback from './composite/EventBlockPlayback'; import EventBlockPlayback from './composite/EventBlockPlayback';
import EventBlockProgressBar from './composite/EventBlockProgressBar'; import EventBlockProgressBar from './composite/EventBlockProgressBar';
@@ -46,7 +44,6 @@ interface EventBlockInnerProps {
loaded: boolean; loaded: boolean;
playback?: Playback; playback?: Playback;
isRolling: boolean; isRolling: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void;
} }
const EventBlockInner = (props: EventBlockInnerProps) => { const EventBlockInner = (props: EventBlockInnerProps) => {
@@ -68,7 +65,6 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
loaded, loaded,
playback, playback,
isRolling, isRolling,
actionHandler,
} = props; } = props;
const [renderInner, setRenderInner] = useState(false); const [renderInner, setRenderInner] = useState(false);
@@ -139,9 +135,6 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div className={style.eventActions}>
<BlockActionMenu showClone enableDelete={!loaded} actionHandler={actionHandler} />
</div>
</> </>
); );
}; };
@@ -1,64 +0,0 @@
import { useCallback } from 'react';
import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
import { IoTrashBinSharp } from '@react-icons/all-files/io5/IoTrashBinSharp';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import { EventItemActions } from '../../RundownEntry';
interface BlockActionMenuProps {
enableDelete?: boolean;
showClone?: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void;
className?: string;
}
export default function BlockActionMenu(props: BlockActionMenuProps) {
const { enableDelete, showClone, actionHandler, className } = props;
const handleAddEvent = useCallback(() => actionHandler('event'), [actionHandler]);
const handleAddDelay = useCallback(() => actionHandler('delay'), [actionHandler]);
const handleAddBlock = useCallback(() => actionHandler('block'), [actionHandler]);
const handleClone = useCallback(() => actionHandler('clone'), [actionHandler]);
const handleDelete = useCallback(() => actionHandler('delete'), [actionHandler]);
return (
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<Tooltip label='Add ...' openDelay={tooltipDelayMid}>
<MenuButton
as={IconButton}
aria-label='Event options'
icon={<IoEllipsisHorizontal />}
tabIndex={-1}
variant='ontime-ghosted-white'
size='sm'
className={className}
/>
</Tooltip>
<MenuList>
<MenuItem icon={<IoAdd />} onClick={handleAddEvent}>
Add Event after
</MenuItem>
<MenuItem icon={<IoTimerOutline />} onClick={handleAddDelay}>
Add Delay after
</MenuItem>
<MenuItem icon={<IoRemoveCircleOutline />} onClick={handleAddBlock}>
Add Block after
</MenuItem>
{showClone && (
<MenuItem icon={<IoDuplicateOutline />} onClick={handleClone}>
Clone event
</MenuItem>
)}
<MenuDivider />
<MenuItem icon={<IoTrashBinSharp />} onClick={handleDelete} isDisabled={!enableDelete} color='#D20300'>
Delete
</MenuItem>
</MenuList>
</Menu>
);
}
@@ -23,7 +23,7 @@
padding: 0 0.25rem; padding: 0 0.25rem;
color: $label-gray; color: $label-gray;
border-radius: 2px; border-radius: 2px;
background-color: $black-10 background-color: $black-10;
} }
.options { .options {
@@ -12,14 +12,14 @@ import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './QuickAddBlock.module.scss'; import style from './QuickAddBlock.module.scss';
interface QuickAddBlockProps { interface QuickAddBlockProps {
showKbd: boolean; showKbd: 'above' | 'below' | 'none';
previousEventId: string; previousEventId?: string;
disableAddDelay?: boolean; disableAddDelay?: boolean;
disableAddBlock: boolean; disableAddBlock: boolean;
} }
const QuickAddBlock = (props: QuickAddBlockProps) => { const QuickAddBlock = (props: QuickAddBlockProps) => {
const { showKbd, previousEventId, disableAddDelay = true, disableAddBlock } = props; const { showKbd = 'none', previousEventId, disableAddDelay = true, disableAddBlock } = props;
const { addEvent } = useEventAction(); const { addEvent } = useEventAction();
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
@@ -28,6 +28,8 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
const { defaultPublic, linkPrevious } = useEditorSettings((state) => state.eventSettings); const { defaultPublic, linkPrevious } = useEditorSettings((state) => state.eventSettings);
const shortcutBase = showKbd === 'none' ? '' : `${deviceAlt} ${showKbd === 'above' ? '⇧' : ''}`;
const handleCreateEvent = useCallback( const handleCreateEvent = useCallback(
(eventType: SupportedEvent) => { (eventType: SupportedEvent) => {
switch (eventType) { switch (eventType) {
@@ -70,6 +72,9 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
[previousEventId, addEvent, emitError], [previousEventId, addEvent, emitError],
); );
const canLinkPrevious = Boolean(previousEventId);
const shouldLinkPrevious = Boolean(linkPrevious) && canLinkPrevious;
return ( return (
<div className={style.quickAdd}> <div className={style.quickAdd}>
<div className={style.btnRow}> <div className={style.btnRow}>
@@ -79,10 +84,9 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
size='xs' size='xs'
variant='ontime-subtle-white' variant='ontime-subtle-white'
className={style.quickBtn} className={style.quickBtn}
data-testid='quick-add-event'
leftIcon={<IoAdd />} leftIcon={<IoAdd />}
> >
Event {showKbd && <span className={style.keyboard}>{`${deviceAlt} + E`}</span>} Event {shortcutBase && <span className={style.keyboard}>{`${shortcutBase} E`}</span>}
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}> <Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
@@ -92,10 +96,9 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
variant='ontime-subtle-white' variant='ontime-subtle-white'
disabled={disableAddDelay} disabled={disableAddDelay}
className={style.quickBtn} className={style.quickBtn}
data-testid='quick-add-delay'
leftIcon={<IoAdd />} leftIcon={<IoAdd />}
> >
Delay {showKbd && <span className={style.keyboard}>{`${deviceAlt} + D`}</span>} Delay {shortcutBase && <span className={style.keyboard}>{`${shortcutBase} D`}</span>}
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip label='Add Block' openDelay={tooltipDelayMid}> <Tooltip label='Add Block' openDelay={tooltipDelayMid}>
@@ -105,15 +108,20 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
variant='ontime-subtle-white' variant='ontime-subtle-white'
disabled={disableAddBlock} disabled={disableAddBlock}
className={style.quickBtn} className={style.quickBtn}
data-testid='quick-add-block'
leftIcon={<IoAdd />} leftIcon={<IoAdd />}
> >
Block {showKbd && <span className={style.keyboard}>{`${deviceAlt} + B`}</span>} Block {shortcutBase && <span className={style.keyboard}>{`${shortcutBase} B`}</span>}
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
<div className={style.options}> <div className={style.options}>
<Checkbox ref={doLinkPrevious} size='sm' variant='ontime-ondark' defaultChecked={linkPrevious}> <Checkbox
ref={doLinkPrevious}
size='sm'
variant='ontime-ondark'
isDisabled={!canLinkPrevious}
defaultChecked={shouldLinkPrevious}
>
Link to previous Link to previous
</Checkbox> </Checkbox>
<Checkbox ref={doPublic} size='sm' variant='ontime-ondark' defaultChecked={defaultPublic}> <Checkbox ref={doPublic} size='sm' variant='ontime-ondark' defaultChecked={defaultPublic}>
@@ -1,8 +1,6 @@
import { Button, ButtonGroup, MenuButton } from '@chakra-ui/react'; import { ButtonGroup } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoOptions } from '@react-icons/all-files/io5/IoOptions'; import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoSnowOutline } from '@react-icons/all-files/io5/IoSnowOutline';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn'; import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { AppMode, useAppMode } from '../../../common/stores/appModeStore'; import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
@@ -16,20 +14,10 @@ export default function RundownHeader() {
const setAppMode = useAppMode((state) => state.setMode); const setAppMode = useAppMode((state) => state.setMode);
const setRunMode = () => setAppMode(AppMode.Run); const setRunMode = () => setAppMode(AppMode.Run);
const setEditMode = () => setAppMode(AppMode.Edit); const setEditMode = () => setAppMode(AppMode.Edit);
const setFreezeMode = () => setAppMode(AppMode.Freeze);
return ( return (
<div className={style.header}> <div className={style.header}>
<ButtonGroup isAttached> <ButtonGroup isAttached>
<TooltipActionBtn
variant={appMode === AppMode.Freeze ? 'ontime-filled' : 'ontime-outlined'}
size='sm'
icon={<IoSnowOutline />}
clickHandler={setFreezeMode}
tooltip='Freeze rundown'
aria-label='Freeze rundown'
isDisabled
/>
<TooltipActionBtn <TooltipActionBtn
variant={appMode === AppMode.Run ? 'ontime-filled' : 'ontime-outlined'} variant={appMode === AppMode.Run ? 'ontime-filled' : 'ontime-outlined'}
size='sm' size='sm'
@@ -47,11 +35,7 @@ export default function RundownHeader() {
aria-label='Edit mode' aria-label='Edit mode'
/> />
</ButtonGroup> </ButtonGroup>
<RundownMenu> <RundownMenu />
<MenuButton size='sm' as={Button} rightIcon={<IoAdd />} aria-label='Rundown menu' variant='ontime-outlined'>
Rundown
</MenuButton>
</RundownMenu>
</div> </div>
); );
} }
@@ -1,31 +1,16 @@
import { memo, ReactNode, useCallback } from 'react'; import { useCallback } from 'react';
import { Menu, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
import { IoTrashOutline } from '@react-icons/all-files/io5/IoTrashOutline';
import { SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import { useAppMode } from '../../../common/stores/appModeStore'; import { useAppMode } from '../../../common/stores/appModeStore';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
const RundownMenu = ({ children }: { children: ReactNode }) => { export default function RundownMenu() {
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const setCursor = useAppMode((state) => state.setCursor); const setCursor = useAppMode((state) => state.setCursor);
const { addEvent, deleteAllEvents } = useEventAction(); const appMode = useAppMode((state) => state.mode);
const { deleteAllEvents } = useEventAction();
const newEvent = useCallback(() => {
addEvent({ type: SupportedEvent.Event });
}, [addEvent]);
const newBlock = useCallback(() => {
addEvent({ type: SupportedEvent.Block });
}, [addEvent]);
const newDelay = useCallback(() => {
addEvent({ type: SupportedEvent.Delay });
}, [addEvent]);
const deleteAll = useCallback(() => { const deleteAll = useCallback(() => {
deleteAllEvents(); deleteAllEvents();
@@ -34,25 +19,15 @@ const RundownMenu = ({ children }: { children: ReactNode }) => {
}, [clearSelectedEvents, deleteAllEvents, setCursor]); }, [clearSelectedEvents, deleteAllEvents, setCursor]);
return ( return (
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark' placement='right-start'> <Button
{children} size='sm'
<MenuList> variant='ontime-outlined'
<MenuItem icon={<IoAdd />} onClick={newEvent}> leftIcon={<IoTrash />}
Add event at start onClick={deleteAll}
</MenuItem> color='#FA5656'
<MenuItem icon={<IoTimerOutline />} onClick={newDelay}> isDisabled={appMode === 'run'}
Add delay at start >
</MenuItem> Clear rundown
<MenuItem icon={<IoRemoveCircleOutline />} onClick={newBlock}> </Button>
Add block at start
</MenuItem>
<MenuDivider />
<MenuItem icon={<IoTrashOutline />} onClick={deleteAll} color='#D20300'>
Delete all events
</MenuItem>
</MenuList>
</Menu>
); );
}; }
export default memo(RundownMenu);
@@ -14,6 +14,7 @@ import TitleCard from '../../../common/components/title-card/TitleCard';
import { getBackstageOptions } from '../../../common/components/view-params-editor/constants'; import { getBackstageOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
@@ -57,10 +58,7 @@ export default function Backstage(props: BackstageProps) {
const [blinkClass, setBlinkClass] = useState(false); const [blinkClass, setBlinkClass] = useState(false);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// Set window title useWindowTitle('Backstage');
useEffect(() => {
document.title = 'ontime - Backstage Screen';
}, []);
// blink on change // blink on change
useEffect(() => { useEffect(() => {
@@ -1,4 +1,3 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Settings, ViewSettings } from 'ontime-types'; import { Settings, ViewSettings } from 'ontime-types';
@@ -6,6 +5,7 @@ import { overrideStylesURL } from '../../../common/api/constants';
import { getClockOptions } from '../../../common/components/view-params-editor/constants'; import { getClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { OverridableOptions } from '../../../common/models/View.types'; import { OverridableOptions } from '../../../common/models/View.types';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
@@ -25,9 +25,7 @@ export default function Clock(props: ClockProps) {
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
useEffect(() => { useWindowTitle('Clock');
document.title = 'ontime - Clock';
}, []);
// defer rendering until we load stylesheets // defer rendering until we load stylesheets
if (!shouldRender) { if (!shouldRender) {
@@ -7,6 +7,7 @@ import { overrideStylesURL } from '../../../common/api/constants';
import { getCountdownOptions } from '../../../common/components/view-params-editor/constants'; import { getCountdownOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
@@ -37,9 +38,7 @@ export default function Countdown(props: CountdownProps) {
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled); const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
const [delay, setDelay] = useState(0); const [delay, setDelay] = useState(0);
useEffect(() => { useWindowTitle('Countdown');
document.title = 'ontime - Countdown';
}, []);
// eg. http://localhost:4001/countdown?eventId=ei0us // eg. http://localhost:4001/countdown?eventId=ei0us
// Check for user options // Check for user options
@@ -6,6 +6,7 @@ import { overrideStylesURL } from '../../../common/api/constants';
import { getLowerThirdOptions } from '../../../common/components/view-params-editor/constants'; import { getLowerThirdOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import './LowerThird.scss'; import './LowerThird.scss';
@@ -141,10 +142,7 @@ export default function LowerThird(props: LowerProps) {
const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre'); const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre');
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
// set window title useWindowTitle('Lower Third');
useEffect(() => {
document.title = 'ontime - Lower Third';
}, []);
const trigger = useMemo(() => { const trigger = useMemo(() => {
if (options.trigger === TriggerType.Event) { if (options.trigger === TriggerType.Event) {
@@ -1,4 +1,3 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types'; import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils'; import { MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
@@ -7,6 +6,7 @@ import { overrideStylesURL } from '../../../common/api/constants';
import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants'; import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { OverridableOptions } from '../../../common/models/View.types'; import { OverridableOptions } from '../../../common/models/View.types';
import { timerPlaceholder } from '../../../common/utils/styleUtils'; import { timerPlaceholder } from '../../../common/utils/styleUtils';
@@ -28,9 +28,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
useEffect(() => { useWindowTitle('Minimal Timer');
document.title = 'ontime - Minimal Timer';
}, []);
// defer rendering until we load stylesheets // defer rendering until we load stylesheets
if (!shouldRender) { if (!shouldRender) {
@@ -1,4 +1,3 @@
import { useEffect } from 'react';
import QRCode from 'react-qr-code'; import QRCode from 'react-qr-code';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
@@ -12,6 +11,7 @@ import TitleCard from '../../../common/components/title-card/TitleCard';
import { getPublicOptions } from '../../../common/components/view-params-editor/constants'; import { getPublicOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
@@ -54,10 +54,7 @@ export default function Public(props: BackstageProps) {
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// set window title useWindowTitle('Public Schedule');
useEffect(() => {
document.title = 'ontime - Public Screen';
}, []);
// defer rendering until we load stylesheets // defer rendering until we load stylesheets
if (!shouldRender) { if (!shouldRender) {
@@ -1,4 +1,3 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types'; import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
import { isOntimeEvent, Playback } from 'ontime-types'; import { isOntimeEvent, Playback } from 'ontime-types';
@@ -9,6 +8,7 @@ import { getStudioClockOptions } from '../../../common/components/view-params-ed
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import useFitText from '../../../common/hooks/useFitText'; import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
@@ -46,9 +46,7 @@ export default function StudioClock(props: StudioClockProps) {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
useEffect(() => { useWindowTitle('Studio Clock');
document.title = 'ontime - Studio Clock';
}, []);
let clock = formatTime(time.clock); let clock = formatTime(time.clock);
let hasAmPm = ''; let hasAmPm = '';
@@ -32,6 +32,8 @@
.blackout { .blackout {
position: absolute; position: absolute;
top: 0;
left: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background-color: #000; background-color: #000;
@@ -1,4 +1,3 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { import {
@@ -19,6 +18,7 @@ import TitleCard from '../../../common/components/title-card/TitleCard';
import { getTimerOptions } from '../../../common/components/view-params-editor/constants'; import { getTimerOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { timerPlaceholder } from '../../../common/utils/styleUtils'; import { timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
@@ -63,9 +63,7 @@ export default function Timer(props: TimerProps) {
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
useEffect(() => { useWindowTitle('Timer');
document.title = 'ontime - Timer';
}, []);
// defer rendering until we load stylesheets // defer rendering until we load stylesheets
if (!shouldRender) { if (!shouldRender) {
+13 -1
View File
@@ -3,12 +3,24 @@ export const ontimeCheckboxOnDark = {
border: '1px', border: '1px',
borderColor: '#2d2d2d', // $gray-1100 borderColor: '#2d2d2d', // $gray-1100
backgroundColor: '#2d2d2d', // $gray-1100 backgroundColor: '#2d2d2d', // $gray-1100
_disabled: {
color: 'white',
borderColor: '#2d2d2d', // $gray-1100
backgroundColor: '#2d2d2d', // $gray-1100
opacity: 0.6,
},
_checked: { _checked: {
borderColor: '#3182ce', // $action-blue borderColor: '#3182ce', // $action-blue
backgroundColor: '#3182ce', //$action-blue backgroundColor: '#3182ce', //$action-blue
_disabled: {
color: 'white',
borderColor: '#3182ce', // $action-blue
backgroundColor: '#3182ce', //$action-blue
opacity: 0.6,
},
}, },
_focus: { _focus: {
boxShadow: '0 0 0 1px #578AF4', // $blue-500 boxShadow: 'none',
}, },
}, },
label: { label: {
+15 -7
View File
@@ -1,19 +1,27 @@
export const ontimeMenuOnDark = { export const ontimeMenuOnDark = {
list: { list: {
fontSize: 'calc(1rem - 2px)',
borderRadius: '3px', borderRadius: '3px',
border: 'none', borderColor: 'rgba(255, 255, 255, 0.1)',
bg: '#fff', // $gray-50 color: '#ececec', // $gray-1030
backgroundColor: '#202020', // $gray-1250
zIndex: 100, zIndex: 100,
}, },
item: { item: {
letterSpacing: '0.15px', backgroundColor: 'transparent',
color: '#101010', // $gray-1350 paddingBlock: '0.5rem',
bg: '#fff', //
_hover: { _hover: {
backgroundColor: '#e2e2e2', // $gray-200 backgroundColor: 'rgba(0, 0, 0, 0.1)',
_disabled: {
backgroundColor: 'transparent',
},
},
_disabled: {
color: '#b1b1b1', // $gray-400
}, },
}, },
divider: { divider: {
borderColor: '#cfcfcf', // $gray-200 borderColor: 'rgba(255, 255, 255, 0.07)',
opacity: 1,
}, },
}; };
+2 -2
View File
@@ -57,8 +57,8 @@ const theme = extendTheme({
}, },
Drawer: { Drawer: {
variants: { variants: {
'ontime': {...ontimeDrawer}, ontime: { ...ontimeDrawer },
} },
}, },
Editable: { Editable: {
variants: { variants: {
+1
View File
@@ -43,6 +43,7 @@
"nodemon": "^2.0.20", "nodemon": "^2.0.20",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"server-timing": "^3.3.3",
"shx": "^0.3.4", "shx": "^0.3.4",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.4.3", "typescript": "^5.4.3",
+7 -9
View File
@@ -116,16 +116,14 @@ export class SocketServer implements IAdapter {
// Protocol specific stuff handled above // Protocol specific stuff handled above
try { try {
const reply = dispatchFromAdapter( const reply = dispatchFromAdapter(type, { payload }, 'ws');
type,
{
payload,
},
'ws',
);
if (reply) { if (reply) {
const { payload } = reply; ws.send(
ws.send(type, payload); JSON.stringify({
type: 'ontime-change',
payload: reply.payload,
}),
);
} }
} catch (error) { } catch (error) {
logger.error(LogOrigin.Rx, `WS IN: ${error}`); logger.error(LogOrigin.Rx, `WS IN: ${error}`);
@@ -1,7 +1,8 @@
import { CustomField, CustomFields } from 'ontime-types'; import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { getErrorMessage } from 'ontime-utils';
import { import {
createCustomField, createCustomField,
editCustomField, editCustomField,
@@ -14,36 +15,37 @@ export async function getCustomFields(_req: Request, res: Response<CustomFields>
res.json(customFields); res.json(customFields);
} }
// Expects { label: <label> type: 'string | ..' } export async function postCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
export async function postCustomField(req: Request, res: Response) {
try { try {
const newField = req.body as CustomField; const newField = req.body as CustomField;
const allFields = await createCustomField(newField); const allFields = await createCustomField(newField);
res.status(201).send(allFields); res.status(201).send(allFields);
} catch (error) { } catch (error) {
res.status(400).send({ message: error.toString() }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
// Expects { label: <oldLabel>, field: { label: <newLabel> type: 'string | ..' } } export async function putCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
export async function putCustomField(req: Request, res: Response) {
try { try {
const oldLabel = req.params.label; const oldLabel = req.params.label;
const { colour, type, label } = req.body; const { colour, type, label } = req.body;
const newFields = await editCustomField(oldLabel, { label, colour, type }); const newFields = await editCustomField(oldLabel, { label, colour, type });
res.status(200).send(newFields); res.status(200).send(newFields);
} catch (error) { } catch (error) {
res.status(400).send({ message: error.toString() }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
// Expects { label: <label> } // Expects { label: <label> }
export async function deleteCustomField(req: Request, res: Response) { export async function deleteCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try { try {
const fieldToDelete = req.params.label; const fieldToDelete = req.params.label;
await removeCustomField(fieldToDelete); await removeCustomField(fieldToDelete);
res.sendStatus(204); res.sendStatus(204);
} catch (error) { } catch (error) {
res.status(400).send({ message: error.toString() }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
+21 -13
View File
@@ -16,6 +16,7 @@ import * as projectService from '../../services/project-service/ProjectService.j
import { ensureJsonExtension } from '../../utils/fileManagement.js'; import { ensureJsonExtension } from '../../utils/fileManagement.js';
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js'; import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
import { appStateService } from '../../services/app-state-service/AppStateService.js'; import { appStateService } from '../../services/app-state-service/AppStateService.js';
import { getErrorMessage } from 'ontime-utils';
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) { export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
// all fields are optional in validation // all fields are optional in validation
@@ -31,7 +32,8 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
const newData = await projectService.applyDataModel(patchDb); const newData = await projectService.applyDataModel(patchDb);
res.status(200).send(newData); res.status(200).send(newData);
} catch (error) { } catch (error) {
res.status(400).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -69,17 +71,17 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
filename, filename,
}); });
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(500).send({ message });
} }
} }
export async function projectDownload(_req: Request, res: Response) { export async function projectDownload(_req: Request, res: Response) {
const fileTitle = projectService.getProjectTitle(); const fileTitle = projectService.getProjectTitle();
res.download(resolveDbPath, `${fileTitle}.json`, (err) => { res.download(resolveDbPath, `${fileTitle}.json`, (error) => {
if (err) { if (error) {
res.status(500).send({ const message = getErrorMessage(error);
message: `Could not download the file: ${err}`, res.status(500).send({ message });
});
} }
}); });
} }
@@ -104,7 +106,8 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
message: `Loaded project ${filename}`, message: `Loaded project ${filename}`,
}); });
} catch (error) { } catch (error) {
res.status(400).send({ message: `Failed parsing ${error}` }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -116,7 +119,8 @@ export async function listProjects(_req: Request, res: Response<ProjectFileListR
const data = await projectService.getProjectList(); const data = await projectService.getProjectList();
res.status(200).send(data); res.status(200).send(data);
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(500).send({ message });
} }
} }
@@ -136,7 +140,8 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
message: `Loaded project ${name}`, message: `Loaded project ${name}`,
}); });
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(500).send({ message });
} }
} }
@@ -167,7 +172,8 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
message: `Duplicated project ${filename} to ${newFilename}`, message: `Duplicated project ${filename} to ${newFilename}`,
}); });
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(500).send({ message });
} }
} }
@@ -199,7 +205,8 @@ export async function renameProjectFile(req: Request, res: Response<MessageRespo
message: `Renamed project ${filename} to ${newFilename}`, message: `Renamed project ${filename} to ${newFilename}`,
}); });
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(500).send({ message });
} }
} }
@@ -235,7 +242,8 @@ export async function deleteProjectFile(req: Request, res: Response<MessageRespo
message: `Deleted project ${filename}`, message: `Deleted project ${filename}`,
}); });
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(500).send({ message });
} }
} }
@@ -3,10 +3,13 @@
* Google Sheets * Google Sheets
*/ */
import { CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { extname } from 'path'; import { extname } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { ImportMap } from 'ontime-utils';
import xlsx from 'node-xlsx'; import xlsx from 'node-xlsx';
import { parseExcel } from '../../utils/parser.js'; import { parseExcel } from '../../utils/parser.js';
import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js'; import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js';
import { deleteFile } from '../../utils/parserUtils.js'; import { deleteFile } from '../../utils/parserUtils.js';
@@ -29,7 +32,7 @@ export function listWorksheets() {
return excelData.map((value) => value.name); return excelData.map((value) => value.name);
} }
export function generateRundownPreview(options: ImportMap) { export function generateRundownPreview(options: ImportMap): { rundown: OntimeRundown; customFields: CustomFields } {
const data = excelData.find(({ name }) => name.toLowerCase() === options.worksheet.toLowerCase())?.data; const data = excelData.find(({ name }) => name.toLowerCase() === options.worksheet.toLowerCase())?.data;
if (!data) { if (!data) {
@@ -39,15 +42,14 @@ export function generateRundownPreview(options: ImportMap) {
const dataFromExcel = parseExcel(data, options); const dataFromExcel = parseExcel(data, options);
// we run the parsed data through an extra step to ensure the objects shape // we run the parsed data through an extra step to ensure the objects shape
const result = { rundown: [], customFields: {} }; const rundown = parseRundown(dataFromExcel);
result.rundown = parseRundown(dataFromExcel); if (rundown.length === 0) {
if (result.rundown.length < 1) {
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`); throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
} }
result.customFields = parseCustomFields(dataFromExcel); const customFields = parseCustomFields(dataFromExcel);
//clear the data // clear the data
excelData = []; excelData = [];
return result; return { rundown, customFields };
} }
@@ -5,6 +5,7 @@ import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js'; import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { failEmptyObjects } from '../../utils/routerUtils.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js'; import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { getErrorMessage } from 'ontime-utils';
export async function getHTTP(_req: Request, res: Response<HttpSettings>) { export async function getHTTP(_req: Request, res: Response<HttpSettings>) {
const http = DataProvider.getHttp(); const http = DataProvider.getHttp();
@@ -24,6 +25,7 @@ export async function postHTTP(req: Request, res: Response<HttpSettings | ErrorR
const result = await DataProvider.setHttp(httpSettings); const result = await DataProvider.setHttp(httpSettings);
res.send(result).status(200); res.send(result).status(200);
} catch (error) { } catch (error) {
res.status(400).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
+5
View File
@@ -25,3 +25,8 @@ appRouter.use('/sheets', sheetsRouter);
appRouter.use('/excel', excelRouter); appRouter.use('/excel', excelRouter);
appRouter.use('/url-presets', urlPresetsRouter); appRouter.use('/url-presets', urlPresetsRouter);
appRouter.use('/view-settings', viewSettingsRouter); appRouter.use('/view-settings', viewSettingsRouter);
//we don't want to redirect to react index when using api routes
appRouter.all('/*', (_req, res) => {
res.status(404).send();
});
@@ -5,6 +5,7 @@ import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js'; import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { failEmptyObjects } from '../../utils/routerUtils.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js'; import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { getErrorMessage } from 'ontime-utils';
export async function getOSC(_req: Request, res: Response<OSCSettings>) { export async function getOSC(_req: Request, res: Response<OSCSettings>) {
const osc = DataProvider.getOsc(); const osc = DataProvider.getOsc();
@@ -24,6 +25,7 @@ export async function postOSC(req: Request, res: Response<OSCSettings | ErrorRes
const result = await DataProvider.setOsc(oscSettings); const result = await DataProvider.setOsc(oscSettings);
res.send(result).status(200); res.send(result).status(200);
} catch (error) { } catch (error) {
res.status(400).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -4,6 +4,7 @@ import type { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js'; import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { removeUndefined } from '../../utils/parserUtils.js'; import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils';
export async function getProjectData(_req: Request, res: Response<ProjectData>) { export async function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(DataProvider.getProjectData()); res.json(DataProvider.getProjectData());
@@ -27,6 +28,7 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
const newData = await DataProvider.setProjectData(newEvent); const newData = await DataProvider.setProjectData(newEvent);
res.status(200).send(newData); res.status(200).send(newData);
} catch (error) { } catch (error) {
res.status(400).send({ message: error.toString() }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -14,6 +14,7 @@ import {
swapEvents, swapEvents,
} from '../../services/rundown-service/RundownService.js'; } from '../../services/rundown-service/RundownService.js';
import { getNormalisedRundown, getRundown } from '../../services/rundown-service/rundownUtils.js'; import { getNormalisedRundown, getRundown } from '../../services/rundown-service/rundownUtils.js';
import { getErrorMessage } from 'ontime-utils';
export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) { export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) {
const rundown = getRundown(); const rundown = getRundown();
@@ -34,7 +35,8 @@ export async function rundownPost(req: Request, res: Response<OntimeRundownEntry
const newEvent = await addEvent(req.body); const newEvent = await addEvent(req.body);
res.status(201).send(newEvent); res.status(201).send(newEvent);
} catch (error) { } catch (error) {
res.status(400).send({ message: error.toString() }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -47,7 +49,8 @@ export async function rundownPut(req: Request, res: Response<OntimeRundownEntry
const event = await editEvent(req.body); const event = await editEvent(req.body);
res.status(200).send(event); res.status(200).send(event);
} catch (error) { } catch (error) {
res.status(400).send({ message: error.toString() }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -61,7 +64,8 @@ export async function rundownBatchPut(req: Request, res: Response<MessageRespons
await batchEditEvents(ids, data); await batchEditEvents(ids, data);
res.status(200).send({ message: 'Batch edit successful' }); res.status(200).send({ message: 'Batch edit successful' });
} catch (error) { } catch (error) {
res.status(400).send(error); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -75,7 +79,8 @@ export async function rundownReorder(req: Request, res: Response<OntimeRundownEn
const event = await reorderEvent(eventId, from, to); const event = await reorderEvent(eventId, from, to);
res.status(200).send(event.newEvent); res.status(200).send(event.newEvent);
} catch (error) { } catch (error) {
res.status(400).send({ message: error.toString() }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -89,7 +94,8 @@ export async function rundownSwap(req: Request, res: Response<MessageResponse |
await swapEvents(from, to); await swapEvents(from, to);
res.status(200).send({ message: 'Swap successful' }); res.status(200).send({ message: 'Swap successful' });
} catch (error) { } catch (error) {
res.status(400).send({ message: error.toString() }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -98,7 +104,8 @@ export async function rundownApplyDelay(req: Request, res: Response<MessageRespo
await applyDelay(req.params.eventId); await applyDelay(req.params.eventId);
res.status(200).send({ message: 'Delay applied' }); res.status(200).send({ message: 'Delay applied' });
} catch (error) { } catch (error) {
res.status(400).send({ message: error.toString() }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -107,7 +114,8 @@ export async function rundownDelete(_req: Request, res: Response<MessageResponse
await deleteAllEvents(); await deleteAllEvents();
res.status(204).send({ message: 'All events deleted' }); res.status(204).send({ message: 'All events deleted' });
} catch (error) { } catch (error) {
res.status(400).send({ message: error.toString() }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -116,6 +124,7 @@ export async function deleteEventById(req: Request, res: Response<MessageRespons
await deleteEvent(req.params.eventId); await deleteEvent(req.params.eventId);
res.status(204).send({ message: 'Event deleted' }); res.status(204).send({ message: 'Event deleted' });
} catch (error) { } catch (error) {
res.status(400).send({ message: error.toString() }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -6,6 +6,7 @@ import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { failEmptyObjects } from '../../utils/routerUtils.js';
import { extractPin } from '../../services/project-service/ProjectService.js'; import { extractPin } from '../../services/project-service/ProjectService.js';
import { isDocker } from '../../setup/index.js'; import { isDocker } from '../../setup/index.js';
import { getErrorMessage } from 'ontime-utils';
import { obfuscate } from 'ontime-utils'; import { obfuscate } from 'ontime-utils';
export async function getSettings(_req: Request, res: Response<Settings>) { export async function getSettings(_req: Request, res: Response<Settings>) {
@@ -31,6 +32,7 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
const editorKey = extractPin(req.body?.editorKey, settings.editorKey); const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey); const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
const serverPort = Number(req.body?.serverPort); const serverPort = Number(req.body?.serverPort);
//TODO: should this not be part of the validator?
if (isNaN(serverPort)) { if (isNaN(serverPort)) {
return res.status(400).send({ message: `Invalid value found for server port: ${req.body?.serverPort}` }); return res.status(400).send({ message: `Invalid value found for server port: ${req.body?.serverPort}` });
} }
@@ -59,6 +61,7 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
await DataProvider.setSettings(newData); await DataProvider.setSettings(newData);
res.status(200).send(newData); res.status(200).send(newData);
} catch (error) { } catch (error) {
res.status(400).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -6,6 +6,8 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import type { AuthenticationStatus, CustomFields, ErrorResponse, OntimeRundown } from 'ontime-types';
import { deleteFile } from '../../utils/parserUtils.js'; import { deleteFile } from '../../utils/parserUtils.js';
import { import {
revoke, revoke,
@@ -16,8 +18,12 @@ import {
upload, upload,
getWorksheetOptions, getWorksheetOptions,
} from '../../services/sheet-service/SheetService.js'; } from '../../services/sheet-service/SheetService.js';
import { getErrorMessage } from 'ontime-utils';
export async function requestConnection(req: Request, res: Response) { export async function requestConnection(
req: Request,
res: Response<{ verification_url: string; user_code: string } | ErrorResponse>,
) {
const { sheetId } = req.params; const { sheetId } = req.params;
const file = req.file.path; const file = req.file.path;
@@ -28,7 +34,8 @@ export async function requestConnection(req: Request, res: Response) {
res.status(200).send({ verification_url, user_code }); res.status(200).send({ verification_url, user_code });
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(500).send({ message });
} }
// delete uploaded file after parsing // delete uploaded file after parsing
@@ -39,52 +46,72 @@ export async function requestConnection(req: Request, res: Response) {
} }
} }
export async function verifyAuthentication(_req: Request, res: Response) { export async function verifyAuthentication(
_req: Request,
res: Response<{ authenticated: AuthenticationStatus } | ErrorResponse>,
) {
try { try {
const authenticated = hasAuth(); const authenticated = hasAuth();
res.status(200).send(authenticated); res.status(200).send(authenticated);
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(500).send({ message });
} }
} }
export async function revokeAuthentication(_req: Request, res: Response) { export async function revokeAuthentication(
_req: Request,
res: Response<{ authenticated: AuthenticationStatus } | ErrorResponse>,
) {
try { try {
const authenticated = revoke(); const authenticated = revoke();
res.status(200).send(authenticated); res.status(200).send(authenticated);
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(500).send({ message });
} }
} }
export async function getWorksheetNamesFromSheet(req: Request, res: Response) { export async function getWorksheetNamesFromSheet(req: Request, res: Response<string[] | ErrorResponse>) {
try { try {
const { sheetId } = req.params; const { sheetId } = req.params;
const { worksheetOptions } = await getWorksheetOptions(sheetId); const { worksheetOptions } = await getWorksheetOptions(sheetId);
res.status(200).send(worksheetOptions); res.status(200).send(worksheetOptions);
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(500).send({ message });
} }
} }
export async function readFromSheet(req: Request, res: Response) { export async function readFromSheet(
req: Request,
res: Response<
| {
rundown: OntimeRundown;
customFields: CustomFields;
}
| ErrorResponse
>,
) {
try { try {
const { sheetId } = req.params; const { sheetId } = req.params;
const { options } = req.body; const { options } = req.body;
const data = await download(sheetId, options); const data = await download(sheetId, options);
res.status(200).send(data); res.status(200).send(data);
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(500).send({ message });
} }
} }
export async function writeToSheet(req: Request, res: Response) { export async function writeToSheet(req: Request, res: Response<void | ErrorResponse>) {
try { try {
const { sheetId } = req.params; const { sheetId } = req.params;
const { options } = req.body; const { options } = req.body;
await upload(sheetId, options); await upload(sheetId, options);
res.status(200).send(); res.status(200).send();
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(500).send({ message });
} }
} }
@@ -4,6 +4,7 @@ import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js'; import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failIsNotArray } from '../../utils/routerUtils.js'; import { failIsNotArray } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils';
export async function getUrlPresets(_req: Request, res: Response<URLPreset[]>) { export async function getUrlPresets(_req: Request, res: Response<URLPreset[]>) {
const presets = DataProvider.getUrlPresets(); const presets = DataProvider.getUrlPresets();
@@ -23,6 +24,7 @@ export async function postUrlPresets(req: Request, res: Response<URLPreset[] | E
await DataProvider.setUrlPresets(newPresets); await DataProvider.setUrlPresets(newPresets);
res.status(200).send(newPresets); res.status(200).send(newPresets);
} catch (error) { } catch (error) {
res.status(400).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -4,6 +4,7 @@ import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js'; import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils';
export async function getViewSettings(_req: Request, res: Response<ViewSettings>) { export async function getViewSettings(_req: Request, res: Response<ViewSettings>) {
const views = DataProvider.getViewSettings(); const views = DataProvider.getViewSettings();
@@ -27,6 +28,7 @@ export async function postViewSettings(req: Request, res: Response<ViewSettings
await DataProvider.setViewSettings(newData); await DataProvider.setViewSettings(newData);
res.status(200).send(newData); res.status(200).send(newData);
} catch (error) { } catch (error) {
res.status(400).send({ message: String(error) }); const message = getErrorMessage(error);
res.status(400).send({ message });
} }
} }
@@ -13,7 +13,7 @@ import { logger } from '../classes/Logger.js';
import { objectFromPath } from '../adapters/utils/parse.js'; import { objectFromPath } from '../adapters/utils/parse.js';
import { dispatchFromAdapter } from './integration.controller.js'; import { dispatchFromAdapter } from './integration.controller.js';
import { unpackError } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import { eventStore } from '../stores/EventStore.js'; import { eventStore } from '../stores/EventStore.js';
export const integrationRouter = express.Router(); export const integrationRouter = express.Router();
@@ -46,7 +46,7 @@ integrationRouter.get('/*', (req: Request, res: Response) => {
const reply = dispatchFromAdapter(action, params, 'http'); const reply = dispatchFromAdapter(action, params, 'http');
res.status(202).json(reply); res.status(202).json(reply);
} catch (error) { } catch (error) {
const errorMessage = unpackError(error); const errorMessage = getErrorMessage(error);
logger.error(LogOrigin.Rx, `HTTP IN: ${errorMessage}`); logger.error(LogOrigin.Rx, `HTTP IN: ${errorMessage}`);
res.status(500).send({ message: errorMessage }); res.status(500).send({ message: errorMessage });
} }
@@ -57,9 +57,7 @@ integrationRouter.get('/poll', (_req: Request, res: Response<Partial<RuntimeStor
const state = eventStore.poll(); const state = eventStore.poll();
res.status(200).send(state); res.status(200).send(state);
} catch (error) { } catch (error) {
const message = unpackError(error); const message = getErrorMessage(error);
res.status(500).send({ res.status(500).send({ message: `Could not get sync data: ${message}` });
message: `Could not get sync data: ${message}`,
});
} }
}); });
+10 -1
View File
@@ -5,6 +5,7 @@ import express from 'express';
import expressStaticGzip from 'express-static-gzip'; import expressStaticGzip from 'express-static-gzip';
import http, { type Server } from 'http'; import http, { type Server } from 'http';
import cors from 'cors'; import cors from 'cors';
import serverTiming from 'server-timing';
// import utils // import utils
import { resolve } from 'path'; import { resolve } from 'path';
@@ -54,6 +55,10 @@ if (!isProduction) {
// Create express APP // Create express APP
const app = express(); const app = express();
if (process.env.NODE_ENV === 'development') {
// log more serever timings
app.use(serverTiming());
}
app.disable('x-powered-by'); app.disable('x-powered-by');
// setup cors for all routes // setup cors for all routes
@@ -83,11 +88,15 @@ app.use(
expressStaticGzip(reactAppPath, { expressStaticGzip(reactAppPath, {
enableBrotli: true, enableBrotli: true,
orderPreference: ['br'], orderPreference: ['br'],
// when we build the client all the react subfiles will get a hashed name we can the immutable tag
// as the contents of a build file will never change without also changing its name
// so the client dose not need to revalidate the file contetnts with the server
serveStatic: { etag: false, lastModified: false, immutable: true, maxAge: '1y' },
}), }),
); );
app.get('*', (_req, res) => { app.get('*', (_req, res) => {
res.sendFile(resolve(resolvedPath(), 'index.html')); res.sendFile(resolve(reactAppPath, 'index.html'));
}); });
// Implement catch all // Implement catch all
@@ -64,6 +64,7 @@ export class SimpleTimer {
public update(timeNow: number): SimpleTimerState { public update(timeNow: number): SimpleTimerState {
if (this.state.playback === SimplePlayback.Start) { if (this.state.playback === SimplePlayback.Start) {
// we know startedAt is not null since we are in play mode
const elapsed = timeNow - this.startedAt; const elapsed = timeNow - this.startedAt;
if (this.state.direction === SimpleDirection.CountDown) { if (this.state.direction === SimpleDirection.CountDown) {
this.state.current = this.state.duration - elapsed; this.state.current = this.state.duration - elapsed;
+1 -1
View File
@@ -64,7 +64,7 @@ export class RestoreService {
private readonly filePath: MaybeString; private readonly filePath: MaybeString;
private readonly file: JSONFile<RestorePoint | null>; private readonly file: JSONFile<RestorePoint | null>;
private failedCreateAttempts: number; private failedCreateAttempts: number;
private savedState: RestorePoint; private savedState: RestorePoint | null;
constructor(filePath: string) { constructor(filePath: string) {
this.filePath = filePath; this.filePath = filePath;
@@ -1664,7 +1664,7 @@ describe('getRuntimeOffset()', () => {
expectedEnd: 81600000, // 22:40:00 expectedEnd: 81600000, // 22:40:00
}, },
timer: { timer: {
addedTime: 0, addedTime: -200000,
current: -400000, current: -400000,
duration: 3600000, duration: 3600000,
elapsed: 4000000, elapsed: 4000000,
@@ -1678,7 +1678,7 @@ describe('getRuntimeOffset()', () => {
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(offset).toBe(-400000); expect(offset).toBe(400000); // <--- offset is always the overtime
}); });
it('handles time-to-end started after the end time', () => { it('handles time-to-end started after the end time', () => {
@@ -1732,7 +1732,8 @@ describe('getRuntimeOffset()', () => {
const updateCurrent = getCurrent(state); const updateCurrent = getCurrent(state);
state.timer.current = updateCurrent; state.timer.current = updateCurrent;
const offset = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(offset).toBe(81000000 - 82000000); // <-- planned end - now expect(millisToString(offset)).toBe('00:16:40');
expect(offset).toBe(82000000 - 81000000); // <-- now - planned end
}); });
}); });
@@ -67,8 +67,7 @@ export async function handleUploadedFile(filePath: string, name: string) {
* *
* @returns {Promise<Array<ProjectFile>>} A promise that resolves to an array of ProjectFile objects, * @returns {Promise<Array<ProjectFile>>} A promise that resolves to an array of ProjectFile objects,
* each representing a file in the 'uploads' folder with its metadata. * each representing a file in the 'uploads' folder with its metadata.
* The metadata includes the filename, creation time (createdAt), * The metadata includes the filename, creation or overwriting time (updatedAt)
* and last modification time (updatedAt) of each file.
* *
* @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics. * @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics.
*/ */
@@ -76,14 +75,13 @@ export async function getProjectFiles(): Promise<ProjectFile[]> {
const allFiles = await getFilesFromFolder(resolveProjectsDirectory); const allFiles = await getFilesFromFolder(resolveProjectsDirectory);
const filteredFiles = filterProjectFiles(allFiles); const filteredFiles = filterProjectFiles(allFiles);
const projectFiles = []; const projectFiles: ProjectFile[] = [];
for (const file of filteredFiles) { for (const file of filteredFiles) {
const filePath = join(resolveProjectsDirectory, file); const filePath = join(resolveProjectsDirectory, file);
const stats = await stat(filePath); const stats = await stat(filePath);
projectFiles.push({ projectFiles.push({
filename: removeFileExtension(file), filename: removeFileExtension(file),
createdAt: stats.birthtime.toISOString(),
updatedAt: stats.mtime.toISOString(), updatedAt: stats.mtime.toISOString(),
}); });
} }
@@ -23,7 +23,6 @@ describe('getProjectFiles test', () => {
const expectedFiles = ['file1', 'file2', 'file3'].map((file) => ({ const expectedFiles = ['file1', 'file2', 'file3'].map((file) => ({
filename: file, filename: file,
createdAt: new Date('2020-01-01').toISOString(),
updatedAt: new Date('2021-01-01').toISOString(), updatedAt: new Date('2021-01-01').toISOString(),
})); }));
@@ -22,20 +22,32 @@ import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js'; import * as cache from './rundownCache.js';
import { getPlayableEvents } from './rundownUtils.js'; import { getPlayableEvents } from './rundownUtils.js';
function generateEvent(eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>) { type PatchWithId = (Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string };
// we discard any UI provided events and add our own
type CompleteEntry<T> = T extends Partial<OntimeEvent>
? OntimeEvent
: T extends Partial<OntimeDelay>
? OntimeDelay
: T extends Partial<OntimeBlock>
? OntimeBlock
: never;
function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
eventData: T,
): CompleteEntry<T> {
// we discard any UI provided IDs and add our own
const id = cache.getUniqueId(); const id = cache.getUniqueId();
if (isOntimeEvent(eventData)) { if (isOntimeEvent(eventData)) {
return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), eventData?.after)) as OntimeEvent; return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), eventData?.after)) as CompleteEntry<T>;
} }
if (isOntimeDelay(eventData)) { if (isOntimeDelay(eventData)) {
return { ...delayDef, duration: eventData.duration ?? 0, id } as OntimeDelay; return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry<T>;
} }
if (isOntimeBlock(eventData)) { if (isOntimeBlock(eventData)) {
return { ...blockDef, title: eventData?.title ?? '', id } as OntimeBlock; return { ...blockDef, title: eventData?.title ?? '', id } as CompleteEntry<T>;
} }
throw new Error('Invalid event type'); throw new Error('Invalid event type');
@@ -46,9 +58,7 @@ function generateEvent(eventData: Partial<OntimeEvent> | Partial<OntimeDelay> |
* @param {object} eventData * @param {object} eventData
* @return {OntimeRundownEntry} * @return {OntimeRundownEntry}
*/ */
export async function addEvent( export async function addEvent(eventData: PatchWithId & { after?: string }): Promise<OntimeRundownEntry> {
eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>,
): Promise<OntimeRundownEntry> {
// if the user didnt provide an index, we add the event to start // if the user didnt provide an index, we add the event to start
let atIndex = 0; let atIndex = 0;
if (eventData?.after !== undefined) { if (eventData?.after !== undefined) {
@@ -62,15 +72,16 @@ export async function addEvent(
// generate a fully formed event from the patch // generate a fully formed event from the patch
const eventToAdd = generateEvent(eventData); const eventToAdd = generateEvent(eventData);
// modify rundown // modify rundown
const scopedMutation = cache.mutateCache(cache.add); const scopedMutation = cache.mutateCache(cache.add);
const { newEvent } = await scopedMutation({ atIndex, event: eventToAdd as OntimeRundownEntry }); const { newEvent } = await scopedMutation({ atIndex, event: eventToAdd });
// notify runtime that rundown has changed // notify runtime that rundown has changed
updateRuntimeOnChange(); updateRuntimeOnChange();
// notify timer and external services of change // notify timer and external services of change
notifyChanges({ timer: [newEvent.id], external: true }); notifyChanges({ timer: [eventData.id], external: true });
return newEvent; return newEvent;
} }
@@ -81,7 +92,11 @@ export async function addEvent(
*/ */
export async function deleteEvent(eventId: string) { export async function deleteEvent(eventId: string) {
const scopedMutation = cache.mutateCache(cache.remove); const scopedMutation = cache.mutateCache(cache.remove);
await scopedMutation({ eventId }); const { didMutate } = await scopedMutation({ eventId });
if (didMutate === false) {
return;
}
// notify runtime that rundown has changed // notify runtime that rundown has changed
updateRuntimeOnChange(); updateRuntimeOnChange();
@@ -108,14 +123,18 @@ export async function deleteAllEvents() {
* Apply patch to an element in rundown * Apply patch to an element in rundown
* @param patch * @param patch
*/ */
export async function editEvent(patch: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) { export async function editEvent(patch: PatchWithId) {
if (isOntimeEvent(patch) && patch?.cue === '') { if (isOntimeEvent(patch) && patch?.cue === '') {
throw new Error('Cue value invalid'); throw new Error('Cue value invalid');
} }
const scopedMutation = cache.mutateCache(cache.edit); const scopedMutation = cache.mutateCache(cache.edit);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know patch has an id const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id });
const { newEvent } = await scopedMutation({ patch, eventId: patch.id! });
// short circuit if nothing changed
if (didMutate === false) {
return newEvent;
}
// notify runtime that rundown has changed // notify runtime that rundown has changed
updateRuntimeOnChange(); updateRuntimeOnChange();
@@ -83,7 +83,7 @@ export function generate(
let accumulatedDelay = 0; let accumulatedDelay = 0;
let daySpan = 0; let daySpan = 0;
let previousEnd: number; let previousEnd: MaybeNumber = null;
for (let i = 0; i < initialRundown.length; i++) { for (let i = 0; i < initialRundown.length; i++) {
const currentEvent = initialRundown[i]; const currentEvent = initialRundown[i];
@@ -106,7 +106,7 @@ export function generate(
lastEnd = updatedEvent.timeEnd; lastEnd = updatedEvent.timeEnd;
// check if we go over midnight, account for eventual gaps // check if we go over midnight, account for eventual gaps
const gapOverMidnight = previousEnd > updatedEvent.timeStart; const gapOverMidnight = previousEnd !== null && previousEnd > updatedEvent.timeStart;
const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd; const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd;
if (gapOverMidnight || durationOverMidnight) { if (gapOverMidnight || durationOverMidnight) {
daySpan++; daySpan++;
@@ -135,7 +135,9 @@ export function generate(
isStale = false; isStale = false;
totalDelay = accumulatedDelay; totalDelay = accumulatedDelay;
totalDuration = getTotalDuration(firstStart, lastEnd, daySpan); if (lastEnd !== null && firstStart !== null) {
totalDuration = getTotalDuration(firstStart, lastEnd, daySpan);
}
return { rundown, order, links, totalDelay, totalDuration, assignedCustomProperties: assignedCustomFields }; return { rundown, order, links, totalDelay, totalDuration, assignedCustomProperties: assignedCustomFields };
} }
@@ -210,6 +212,7 @@ type MutationParams<T> = T & CommonParams;
type MutatingReturn = { type MutatingReturn = {
newRundown: OntimeRundown; newRundown: OntimeRundown;
newEvent?: OntimeRundownEntry; newEvent?: OntimeRundownEntry;
didMutate: boolean;
}; };
type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingReturn; type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingReturn;
@@ -227,7 +230,7 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
*/ */
isStale = true; isStale = true;
const { newEvent, newRundown } = mutation({ ...params, persistedRundown }); const { newEvent, newRundown, didMutate } = mutation({ ...params, persistedRundown });
revision = revision + 1; revision = revision + 1;
persistedRundown = newRundown; persistedRundown = newRundown;
@@ -244,7 +247,7 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
DataProvider.setRundown(persistedRundown); DataProvider.setRundown(persistedRundown);
}); });
return { newEvent }; return { newEvent, newRundown, didMutate };
} }
return scopedMutation; return scopedMutation;
@@ -256,7 +259,7 @@ export function add({ persistedRundown, atIndex, event }: AddArgs): Required<Mut
const newEvent: OntimeRundownEntry = { ...event }; const newEvent: OntimeRundownEntry = { ...event };
const newRundown = insertAtIndex(atIndex, newEvent, persistedRundown); const newRundown = insertAtIndex(atIndex, newEvent, persistedRundown);
return { newRundown, newEvent }; return { newRundown, newEvent, didMutate: true };
} }
type RemoveArgs = MutationParams<{ eventId: string }>; type RemoveArgs = MutationParams<{ eventId: string }>;
@@ -265,11 +268,11 @@ export function remove({ persistedRundown, eventId }: RemoveArgs): MutatingRetur
const atIndex = persistedRundown.findIndex((event) => event.id === eventId); const atIndex = persistedRundown.findIndex((event) => event.id === eventId);
const newRundown = deleteAtIndex(atIndex, persistedRundown); const newRundown = deleteAtIndex(atIndex, persistedRundown);
return { newRundown }; return { newRundown, didMutate: atIndex !== -1 };
} }
export function removeAll(): { newRundown: OntimeRundown } { export function removeAll(): MutatingReturn {
return { newRundown: [] }; return { newRundown: [], didMutate: true };
} }
/** /**
@@ -304,7 +307,7 @@ export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<M
const eventInMemory = persistedRundown[indexAt]; const eventInMemory = persistedRundown[indexAt];
if (!hasChanges(eventInMemory, patch)) { if (!hasChanges(eventInMemory, patch)) {
isStale = false; isStale = false;
return; return { newRundown: persistedRundown, newEvent: eventInMemory, didMutate: false };
} }
const newEvent = makeEvent(eventInMemory, patch); const newEvent = makeEvent(eventInMemory, patch);
@@ -312,6 +315,7 @@ export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<M
const newRundown = [...persistedRundown]; const newRundown = [...persistedRundown];
newRundown[indexAt] = newEvent; newRundown[indexAt] = newEvent;
// check whether the data warrants recalculation of cache
const makeStale = isDataStale(patch); const makeStale = isDataStale(patch);
if (!makeStale) { if (!makeStale) {
@@ -319,7 +323,7 @@ export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<M
} }
isStale = makeStale; isStale = makeStale;
return { newRundown, newEvent }; return { newRundown, newEvent, didMutate: true };
} }
type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial<OntimeRundownEntry> }>; type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial<OntimeRundownEntry> }>;
@@ -339,7 +343,7 @@ export function batchEdit({ persistedRundown, eventIds, patch }: BatchEditArgs):
newRundown.push(persistedRundown[i]); newRundown.push(persistedRundown[i]);
} }
} }
return { newRundown }; return { newRundown, didMutate: true };
} }
type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>; type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>;
@@ -357,14 +361,14 @@ export function reorder({ persistedRundown, eventId, from, to }: ReorderArgs): R
event.revision += 1; event.revision += 1;
} }
} }
return { newRundown, newEvent: newRundown.at(from) }; return { newRundown, newEvent: newRundown.at(from) as OntimeRundownEntry, didMutate: true };
} }
type ApplyDelayArgs = MutationParams<{ eventId: string }>; type ApplyDelayArgs = MutationParams<{ eventId: string }>;
export function applyDelay({ persistedRundown, eventId }: ApplyDelayArgs): MutatingReturn { export function applyDelay({ persistedRundown, eventId }: ApplyDelayArgs): MutatingReturn {
const newRundown = apply(eventId, persistedRundown); const newRundown = apply(eventId, persistedRundown);
return { newRundown }; return { newRundown, didMutate: true };
} }
type SwapArgs = MutationParams<{ fromId: string; toId: string }>; type SwapArgs = MutationParams<{ fromId: string; toId: string }>;
@@ -388,7 +392,7 @@ export function swap({ persistedRundown, fromId, toId }: SwapArgs): MutatingRetu
newRundown[indexB] = newB; newRundown[indexB] = newB;
(newRundown[indexB] as OntimeEvent).revision += 1; (newRundown[indexB] as OntimeEvent).revision += 1;
return { newRundown }; return { newRundown, didMutate: true };
} }
/** /**
@@ -183,7 +183,7 @@ function verifyConnection(
pollInterval = null; pollInterval = null;
} }
postAction(); await postAction();
} catch (_error) { } catch (_error) {
/** we do not handle failure */ /** we do not handle failure */
} }
@@ -201,15 +201,15 @@ async function verifySheet(
sheetId = currentSheetId, sheetId = currentSheetId,
authClient = currentAuthClient, authClient = currentAuthClient,
): Promise<{ worksheetOptions: string[] }> { ): Promise<{ worksheetOptions: string[] }> {
const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({ try {
spreadsheetId: sheetId, const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({
includeGridData: false, spreadsheetId: sheetId,
}); includeGridData: false,
});
if (spreadsheets.status !== 200) { return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
throw new Error(spreadsheets.statusText); } catch (error) {
throw new Error(`Failed to verify sheet: ${error.message}`);
} }
return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
} }
export async function handleInitialConnection( export async function handleInitialConnection(
+2 -2
View File
@@ -313,7 +313,7 @@ export function getRuntimeOffset(state: RuntimeState): MaybeNumber {
return clock - timeStart; return clock - timeStart;
} }
const overtime = Math.min(current, 0); const overtime = Math.abs(Math.min(current, 0));
// in time-to-end, offset is overtime // in time-to-end, offset is overtime
if (timerType === TimerType.TimeToEnd) { if (timerType === TimerType.TimeToEnd) {
return overtime; return overtime;
@@ -322,7 +322,7 @@ export function getRuntimeOffset(state: RuntimeState): MaybeNumber {
const startOffset = startedAt - timeStart; const startOffset = startedAt - timeStart;
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt; const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
return startOffset + addedTime + pausedTime + Math.abs(overtime); return startOffset + addedTime + pausedTime + overtime;
} }
/** /**
+7 -1
View File
@@ -25,7 +25,13 @@ const populateDb = (directory: string, filename: string): string => {
if (!existsSync(dbPath)) { if (!existsSync(dbPath)) {
try { try {
const dbDirectory = resolveDbDirectory; const dbDirectory = resolveDbDirectory;
const newFileDirectory = join(dbDirectory, pathToStartDb.split('/').pop()); const startDbName = pathToStartDb.split('/').pop();
if (!startDbName) {
throw new Error('Invalid path to start database');
}
const newFileDirectory = join(dbDirectory, startDbName);
copyFileSync(pathToStartDb, newFileDirectory); copyFileSync(pathToStartDb, newFileDirectory);
dbPath = newFileDirectory; dbPath = newFileDirectory;
@@ -318,7 +318,6 @@ describe('test parser edge cases', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain // @ts-expect-error -- we know this is wrong, testing imports outside domain
const parseResponse = await parseJson(testData); const parseResponse = await parseJson(testData);
expect(console.log).toHaveBeenCalledWith('ERROR: unkown event type, skipping');
expect(parseResponse?.rundown.length).toBe(0); expect(parseResponse?.rundown.length).toBe(0);
}); });
@@ -332,7 +331,7 @@ describe('test parser edge cases', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain // @ts-expect-error -- we know this is wrong, testing imports outside domain
await parseJson(testData); await parseJson(testData);
expect(console.log).toHaveBeenCalledWith('ERROR: unknown app version, skipping'); expect(console.log).toHaveBeenCalledWith('ERROR: unable to parse settings, missing app or version');
}); });
}); });
@@ -564,6 +563,7 @@ describe('test views import', () => {
normalColor: '#ffffffcc', normalColor: '#ffffffcc',
warningColor: '#FFAB33', warningColor: '#FFAB33',
dangerColor: '#ED3333', dangerColor: '#ED3333',
freezeEnd: false,
endMessage: '', endMessage: '',
overrideStyles: false, overrideStyles: false,
}; };
@@ -581,7 +581,7 @@ describe('test views import', () => {
}, },
} as DatabaseModel; } as DatabaseModel;
const parsed = parseViewSettings(testData); const parsed = parseViewSettings(testData);
expect(parsed).toStrictEqual({}); expect(parsed).toStrictEqual(dbModel.viewSettings);
}); });
}); });
@@ -591,7 +591,7 @@ describe('test import of v2 datamodel', () => {
rundown: [ rundown: [
{ type: SupportedEvent.Block, title: 'block-title', id: 'block-id' }, { type: SupportedEvent.Block, title: 'block-title', id: 'block-id' },
{ type: SupportedEvent.Delay, duration: 0 }, { type: SupportedEvent.Delay, duration: 0 },
{ type: SupportedEvent.Event, title: 'block-title', id: 'block-id' }, { type: SupportedEvent.Event, title: 'event-title', id: 'event-id' },
], ],
project: { project: {
title: '', title: '',
+16 -5
View File
@@ -273,15 +273,26 @@ export const parseJson = async (jsonData: Partial<DatabaseModel>): Promise<Datab
return null; return null;
} }
let settings;
// check settings first to make sure we can parse it
try {
settings = parseSettings(jsonData);
} catch (error) {
// if we cant parse, return an empty project
console.log('ERROR: unable to parse settings, missing app or version');
return dbModel;
}
const returnData: DatabaseModel = { const returnData: DatabaseModel = {
rundown: parseRundown(jsonData), rundown: parseRundown(jsonData),
project: parseProject(jsonData) ?? dbModel.project, project: parseProject(jsonData),
settings: parseSettings(jsonData) ?? dbModel.settings, settings,
viewSettings: parseViewSettings(jsonData) ?? dbModel.viewSettings, viewSettings: parseViewSettings(jsonData),
urlPresets: parseUrlPresets(jsonData), urlPresets: parseUrlPresets(jsonData),
customFields: parseCustomFields(jsonData), customFields: parseCustomFields(jsonData),
osc: parseOsc(jsonData) ?? dbModel.osc, osc: parseOsc(jsonData),
http: parseHttp(jsonData) ?? dbModel.http, http: parseHttp(jsonData),
}; };
return returnData; return returnData;
+156 -166
View File
@@ -15,6 +15,9 @@ import {
isOntimeCycle, isOntimeCycle,
HttpSubscription, HttpSubscription,
URLPreset, URLPreset,
OntimeEvent,
OntimeBlock,
OntimeDelay,
} from 'ontime-types'; } from 'ontime-types';
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js'; import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
@@ -22,135 +25,118 @@ import { dbModel } from '../models/dataModel.js';
import { createEvent } from './parser.js'; import { createEvent } from './parser.js';
/** /**
* Parse events array of an entry * Parse rundown array of an entry
* @param {object} data - data object
* @returns {object} - event object data
*/ */
export const parseRundown = (data: Partial<DatabaseModel>): OntimeRundown => { export const parseRundown = (data: Partial<DatabaseModel>): OntimeRundown => {
let newRundown: OntimeRundown = []; if (!data.rundown) {
if ('rundown' in data) { return [];
console.log('Found rundown definition, importing...');
const rundown = [];
try {
let eventIndex = 0;
const ids = [];
for (const event of data.rundown) {
// double check unique ids
if (ids.includes(event?.id)) {
console.log('ERROR: ID collision on import, skipping');
continue;
}
if (isOntimeEvent(event)) {
eventIndex += 1;
const parsedEvent = createEvent(event, eventIndex.toString());
if (event != null) {
rundown.push(parsedEvent);
ids.push(parsedEvent.id);
}
} else if (isOntimeDelay(event)) {
rundown.push({
...delayDef,
duration: event.duration,
id: event.id || generateId(),
});
} else if (isOntimeBlock(event)) {
rundown.push({ ...blockDef, title: event.title, id: event.id || generateId() });
} else {
console.log('ERROR: unkown event type, skipping');
}
}
} catch (error) {
console.log(`Error ${error}`);
}
// write to db
newRundown = rundown;
console.log(`Uploaded file with ${newRundown.length} entries`);
} }
return newRundown;
console.log('Found rundown, importing...');
const rundown: OntimeRundown = [];
let eventIndex = 0;
const ids: string[] = [];
for (const event of data.rundown) {
if (ids.includes(event.id)) {
console.log('ERROR: ID collision on import, skipping');
continue;
}
const id = event.id || generateId();
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
if (isOntimeEvent(event)) {
newEvent = createEvent(event, eventIndex.toString());
// skip if event is invalid
if (newEvent == null) {
continue;
}
eventIndex += 1;
} else if (isOntimeDelay(event)) {
newEvent = { ...delayDef, duration: event.duration, id };
} else if (isOntimeBlock(event)) {
newEvent = { ...blockDef, title: event.title, id };
} else {
console.log('ERROR: unknown event type, skipping');
continue;
}
if (newEvent) {
rundown.push(newEvent);
ids.push(id);
}
}
console.log(`Uploaded rundown with ${rundown.length} entries`);
return rundown;
}; };
/** /**
* Parse event portion of an entry * Parse event portion of an entry
* @param {object} data - data object
* @returns {object} - event object data
*/ */
export const parseProject = (data: Partial<DatabaseModel>): ProjectData => { export const parseProject = (data: Partial<DatabaseModel>): ProjectData => {
let newProjectData: Partial<ProjectData> = {}; if (!data.project) {
// we are adding this here to aid transition, should be removed once enough time has past that users have fully migrated return { ...dbModel.project };
if ('project' in data) {
console.log('Found project data, importing...');
const project = data.project;
// filter known properties and write to db
newProjectData = {
...dbModel.project,
title: project.title || dbModel.project.title,
description: project.description || dbModel.project.description,
publicUrl: project.publicUrl || dbModel.project.publicUrl,
publicInfo: project.publicInfo || dbModel.project.publicInfo,
backstageUrl: project.backstageUrl || dbModel.project.backstageUrl,
backstageInfo: project.backstageInfo || dbModel.project.backstageInfo,
};
} }
return newProjectData as ProjectData;
console.log('Found project data, importing...');
return {
title: data.project.title ?? dbModel.project.title,
description: data.project.description ?? dbModel.project.description,
publicUrl: data.project.publicUrl ?? dbModel.project.publicUrl,
publicInfo: data.project.publicInfo ?? dbModel.project.publicInfo,
backstageUrl: data.project.backstageUrl ?? dbModel.project.backstageUrl,
backstageInfo: data.project.backstageInfo ?? dbModel.project.backstageInfo,
};
}; };
/** /**
* Parse settings portion of an entry * Parse settings portion of an entry
* @param {object} data - data object
* @returns {object} - event object data
*/ */
export const parseSettings = (data): Settings => { export const parseSettings = (data: Partial<DatabaseModel>): Settings => {
let newSettings: Partial<Settings> = {}; if (!data.settings) {
if ('settings' in data) { return { ...dbModel.settings };
console.log('Found settings definition, importing...');
const s = data.settings;
// skip if file definition is missing
if (s?.app !== 'ontime' || s?.version == null) {
console.log('ERROR: unknown app version, skipping');
} else {
const settings = {
version: dbModel.settings.version,
serverPort: s.serverPort ?? dbModel.settings.serverPort,
editorKey: s.editorKey ?? null,
operatorKey: s.operatorKey ?? null,
timeFormat: s.timeFormat ?? '24',
language: s.language ?? 'en',
};
// write to db
newSettings = {
...dbModel.settings,
...settings,
};
}
} }
return newSettings as Settings;
// skip if file definition is missing
if (data.settings?.app !== 'ontime' || data.settings?.version == null) {
throw new Error('ERROR: unable to parse settings, missing app or version');
}
console.log('Found settings, importing...');
return {
app: dbModel.settings.app,
version: dbModel.settings.version,
serverPort: data.settings.serverPort ?? dbModel.settings.serverPort,
editorKey: data.settings.editorKey ?? null,
operatorKey: data.settings.operatorKey ?? null,
timeFormat: data.settings.timeFormat ?? '24',
language: data.settings.language ?? 'en',
};
}; };
/** /**
* Parse settings portion of an entry * Parse view settings portion of an entry
* @param {object} data - data object
* @returns {object} - event object data
*/ */
export const parseViewSettings = (data: Partial<DatabaseModel>): ViewSettings => { export const parseViewSettings = (data: Partial<DatabaseModel>): ViewSettings => {
let newViews: Partial<ViewSettings> = {}; if (!data.viewSettings) {
if ('viewSettings' in data) { return { ...dbModel.viewSettings };
console.log('Found view definition, importing...');
const v = data.viewSettings;
const viewSettings = {
overrideStyles: v.overrideStyles ?? dbModel.viewSettings.overrideStyles,
normalColor: v.normalColor ?? dbModel.viewSettings.normalColor,
warningColor: v.warningColor ?? dbModel.viewSettings.warningColor,
dangerColor: v.dangerColor ?? dbModel.viewSettings.dangerColor,
endMessage: v.endMessage ?? dbModel.viewSettings.endMessage,
};
newViews = { ...viewSettings };
} }
return newViews as ViewSettings;
console.log('Found view settings, importing...');
return {
dangerColor: data.viewSettings.dangerColor ?? dbModel.viewSettings.dangerColor,
endMessage: data.viewSettings.endMessage ?? dbModel.viewSettings.endMessage,
freezeEnd: data.viewSettings.freezeEnd ?? dbModel.viewSettings.freezeEnd,
normalColor: data.viewSettings.normalColor ?? dbModel.viewSettings.normalColor,
overrideStyles: data.viewSettings.overrideStyles ?? dbModel.viewSettings.overrideStyles,
warningColor: data.viewSettings.warningColor ?? dbModel.viewSettings.warningColor,
};
}; };
/** /**
@@ -170,20 +156,20 @@ export function sanitiseOscSubscriptions(subscriptions?: OscSubscription[]): Osc
/** /**
* Parse osc portion of an entry * Parse osc portion of an entry
*/ */
export const parseOsc = (data: { osc?: Partial<OSCSettings> }): OSCSettings => { export const parseOsc = (data: Partial<DatabaseModel>): OSCSettings => {
if ('osc' in data) { if (!data.osc) {
console.log('Found OSC definition, importing...'); return { ...dbModel.osc };
const loadedConfig = data.osc || {};
return {
portIn: loadedConfig.portIn ?? dbModel.osc.portIn,
portOut: loadedConfig.portOut ?? dbModel.osc.portOut,
targetIP: loadedConfig.targetIP ?? dbModel.osc.targetIP,
enabledIn: loadedConfig.enabledIn ?? dbModel.osc.enabledIn,
enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut,
subscriptions: sanitiseOscSubscriptions(loadedConfig.subscriptions),
};
} }
console.log('Found OSC settings, importing...');
return {
portIn: data.osc.portIn ?? dbModel.osc.portIn,
portOut: data.osc.portOut ?? dbModel.osc.portOut,
targetIP: data.osc.targetIP ?? dbModel.osc.targetIP,
enabledIn: data.osc.enabledIn ?? dbModel.osc.enabledIn,
enabledOut: data.osc.enabledOut ?? dbModel.osc.enabledOut,
subscriptions: sanitiseOscSubscriptions(data.osc.subscriptions),
};
}; };
/** /**
@@ -206,66 +192,70 @@ export function sanitiseHttpSubscriptions(subscriptions?: HttpSubscription[]): H
/** /**
* Parse Http portion of an entry * Parse Http portion of an entry
* @param {object} data - data object
* @param {boolean} enforce - whether to create a definition if one is missing
* @returns {object} - event object data
*/ */
export const parseHttp = (data: { http?: Partial<HttpSettings> }): HttpSettings => { export const parseHttp = (data: Partial<DatabaseModel>): HttpSettings => {
if ('http' in data) { if (!data.http) {
console.log('Found HTTP definition, importing...'); return { ...dbModel.http };
// TODO: this can be improved by only merging known keys
const loadedConfig = data?.http || {};
return {
enabledOut: loadedConfig.enabledOut ?? dbModel.http.enabledOut,
subscriptions: sanitiseHttpSubscriptions(loadedConfig.subscriptions),
};
} }
console.log('Found HTTP settings, importing...');
return {
enabledOut: data.http.enabledOut ?? dbModel.http.enabledOut,
subscriptions: sanitiseHttpSubscriptions(data.http.subscriptions),
};
}; };
/** /**
* Parse URL preset portion of an entry * Parse URL preset portion of an entry
* @param {object} data - data object
* @returns {object} - event object data
*/ */
export const parseUrlPresets = (data: Partial<DatabaseModel>): URLPreset[] => { export const parseUrlPresets = (data: Partial<DatabaseModel>): URLPreset[] => {
const newPresets: URLPreset[] = []; if (!data.urlPresets) {
if ('urlPresets' in data) { return [];
console.log('Found URL presets definition, importing...');
try {
for (const preset of data.urlPresets) {
const newPreset = {
enabled: preset.enabled ?? false,
alias: preset.alias ?? '',
pathAndParams: preset.pathAndParams ?? '',
};
newPresets.push(newPreset);
}
console.log(`Uploaded ${newPresets.length} preset(s)`);
} catch (error) {
console.log(`Error: ${error}`);
}
} }
console.log('Found URL presets, importing...');
const newPresets: URLPreset[] = [];
for (const preset of data.urlPresets) {
const newPreset = {
enabled: preset.enabled ?? false,
alias: preset.alias ?? '',
pathAndParams: preset.pathAndParams ?? '',
};
newPresets.push(newPreset);
}
console.log(`Uploaded ${newPresets.length} preset(s)`);
return newPresets; return newPresets;
}; };
/** /**
* Parse customFields entry * Parse customFields entry
* @param {object} data - data object
* @returns {object} - event object data
*/ */
export const parseCustomFields = (data: Partial<DatabaseModel>): CustomFields => { export const parseCustomFields = (data: Partial<DatabaseModel>): CustomFields => {
let newCustomFields: CustomFields = { ...dbModel.customFields }; if (typeof data.customFields !== 'object') {
return { ...dbModel.customFields };
if ('customFields' in data) {
console.log('Found Custom Fields definition, importing...');
try {
//TODO: validate
newCustomFields = { ...dbModel.customFields, ...data.customFields };
} catch (error) {
console.log(`Error: ${error}`);
}
} }
return { ...newCustomFields };
console.log('Found Custom Fields, importing...');
const newCustomFields: CustomFields = {};
for (const fieldLabel in data.customFields) {
const field = data.customFields[fieldLabel];
if (!field.label || !field.type || !field.colour) {
console.log('ERROR: missing required field, skipping');
continue;
}
newCustomFields[field.label] = {
type: field.type,
colour: field.colour,
label: field.label,
};
}
return newCustomFields;
}; };
+5 -5
View File
@@ -15,10 +15,9 @@ export const makeString = (val: unknown, fallback = ''): string => {
/** /**
* @description Delete file from system * @description Delete file from system
* @param {string} file - reference to file
*/ */
export const deleteFile = async (file) => { export const deleteFile = async (filePath: string) => {
unlink(file, (error) => { unlink(filePath, (error) => {
if (error) { if (error) {
console.error('Could not delete file:', error); console.error('Could not delete file:', error);
} }
@@ -67,11 +66,12 @@ export function mergeObject<T extends object>(a: T, b: Partial<T>): T {
* @description Removes undefined * @description Removes undefined
* @param {object} obj * @param {object} obj
*/ */
export const removeUndefined = (obj: object) => { export const removeUndefined = <T extends Record<string, unknown>>(obj: T): Partial<T> => {
return Object.keys(obj).reduce((patched, key) => { return Object.keys(obj).reduce((patched, key) => {
if (typeof obj[key] !== 'undefined') { if (typeof obj[key] !== 'undefined') {
// @ts-expect-error -- not sure how to type this
patched[key] = obj[key]; patched[key] = obj[key];
} }
return patched; return patched;
}, {}); }, {} as Partial<T>);
}; };
+3 -3
View File
@@ -2,10 +2,10 @@ import { test, expect } from '@playwright/test';
const fileToUpload = 'e2e/tests/fixtures/test-db.json'; const fileToUpload = 'e2e/tests/fixtures/test-db.json';
test('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');
await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('button', { name: 'Edit mode' }).click();
await page.getByRole('menuitem', { name: 'Delete all events' }).click(); await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'toggle settings' }).click(); await page.getByRole('button', { name: 'toggle settings' }).click();
await page.getByRole('button', { name: 'Project', exact: true }).click(); await page.getByRole('button', { name: 'Project', exact: true }).click();
+8 -11
View File
@@ -4,9 +4,8 @@ test('delay blocks add time to events', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
// delete all events and add a new one // delete all events and add a new one
await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('button', { name: 'Edit mode' }).click();
await page.getByRole('menuitem', { name: 'Delete all events' }).click(); await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('button', { name: 'Create event' }).click(); await page.getByRole('button', { name: 'Create event' }).click();
// add data to new event // add data to new event
@@ -18,8 +17,7 @@ test('delay blocks add time to events', async ({ page }) => {
await page.getByTestId('rundown').getByPlaceholder('Duration').press('Enter'); await page.getByTestId('rundown').getByPlaceholder('Duration').press('Enter');
// add delay block // add delay block
await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('button', { name: 'Delay Alt ⇧ D' }).click();
await page.getByRole('menuitem', { name: 'Add delay at start' }).click();
// fill positive delay // fill positive delay
await page.getByTestId('delay-input').click(); await page.getByTestId('delay-input').click();
@@ -37,8 +35,7 @@ test('delay blocks add time to events', async ({ page }) => {
// add new delay // add new delay
await page.getByTestId('rundown').getByPlaceholder('Start').click(); await page.getByTestId('rundown').getByPlaceholder('Start').click();
await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('button', { name: 'Delay Alt ⇧ D' }).click();
await page.getByRole('menuitem', { name: 'Add delay at start' }).click();
await page.getByTestId('delay-input').click(); await page.getByTestId('delay-input').click();
await page.getByTestId('delay-input').fill('10m'); await page.getByTestId('delay-input').fill('10m');
await page.getByTestId('delay-input').press('Enter'); await page.getByTestId('delay-input').press('Enter');
@@ -54,9 +51,10 @@ test('delays are show correctly', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
// add a test event // add a test event
await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('button', { name: 'Edit mode' }).click();
await page.getByRole('menuitem', { name: 'Delete all events' }).click(); await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('time-input-timeStart').click(); await page.getByTestId('time-input-timeStart').click();
await page.getByTestId('rundown').getByTestId('time-input-timeStart').click(); await page.getByTestId('rundown').getByTestId('time-input-timeStart').click();
await page.getByTestId('rundown').getByTestId('time-input-timeStart').fill('10'); await page.getByTestId('rundown').getByTestId('time-input-timeStart').fill('10');
@@ -70,8 +68,7 @@ test('delays are show correctly', async ({ page }) => {
await expect(page.getByTestId('entry-1').locator('#block-status')).toHaveAttribute('data-ispublic', 'true'); await expect(page.getByTestId('entry-1').locator('#block-status')).toHaveAttribute('data-ispublic', 'true');
// add a delay // add a delay
await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('button', { name: 'Delay Alt ⇧ D' }).click();
await page.getByRole('menuitem', { name: 'Add delay at start' }).click();
await page.getByTestId('delay-input').click(); await page.getByTestId('delay-input').click();
await page.getByTestId('delay-input').fill('1'); await page.getByTestId('delay-input').fill('1');
await page.getByTestId('delay-input').press('Enter'); await page.getByTestId('delay-input').press('Enter');
+10 -11
View File
@@ -4,27 +4,26 @@ test('CRUD operations on the rundown', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
// clear rundown // clear rundown
await page.getByRole('button', { name: 'Run mode' }).click(); await page.getByRole('button', { name: 'Edit mode' }).click();
await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('menuitem', { name: 'Delete all events' }).click();
// create event from the rundown empty button // create event from the rundown empty button
await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByRole('button', { name: 'Create Event' }).click();
// create blocks using the quick add buttons // create blocks using the quick add buttons
await page.getByRole('button', { name: 'Block' }).click(); await page.getByRole('button', { name: 'Block' }).nth(1).click();
await page.getByRole('button', { name: 'Delay' }).click(); await page.getByRole('button', { name: 'Delay' }).nth(1).click();
await page.getByTestId('quick-add-event').click(); await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
// test quick add options - start is last end // test quick add options - star2+5-t is last end
await page.getByTestId('entry-2').getByTestId('time-input-duration').fill('20m'); await page.getByTestId('entry-2').getByTestId('time-input-duration').fill('20m');
await page.getByTestId('quick-add-event').click(); await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(page.getByLabel('Link to previous')).toBeChecked(); await expect(page.getByLabel('Link to previous').nth(1)).toBeChecked();
expect(await page.getByTestId('entry-3').getByTestId('time-input-timeStart').inputValue()).toContain('00:30:00'); expect(await page.getByTestId('entry-3').getByTestId('time-input-timeStart').inputValue()).toContain('00:30:00');
// test quick add options - event is public // test quick add options - event is public
await expect(page.getByLabel('Event is public')).toBeChecked(); await expect(page.getByLabel('Event is public').nth(1)).toBeChecked();
await page.getByTestId('quick-add-event').click(); await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(page.getByTestId('entry-4').locator('#block-status')).toHaveAttribute('data-ispublic', 'true'); await expect(page.getByTestId('entry-4').locator('#block-status')).toHaveAttribute('data-ispublic', 'true');
}); });
+23 -20
View File
@@ -3,9 +3,8 @@ import { test, expect } from '@playwright/test';
test('smoke test operator', async ({ page }) => { test('smoke test operator', async ({ page }) => {
// make some boilerplate // make some boilerplate
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'Run mode' }).click(); await page.getByRole('button', { name: 'Edit mode' }).click();
await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('menuitem', { name: 'Delete all events' }).click();
await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('time-input-timeStart').fill('1m'); await page.getByTestId('time-input-timeStart').fill('1m');
@@ -13,36 +12,40 @@ test('smoke test operator', async ({ page }) => {
await page.getByTestId('time-input-duration').fill('1m'); await page.getByTestId('time-input-duration').fill('1m');
await page.getByTestId('time-input-duration').press('Enter'); await page.getByTestId('time-input-duration').press('Enter');
await page.getByTestId('quick-add-event').click(); await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await page.getByTestId('entry-2').getByTestId('lock__duration').click(); await page.getByTestId('entry-2').getByTestId('lock__duration').click();
await page.getByTestId('entry-2').getByTestId('time-input-duration').fill('1m'); await page.getByTestId('entry-2').getByTestId('time-input-duration').fill('1m');
await page.getByTestId('entry-2').getByTestId('time-input-duration').press('Enter'); await page.getByTestId('entry-2').getByTestId('time-input-duration').press('Enter');
await page.getByTestId('entry-2').getByTestId('time-input-duration').press('Enter'); await page.getByTestId('entry-2').getByTestId('time-input-duration').press('Enter');
await page.getByTestId('quick-add-event').click(); await page.getByRole('button', { name: 'Event Alt E', exact: true }).click();
await page.getByTestId('entry-3').getByTestId('lock__duration').click(); await page.getByTestId('entry-3').getByTestId('lock__duration').click();
await page.getByTestId('entry-3').getByTestId('time-input-duration').fill('1m'); await page.getByTestId('entry-3').getByTestId('time-input-duration').fill('1m');
await page.getByTestId('entry-3').getByTestId('time-input-duration').press('Enter'); await page.getByTestId('entry-3').getByTestId('time-input-duration').press('Enter');
await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('button', { name: 'Block', exact: true }).nth(0).click();
await page.getByRole('menuitem', { name: 'Add block at start' }).click();
await page.getByTestId('quick-add-block').click();
await page.getByRole('button', { name: 'Edit mode' }).click(); await page.getByRole('button', { name: 'Edit mode' }).click();
await page.getByTestId('entry-1').getByRole('button', { name: 'Event options' }).first().click(); await page.getByTestId('entry-1').click({ button: 'right' });
await page.getByLabel('Title', { exact: true }).click(); await page.getByRole('menuitem', { name: 'Event after' }).click();
await page.getByLabel('Title', { exact: true }).fill('title 1');
await page.getByLabel('Title', { exact: true }).press('Enter');
await page.getByTestId('entry-2').getByRole('button', { name: 'Event options' }).first().click(); await page.getByTestId('entry-1').getByTestId('block__title').click();
await page.getByLabel('Title', { exact: true }).click(); await page.getByTestId('entry-1').getByTestId('block__title').fill('title 1');
await page.getByLabel('Title', { exact: true }).fill('title 2'); await page.getByTestId('entry-1').getByTestId('block__title').press('Enter');
await page.getByLabel('Title', { exact: true }).press('Enter');
await page.getByTestId('entry-3').getByRole('button', { name: 'Event options' }).first().click(); await page.getByTestId('entry-2').click({ button: 'right' });
await page.getByLabel('Title', { exact: true }).click(); await page.getByRole('menuitem', { name: 'Event after' }).click();
await page.getByLabel('Title', { exact: true }).fill('title 3');
await page.getByLabel('Title', { exact: true }).press('Enter'); await page.getByTestId('entry-2').getByTestId('block__title').click();
await page.getByTestId('entry-2').getByTestId('block__title').fill('title 2');
await page.getByTestId('entry-2').getByTestId('block__title').press('Enter');
await page.getByTestId('entry-3').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Event after' }).click();
await page.getByTestId('entry-3').getByTestId('block__title').click();
await page.getByTestId('entry-3').getByTestId('block__title').fill('title 3');
await page.getByTestId('entry-3').getByTestId('block__title').press('Enter');
// start an event // start an event
await page.getByTestId('panel-timer-control').getByRole('button', { name: 'Start' }).click(); await page.getByTestId('panel-timer-control').getByRole('button', { name: 'Start' }).click();
+5 -4
View File
@@ -1,11 +1,11 @@
import { test } from '@playwright/test'; import { expect, test } from '@playwright/test';
test('test URL preset feature, it should redirect to given URL', async ({ page }) => { test('URL preset feature, it should redirect to given URL', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
// open settings // open settings
await page.getByTestId('navigation__toggle-settings').click(); await page.getByRole('button', { name: 'Toggle settings' }).click();
await page.getByRole('button', { name: 'General' }).click(); await page.getByRole('button', { name: 'App Settings' }).click();
// create preset // create preset
await page.getByTestId('url-preset-form').scrollIntoViewIfNeeded(); await page.getByTestId('url-preset-form').scrollIntoViewIfNeeded();
@@ -26,4 +26,5 @@ test('test URL preset feature, it should redirect to given URL', async ({ page }
// make sure preset works // make sure preset works
await page.goto('http://localhost:4001/testing'); await page.goto('http://localhost:4001/testing');
await page.getByTestId('countdown__select').click(); await page.getByTestId('countdown__select').click();
await expect(page.getByTestId('countdown__select')).toBeVisible();
}); });
@@ -4,10 +4,10 @@ const fileToUpload = 'e2e/tests/fixtures/test-sheet.xlsx';
test('sheet file upload', async ({ page }) => { test('sheet file upload', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('button', { name: 'Edit mode' }).click();
await page.getByRole('menuitem', { name: 'Delete all events' }).click(); await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByTestId('navigation__toggle-settings').click(); await page.getByRole('button', { name: 'Toggle settings' }).click();
await page.getByRole('button', { name: 'Import spreadsheet' }).click(); await page.getByRole('button', { name: 'Import spreadsheet' }).click();
// workaround to upload file on hidden input // workaround to upload file on hidden input

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