Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46e9fcb3cb | |||
| f2d7f4ab82 | |||
| a5496434f8 | |||
| 42984eed87 | |||
| 59c1c77d48 | |||
| dd13eb31d8 | |||
| 82810f1cb1 | |||
| 71c468d069 | |||
| cf40001a2a | |||
| d458928090 | |||
| 76e12af116 | |||
| b577057768 | |||
| dd4a165b08 | |||
| ab847a324b | |||
| cc5c097ddc | |||
| ea2330e23b | |||
| c5870452e3 | |||
| b73b529c00 | |||
| 8ef807d6cb | |||
| 1d4ddea596 | |||
| 4cba970cda | |||
| bad9dab0a0 | |||
| f11218a757 | |||
| 77e87e1d0c | |||
| a4aa513d1f | |||
| b73448f827 | |||
| 687ad68726 | |||
| 089eba3877 | |||
| 24b0cfd24e | |||
| 5950551da2 | |||
| e46948772e | |||
| 78fa1df99c | |||
| 0500368982 | |||
| 2d53323f60 | |||
| 08ca3cd3a5 | |||
| 16fd44a441 | |||
| 9a6ca7ac67 | |||
| 2c63bbff76 | |||
| d2c34757f9 | |||
| aeadc78578 | |||
| bf98f9792a | |||
| 6d2b59ef73 | |||
| 0a53018930 | |||
| f1cac8d1c4 | |||
| cfb76153a0 | |||
| fee2021ad7 | |||
| db956f7955 | |||
| 887e5c448e | |||
| 6ce275da7a | |||
| 213f516f71 | |||
| 86e6f8b58c |
@@ -40,9 +40,6 @@ dist/
|
|||||||
ontime-db
|
ontime-db
|
||||||
ontime-external/
|
ontime-external/
|
||||||
|
|
||||||
# working database
|
|
||||||
apps/server/src/preloaded-db/db.json
|
|
||||||
|
|
||||||
# versioning file
|
# versioning file
|
||||||
**/ONTIME_VERSION.js
|
**/ONTIME_VERSION.js
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ COPY --from=builder /app/apps/client/build ./client/
|
|||||||
# Prepare Backend
|
# Prepare Backend
|
||||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||||
|
COPY --from=builder /app/apps/server/src/user/ ./user/
|
||||||
|
|
||||||
# Export default ports
|
# Export default ports
|
||||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||||
@@ -32,4 +33,4 @@ CMD ["node", "server/docker.cjs"]
|
|||||||
# Build and run commands
|
# Build and run commands
|
||||||
# !!! Note that this command needs pre-build versions of the UI and server apps
|
# !!! Note that this command needs pre-build versions of the UI and server apps
|
||||||
# docker buildx build . -t getontime/ontime
|
# docker buildx build . -t getontime/ontime
|
||||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/external/db/ -v ./ontime-styles:/external/styles/ getontime/ontime
|
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.6.0",
|
"version": "3.6.2",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.6.0",
|
"version": "3.6.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useClientPath } from './common/hooks/useClientPath';
|
|||||||
import Log from './features/log/Log';
|
import Log from './features/log/Log';
|
||||||
import withPreset from './features/PresetWrapper';
|
import withPreset from './features/PresetWrapper';
|
||||||
import withData from './features/viewers/ViewWrapper';
|
import withData from './features/viewers/ViewWrapper';
|
||||||
|
import ViewLoader from './views/ViewLoader';
|
||||||
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||||
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
|||||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||||
|
|
||||||
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
|
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
|
||||||
const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
|
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||||
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
||||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||||
@@ -75,16 +76,72 @@ export default function AppRouter() {
|
|||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<SentryRoutes>
|
<SentryRoutes>
|
||||||
<Route path='/' element={<Navigate to='/timer' />} />
|
<Route path='/' element={<Navigate to='/timer' />} />
|
||||||
<Route path='/timer' element={<STimer />} />
|
<Route
|
||||||
<Route path='/public' element={<SPublic />} />
|
path='/timer'
|
||||||
<Route path='/minimal' element={<SMinimalTimer />} />
|
element={
|
||||||
<Route path='/clock' element={<SClock />} />
|
<ViewLoader>
|
||||||
|
<STimer />
|
||||||
<Route path='/countdown' element={<SCountdown />} />
|
</ViewLoader>
|
||||||
<Route path='/backstage' element={<SBackstage />} />
|
}
|
||||||
<Route path='/studio' element={<SStudio />} />
|
/>
|
||||||
|
<Route
|
||||||
|
path='/public'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SPublic />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/minimal'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SMinimalTimer />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/clock'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SClock />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/countdown'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SCountdown />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/backstage'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SBackstage />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/studio'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<SStudio />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/*/!* Lower third cannot have a loading screen *!/*/}
|
||||||
<Route path='/lower' element={<SLowerThird />} />
|
<Route path='/lower' element={<SLowerThird />} />
|
||||||
<Route path='/timeline' element={<STimeline />} />
|
<Route
|
||||||
|
path='/timeline'
|
||||||
|
element={
|
||||||
|
<ViewLoader>
|
||||||
|
<STimeline />
|
||||||
|
</ViewLoader>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/*/!* Protected Routes *!/*/}
|
{/*/!* Protected Routes *!/*/}
|
||||||
<Route path='/editor' element={<Editor />} />
|
<Route path='/editor' element={<Editor />} />
|
||||||
|
|||||||
@@ -32,5 +32,6 @@ export const projectDataURL = `${serverURL}/project`;
|
|||||||
export const rundownURL = `${serverURL}/events`;
|
export const rundownURL = `${serverURL}/events`;
|
||||||
export const ontimeURL = `${serverURL}/ontime`;
|
export const ontimeURL = `${serverURL}/ontime`;
|
||||||
|
|
||||||
export const stylesPath = 'external/styles/override.css';
|
export const userAssetsPath = 'user';
|
||||||
export const overrideStylesURL = `${serverURL}/${stylesPath}`;
|
export const cssOverridePath = 'styles/override.css';
|
||||||
|
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||||
|
|
||||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||||
|
|
||||||
@@ -138,14 +138,6 @@ export async function deleteProject(filename: string): Promise<MessageResponse>
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP request to retrieve application info
|
|
||||||
*/
|
|
||||||
export async function getInfo(): Promise<GetInfo> {
|
|
||||||
const res = await axios.get(`${dbPath}/info`);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function gets project from db
|
* Utility function gets project from db
|
||||||
* @param fileName
|
* @param fileName
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { GetInfo } from 'ontime-types';
|
||||||
|
|
||||||
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
|
const sessionPath = `${apiEntryUrl}/session`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to retrieve application info
|
||||||
|
*/
|
||||||
|
export async function getInfo(): Promise<GetInfo> {
|
||||||
|
const res = await axios.get(`${sessionPath}/info`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
@@ -8,5 +8,5 @@
|
|||||||
.linkIcon {
|
.linkIcon {
|
||||||
margin-left: $element-inner-spacing;
|
margin-left: $element-inner-spacing;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
transform: rotate(45deg);
|
@include rotate-fourty-five;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren, useState } from 'react';
|
||||||
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
||||||
|
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||||
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
|
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
|
||||||
|
|
||||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||||
@@ -7,26 +8,34 @@ import { Size } from '../../models/Util.type';
|
|||||||
import copyToClipboard from '../../utils/copyToClipboard';
|
import copyToClipboard from '../../utils/copyToClipboard';
|
||||||
|
|
||||||
interface CopyTagProps {
|
interface CopyTagProps {
|
||||||
|
copyValue: string;
|
||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
|
||||||
size?: Size;
|
size?: Size;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
||||||
const { label, className, size = 'xs', disabled, children } = props;
|
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const handleClick = () => copyToClipboard(children as string);
|
const handleClick = () => {
|
||||||
|
copyToClipboard(copyValue);
|
||||||
|
setCopied(true);
|
||||||
|
|
||||||
|
// reset copied state
|
||||||
|
setTimeout(() => setCopied(false), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
||||||
<ButtonGroup size={size} isAttached className={className}>
|
<ButtonGroup size={size} isAttached>
|
||||||
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
|
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
icon={<IoCopy />}
|
icon={copied ? <IoCheckmark /> : <IoCopy />}
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -72,9 +72,33 @@ $button-size: 3rem;
|
|||||||
|
|
||||||
.linkIcon {
|
.linkIcon {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
transform: rotate(45deg);
|
@include rotate-fourty-five;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
border-color: $border-color-ondark;
|
border-color: $border-color-ondark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
margin-left: 1rem;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interfaces {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goIcon {
|
||||||
|
@include rotate-fourty-five;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,14 +18,17 @@ import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutl
|
|||||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||||
|
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
|
import { isLocalhost, serverPort } from '../../api/constants';
|
||||||
import useClickOutside from '../../hooks/useClickOutside';
|
import useClickOutside from '../../hooks/useClickOutside';
|
||||||
import useElectronEvent from '../../hooks/useElectronEvent';
|
import useElectronEvent from '../../hooks/useElectronEvent';
|
||||||
|
import useInfo from '../../hooks-query/useInfo';
|
||||||
import { useClientStore } from '../../stores/clientStore';
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
import { isKeyEnter } from '../../utils/keyEvent';
|
import { isKeyEnter } from '../../utils/keyEvent';
|
||||||
import { handleLinks } from '../../utils/linkUtils';
|
import { handleLinks, openLink } from '../../utils/linkUtils';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||||
|
import CopyTag from '../copy-tag/CopyTag';
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
@@ -54,7 +57,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||||
<DrawerOverlay />
|
<DrawerOverlay />
|
||||||
<DrawerContent>
|
<DrawerContent maxWidth='21rem'>
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<DrawerCloseButton size='lg' />
|
<DrawerCloseButton size='lg' />
|
||||||
Ontime
|
Ontime
|
||||||
@@ -105,25 +108,22 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
>
|
>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Editor
|
Editor
|
||||||
<IoArrowUp className={style.linkIcon} />
|
|
||||||
</Link>
|
</Link>
|
||||||
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Cuesheet
|
Cuesheet
|
||||||
<IoArrowUp className={style.linkIcon} />
|
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
<ClientLink to='op' current={location.pathname === '/op'}>
|
<ClientLink to='op' current={location.pathname === '/op'}>
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Operator
|
Operator
|
||||||
<IoArrowUp className={style.linkIcon} />
|
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
<hr className={style.separator} />
|
<hr className={style.separator} />
|
||||||
{navigatorConstants.map((route) => (
|
{navigatorConstants.map((route) => (
|
||||||
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
|
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
|
||||||
{route.label}
|
{route.label}
|
||||||
<IoArrowUp className={style.linkIcon} />
|
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
))}
|
))}
|
||||||
|
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
|
||||||
</DrawerBody>
|
</DrawerBody>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -132,6 +132,45 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OtherAddressesProps {
|
||||||
|
currentLocation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OtherAddresses(props: OtherAddressesProps) {
|
||||||
|
const { currentLocation } = props;
|
||||||
|
const { data } = useInfo();
|
||||||
|
|
||||||
|
// there is no point showing this if we only have one interface
|
||||||
|
if (data.networkInterfaces.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.bottom}>
|
||||||
|
<div className={style.sectionHeader}>Accessible on external networks</div>
|
||||||
|
<div className={style.interfaces}>
|
||||||
|
{data?.networkInterfaces?.map((nif) => {
|
||||||
|
if (nif.name === 'localhost') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = `http://${nif.address}:${serverPort}${currentLocation}`;
|
||||||
|
return (
|
||||||
|
<CopyTag
|
||||||
|
key={nif.name}
|
||||||
|
copyValue={address}
|
||||||
|
onClick={() => openLink(address)}
|
||||||
|
label='Copy IP or navigate to address'
|
||||||
|
>
|
||||||
|
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||||
|
</CopyTag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ClientLinkProps {
|
interface ClientLinkProps {
|
||||||
current: boolean;
|
current: boolean;
|
||||||
to: string;
|
to: string;
|
||||||
@@ -147,6 +186,7 @@ function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
|
|||||||
return (
|
return (
|
||||||
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
|
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
|
||||||
{children}
|
{children}
|
||||||
|
<IoArrowUp className={style.linkIcon} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
.inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt to match with ontimeTextInputs
|
||||||
|
.input {
|
||||||
|
color: $gray-200;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: $gray-1200;
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $gray-1100;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background-color: $gray-1000;
|
||||||
|
color: $gray-50;
|
||||||
|
border: 1px solid $blue-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import PopoverPicker from '../input/popover-picker/PopoverPicker';
|
||||||
|
|
||||||
|
import style from './InlineColourPicker.module.scss';
|
||||||
|
|
||||||
|
interface InlineColourPickerProps {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureHex = (value: string) => {
|
||||||
|
if (!value.startsWith('#')) {
|
||||||
|
return `#${value}`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InlineColourPicker(props: InlineColourPickerProps) {
|
||||||
|
const { name, value } = props;
|
||||||
|
const [colour, setColour] = useState(() => ensureHex(value));
|
||||||
|
|
||||||
|
const debouncedChange = (value: string) => {
|
||||||
|
setColour(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.inline}>
|
||||||
|
<PopoverPicker color={colour} onChange={debouncedChange} />
|
||||||
|
<input type='hidden' name={name} value={colour} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
|
|
||||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
|
import InlineColourPicker from './InlineColourPicker';
|
||||||
import { ParamField } from './types';
|
import { ParamField } from './types';
|
||||||
|
|
||||||
interface EditFormInputProps {
|
interface EditFormInputProps {
|
||||||
@@ -23,8 +24,8 @@ interface EditFormInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ParamInput(props: EditFormInputProps) {
|
export default function ParamInput(props: EditFormInputProps) {
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const { paramField } = props;
|
const { paramField } = props;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { id, type, defaultValue } = paramField;
|
const { id, type, defaultValue } = paramField;
|
||||||
|
|
||||||
if (type === 'persist') {
|
if (type === 'persist') {
|
||||||
@@ -81,6 +82,12 @@ export default function ParamInput(props: EditFormInputProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'colour') {
|
||||||
|
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
|
||||||
|
|
||||||
|
return <InlineColourPicker name={id} value={currentvalue} />;
|
||||||
|
}
|
||||||
|
|
||||||
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
||||||
const { prefix, placeholder } = paramField;
|
const { prefix, placeholder } = paramField;
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ import style from './ViewParamsEditor.module.scss';
|
|||||||
|
|
||||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility remove the # character from a hex string
|
||||||
|
*/
|
||||||
|
function sanitiseColour(colour: string) {
|
||||||
|
if (colour.startsWith('#')) {
|
||||||
|
return colour.substring(1);
|
||||||
|
}
|
||||||
|
return colour;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes a new URLSearchParams object from the given params object
|
* Makes a new URLSearchParams object from the given params object
|
||||||
*/
|
*/
|
||||||
@@ -40,8 +50,13 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
|
|||||||
|
|
||||||
// compare which values are different from the default values
|
// compare which values are different from the default values
|
||||||
Object.entries(paramsObj).forEach(([id, value]) => {
|
Object.entries(paramsObj).forEach(([id, value]) => {
|
||||||
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
|
if (typeof value === 'string' && value.length) {
|
||||||
newSearchParams.set(id, value);
|
// we dont know which values contain colours
|
||||||
|
// unfortunately this means we run all the strings through the sanitation
|
||||||
|
const valueWithoutHash = sanitiseColour(value);
|
||||||
|
if (defaultValues[id] !== valueWithoutHash) {
|
||||||
|
newSearchParams.set(id, valueWithoutHash);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return newSearchParams;
|
return newSearchParams;
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ type MultiOptionsField = {
|
|||||||
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
||||||
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
||||||
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
||||||
|
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
|
||||||
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
|
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
|
||||||
|
|
||||||
export type ParamField = BaseField &
|
export type ParamField = BaseField &
|
||||||
(StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
|
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField);
|
||||||
export type ViewOption = ParamSection | ParamField;
|
export type ViewOption = ParamSection | ParamField;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { GetInfo } from 'ontime-types';
|
|||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { APP_INFO } from '../api/constants';
|
import { APP_INFO } from '../api/constants';
|
||||||
import { getInfo } from '../api/db';
|
import { getInfo } from '../api/session';
|
||||||
import { ontimePlaceholderInfo } from '../models/Info';
|
import { ontimePlaceholderInfo } from '../models/Info';
|
||||||
|
|
||||||
export default function useInfo() {
|
export default function useInfo() {
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
export default function useElectronEvent() {
|
import { useCallback, useEffect } from 'react';
|
||||||
const isElectron = window?.process?.type === 'renderer';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const sendToElectron = (channel: string, args?: string | Record<string, unknown>) => {
|
const isElectron = window.process?.type === 'renderer';
|
||||||
if (isElectron) {
|
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
|
||||||
window?.ipcRenderer.send(channel, args);
|
|
||||||
|
export default function useElectronEvent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
|
||||||
|
if (isElectron && ipcRenderer) {
|
||||||
|
ipcRenderer.send(channel, args);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
// listen to requests to change the editor location
|
||||||
|
useEffect(() => {
|
||||||
|
if (isElectron) {
|
||||||
|
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
|
||||||
|
navigate(location, { relative: 'route' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the listener after the component is dismounted
|
||||||
|
return () => {
|
||||||
|
ipcRenderer?.removeAllListeners();
|
||||||
|
};
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
return { isElectron, sendToElectron };
|
return { isElectron, sendToElectron };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import {
|
|||||||
parseUserTime,
|
parseUserTime,
|
||||||
reorderArray,
|
reorderArray,
|
||||||
swapEventData,
|
swapEventData,
|
||||||
validateEndAction,
|
|
||||||
validateTimerType,
|
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
@@ -37,6 +35,7 @@ export const useEventAction = () => {
|
|||||||
const {
|
const {
|
||||||
defaultPublic,
|
defaultPublic,
|
||||||
linkPrevious,
|
linkPrevious,
|
||||||
|
defaultTimeStrategy,
|
||||||
defaultDuration,
|
defaultDuration,
|
||||||
defaultWarnTime,
|
defaultWarnTime,
|
||||||
defaultDangerTime,
|
defaultDangerTime,
|
||||||
@@ -116,11 +115,15 @@ export const useEventAction = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newEvent.timerType === undefined) {
|
if (newEvent.timerType === undefined) {
|
||||||
newEvent.timerType = validateTimerType(defaultTimerType);
|
newEvent.timerType = defaultTimerType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newEvent.endAction === undefined) {
|
if (newEvent.endAction === undefined) {
|
||||||
newEvent.endAction = validateEndAction(defaultEndAction);
|
newEvent.endAction = defaultEndAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEvent.timeStrategy === undefined) {
|
||||||
|
newEvent.timeStrategy = defaultTimeStrategy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +145,7 @@ export const useEventAction = () => {
|
|||||||
defaultEndAction,
|
defaultEndAction,
|
||||||
defaultPublic,
|
defaultPublic,
|
||||||
defaultTimerType,
|
defaultTimerType,
|
||||||
|
defaultTimeStrategy,
|
||||||
defaultWarnTime,
|
defaultWarnTime,
|
||||||
linkPrevious,
|
linkPrevious,
|
||||||
queryClient,
|
queryClient,
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ export const ontimePlaceholderInfo: GetInfo = {
|
|||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
serverPort: 4001,
|
serverPort: 4001,
|
||||||
osc: oscPlaceholderSettings,
|
osc: oscPlaceholderSettings,
|
||||||
cssOverride: '',
|
publicDir: '',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { EndAction, TimerType } from 'ontime-types';
|
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
import { validateEndAction, validateTimerType } from 'ontime-utils';
|
import { validateEndAction, validateTimerType, validateTimeStrategy } from 'ontime-utils';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||||
@@ -7,6 +7,7 @@ import { booleanFromLocalStorage } from '../utils/localStorage';
|
|||||||
type EditorSettingsStore = {
|
type EditorSettingsStore = {
|
||||||
defaultDuration: string;
|
defaultDuration: string;
|
||||||
linkPrevious: boolean;
|
linkPrevious: boolean;
|
||||||
|
defaultTimeStrategy: TimeStrategy;
|
||||||
defaultWarnTime: string;
|
defaultWarnTime: string;
|
||||||
defaultDangerTime: string;
|
defaultDangerTime: string;
|
||||||
defaultPublic: boolean;
|
defaultPublic: boolean;
|
||||||
@@ -14,6 +15,7 @@ type EditorSettingsStore = {
|
|||||||
defaultEndAction: EndAction;
|
defaultEndAction: EndAction;
|
||||||
setDefaultDuration: (defaultDuration: string) => void;
|
setDefaultDuration: (defaultDuration: string) => void;
|
||||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||||
|
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
|
||||||
setWarnTime: (warnTime: string) => void;
|
setWarnTime: (warnTime: string) => void;
|
||||||
setDangerTime: (dangerTime: string) => void;
|
setDangerTime: (dangerTime: string) => void;
|
||||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||||
@@ -24,6 +26,7 @@ type EditorSettingsStore = {
|
|||||||
export const editorSettingsDefaults = {
|
export const editorSettingsDefaults = {
|
||||||
duration: '00:10:00',
|
duration: '00:10:00',
|
||||||
linkPrevious: true,
|
linkPrevious: true,
|
||||||
|
timeStrategy: TimeStrategy.LockDuration,
|
||||||
warnTime: '00:02:00', // 120000 same as backend
|
warnTime: '00:02:00', // 120000 same as backend
|
||||||
dangerTime: '00:01:00', // 60000 same as backend
|
dangerTime: '00:01:00', // 60000 same as backend
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
@@ -34,6 +37,7 @@ export const editorSettingsDefaults = {
|
|||||||
enum EditorSettingsKeys {
|
enum EditorSettingsKeys {
|
||||||
DefaultDuration = 'ontime-default-duration',
|
DefaultDuration = 'ontime-default-duration',
|
||||||
LinkPrevious = 'ontime-link-previous',
|
LinkPrevious = 'ontime-link-previous',
|
||||||
|
DefaultTimeStrategy = 'ontime-time-strategy',
|
||||||
DefaultWarnTime = 'ontime-default-warn-time',
|
DefaultWarnTime = 'ontime-default-warn-time',
|
||||||
DefaultDangerTime = 'ontime-default-danger-time',
|
DefaultDangerTime = 'ontime-default-danger-time',
|
||||||
DefaultPublic = 'ontime-default-public',
|
DefaultPublic = 'ontime-default-public',
|
||||||
@@ -45,6 +49,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
|||||||
return {
|
return {
|
||||||
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
|
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
|
||||||
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
|
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
|
||||||
|
defaultTimeStrategy: validateTimeStrategy(
|
||||||
|
localStorage.getItem(EditorSettingsKeys.DefaultTimeStrategy),
|
||||||
|
editorSettingsDefaults.timeStrategy,
|
||||||
|
),
|
||||||
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
|
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
|
||||||
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
|
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
|
||||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
|
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
|
||||||
@@ -68,6 +76,12 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
|||||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
||||||
return { linkPrevious };
|
return { linkPrevious };
|
||||||
}),
|
}),
|
||||||
|
setTimeStrategy: (defaultTimeStrategy) =>
|
||||||
|
set(() => {
|
||||||
|
localStorage.setItem(EditorSettingsKeys.DefaultTimeStrategy, String(defaultTimeStrategy));
|
||||||
|
return { defaultTimeStrategy };
|
||||||
|
}),
|
||||||
|
|
||||||
setWarnTime: (defaultWarnTime) =>
|
setWarnTime: (defaultWarnTime) =>
|
||||||
set(() => {
|
set(() => {
|
||||||
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
|
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
|
||||||
|
|||||||
@@ -1,58 +1,7 @@
|
|||||||
import isEqual from 'react-fast-compare';
|
import isEqual from 'react-fast-compare';
|
||||||
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
|
import { RuntimeStore, runtimeStorePlaceholder } from 'ontime-types';
|
||||||
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
|
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
|
||||||
|
|
||||||
export const runtimeStorePlaceholder: RuntimeStore = {
|
|
||||||
clock: 0,
|
|
||||||
timer: {
|
|
||||||
addedTime: 0,
|
|
||||||
current: null,
|
|
||||||
duration: null,
|
|
||||||
elapsed: null,
|
|
||||||
expectedFinish: null,
|
|
||||||
finishedAt: null,
|
|
||||||
phase: TimerPhase.None,
|
|
||||||
playback: Playback.Stop,
|
|
||||||
secondaryTimer: null,
|
|
||||||
startedAt: null,
|
|
||||||
},
|
|
||||||
onAir: false,
|
|
||||||
message: {
|
|
||||||
timer: {
|
|
||||||
text: '',
|
|
||||||
visible: false,
|
|
||||||
blink: false,
|
|
||||||
blackout: false,
|
|
||||||
secondarySource: null,
|
|
||||||
},
|
|
||||||
external: '',
|
|
||||||
},
|
|
||||||
runtime: {
|
|
||||||
selectedEventIndex: null,
|
|
||||||
numEvents: 0,
|
|
||||||
offset: 0,
|
|
||||||
plannedStart: 0,
|
|
||||||
plannedEnd: 0,
|
|
||||||
actualStart: null,
|
|
||||||
expectedEnd: null,
|
|
||||||
},
|
|
||||||
currentBlock: {
|
|
||||||
block: null,
|
|
||||||
startedAt: null,
|
|
||||||
},
|
|
||||||
eventNow: null,
|
|
||||||
eventNext: null,
|
|
||||||
publicEventNow: null,
|
|
||||||
publicEventNext: null,
|
|
||||||
auxtimer1: {
|
|
||||||
current: 0,
|
|
||||||
direction: SimpleDirection.CountUp,
|
|
||||||
duration: 0,
|
|
||||||
playback: SimplePlayback.Stop,
|
|
||||||
},
|
|
||||||
frozen: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
|
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
|
||||||
|
|
||||||
export const runtimeStore = createWithEqualityFn<RuntimeStore>(
|
export const runtimeStore = createWithEqualityFn<RuntimeStore>(
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/rele
|
|||||||
export const websiteUrl = 'https://www.getontime.no';
|
export const websiteUrl = 'https://www.getontime.no';
|
||||||
|
|
||||||
export const documentationUrl = 'https://docs.getontime.no';
|
export const documentationUrl = 'https://docs.getontime.no';
|
||||||
|
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
.halfWidth {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
.fullWidth {
|
.fullWidth {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -37,3 +41,9 @@
|
|||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.twoCols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function UrlPresetsForm() {
|
|||||||
|
|
||||||
const addNew = () => {
|
const addNew = () => {
|
||||||
prepend({
|
prepend({
|
||||||
enabled: false,
|
enabled: true,
|
||||||
alias: '',
|
alias: '',
|
||||||
pathAndParams: '',
|
pathAndParams: '',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
|||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||||
|
|
||||||
|
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||||
|
|
||||||
import CustomFieldForm from './CustomFieldForm';
|
import CustomFieldForm from './CustomFieldForm';
|
||||||
@@ -36,6 +37,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
|||||||
onSubmit={handleEdit}
|
onSubmit={handleEdit}
|
||||||
initialColour={colour}
|
initialColour={colour}
|
||||||
initialLabel={label}
|
initialLabel={label}
|
||||||
|
initialKey={field}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -47,7 +49,12 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
|||||||
<td>
|
<td>
|
||||||
<Swatch color={colour} />
|
<Swatch color={colour} />
|
||||||
</td>
|
</td>
|
||||||
<td className={style.fullWidth}>{label}</td>
|
<td className={style.halfWidth}>{label}</td>
|
||||||
|
<td className={style.fullWidth}>
|
||||||
|
<CopyTag label='Copy key to use in integrations' copyValue={field}>
|
||||||
|
{field}
|
||||||
|
</CopyTag>
|
||||||
|
</td>
|
||||||
<td className={style.actions}>
|
<td className={style.actions}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size='sm'
|
size='sm'
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Button, Input } from '@chakra-ui/react';
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
import { CustomField } from 'ontime-types';
|
import { CustomField } from 'ontime-types';
|
||||||
import { isAlphanumeric } from 'ontime-utils';
|
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||||
|
|
||||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||||
|
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||||
import * as Panel from '../../PanelUtils';
|
import * as Panel from '../../PanelUtils';
|
||||||
|
|
||||||
import style from '../FeatureSettings.module.scss';
|
import style from '../FeatureSettings.module.scss';
|
||||||
@@ -15,10 +16,13 @@ interface CustomFieldsFormProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
initialColour?: string;
|
initialColour?: string;
|
||||||
initialLabel?: string;
|
initialLabel?: string;
|
||||||
|
initialKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||||
const { onSubmit, onCancel, initialColour, initialLabel } = props;
|
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
|
||||||
|
const { data } = useCustomFields();
|
||||||
|
|
||||||
// we use this to force an update
|
// we use this to force an update
|
||||||
const [_, setColour] = useState(initialColour || '');
|
const [_, setColour] = useState(initialColour || '');
|
||||||
|
|
||||||
@@ -31,7 +35,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
|||||||
getValues,
|
getValues,
|
||||||
formState: { errors, isSubmitting, isValid, isDirty },
|
formState: { errors, isSubmitting, isValid, isDirty },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
defaultValues: { label: initialLabel || '', colour: initialColour || '' },
|
defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' },
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDirtyValues: true,
|
keepDirtyValues: true,
|
||||||
},
|
},
|
||||||
@@ -66,28 +70,38 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
||||||
<div className={style.column}>
|
<div className={style.twoCols}>
|
||||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
<div>
|
||||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||||
<Input
|
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||||
{...register('label', {
|
<Input
|
||||||
required: { value: true, message: 'Required field' },
|
{...register('label', {
|
||||||
validate: (value) => {
|
required: { value: true, message: 'Required field' },
|
||||||
if (value.trim().length === 0) return 'Required field';
|
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
||||||
if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed';
|
validate: (value) => {
|
||||||
return true;
|
if (value.trim().length === 0) return 'Required field';
|
||||||
},
|
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
|
||||||
})}
|
if (Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||||
size='sm'
|
return true;
|
||||||
variant='ontime-filled'
|
},
|
||||||
autoComplete='off'
|
})}
|
||||||
/>
|
size='sm'
|
||||||
|
variant='ontime-filled'
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Panel.Description>Key (auto-generated value for use in Integrations and API)</Panel.Description>
|
||||||
|
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Panel.Description>Colour</Panel.Description>
|
<Panel.Description>Colour</Panel.Description>
|
||||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
<div className={style.buttonRow}>
|
<div className={style.buttonRow}>
|
||||||
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
|
|||||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
||||||
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
|
||||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||||
|
import { customFieldsDocsUrl } from '../../../../../externals';
|
||||||
import * as Panel from '../../PanelUtils';
|
import * as Panel from '../../PanelUtils';
|
||||||
|
|
||||||
import CustomFieldEntry from './CustomFieldEntry';
|
import CustomFieldEntry from './CustomFieldEntry';
|
||||||
import CustomFieldForm from './CustomFieldForm';
|
import CustomFieldForm from './CustomFieldForm';
|
||||||
|
|
||||||
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
|
||||||
|
|
||||||
export default function CustomFields() {
|
export default function CustomFields() {
|
||||||
const { data, refetch } = useCustomFields();
|
const { data, refetch } = useCustomFields();
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
@@ -59,9 +58,14 @@ export default function CustomFields() {
|
|||||||
<Alert status='info' variant='ontime-on-dark-info'>
|
<Alert status='info' variant='ontime-on-dark-info'>
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br />
|
Custom fields allow for additional information to be added to an event.
|
||||||
<br />
|
<br />
|
||||||
This data is not used by Ontime.
|
<br />
|
||||||
|
This data is not used by Ontime, but provides place for cueing or department specific information (eg.
|
||||||
|
light, sound, camera).
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Custom fields can be used width the Integrations feature using the generated key.
|
||||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -73,6 +77,7 @@ export default function CustomFields() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Colour</th>
|
<th>Colour</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
|
<th>Key (used in Integrations)</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ export default function ViewSettingsForm() {
|
|||||||
<Alert status='info' variant='ontime-on-dark-info'>
|
<Alert status='info' variant='ontime-on-dark-info'>
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
You can override the styles of the viewers with a custom CSS file. <br />
|
You can the Ontime views or customise its styles by modifying the provided CSS file. <br />
|
||||||
{info?.cssOverride && `In your installation the file is at ${info?.cssOverride}`}
|
The CSS file is in the user directory at {`${info.publicDir}/user/styles/override.css`}
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
|
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function HttpIntegrations() {
|
|||||||
id: generateId(),
|
id: generateId(),
|
||||||
cycle: 'onLoad',
|
cycle: 'onLoad',
|
||||||
message: '',
|
message: '',
|
||||||
enabled: false,
|
enabled: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default function OscIntegrations() {
|
|||||||
cycle: 'onLoad',
|
cycle: 'onLoad',
|
||||||
address: '',
|
address: '',
|
||||||
payload: '',
|
payload: '',
|
||||||
enabled: false,
|
enabled: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Select, Switch } from '@chakra-ui/react';
|
import { Select, Switch } from '@chakra-ui/react';
|
||||||
import { EndAction, TimerType } from 'ontime-types';
|
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
import { parseUserTime } from 'ontime-utils';
|
import { parseUserTime } from 'ontime-utils';
|
||||||
|
|
||||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||||
@@ -10,6 +10,7 @@ export default function EditorSettingsForm() {
|
|||||||
const {
|
const {
|
||||||
defaultDuration,
|
defaultDuration,
|
||||||
linkPrevious,
|
linkPrevious,
|
||||||
|
defaultTimeStrategy,
|
||||||
defaultWarnTime,
|
defaultWarnTime,
|
||||||
defaultDangerTime,
|
defaultDangerTime,
|
||||||
defaultPublic,
|
defaultPublic,
|
||||||
@@ -17,6 +18,7 @@ export default function EditorSettingsForm() {
|
|||||||
defaultEndAction,
|
defaultEndAction,
|
||||||
setDefaultDuration,
|
setDefaultDuration,
|
||||||
setLinkPrevious,
|
setLinkPrevious,
|
||||||
|
setTimeStrategy,
|
||||||
setWarnTime,
|
setWarnTime,
|
||||||
setDangerTime,
|
setDangerTime,
|
||||||
setDefaultPublic,
|
setDefaultPublic,
|
||||||
@@ -36,15 +38,6 @@ export default function EditorSettingsForm() {
|
|||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Title>Rundown defaults for new events</Panel.Title>
|
<Panel.Title>Rundown defaults for new events</Panel.Title>
|
||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
<Panel.ListItem>
|
|
||||||
<Panel.Field title='Default duration' description='Default duration for new events' />
|
|
||||||
<TimeInput<'defaultDuration'>
|
|
||||||
name='defaultDuration'
|
|
||||||
submitHandler={(_field, value) => setDefaultDuration(value)}
|
|
||||||
time={durationInMs}
|
|
||||||
placeholder={editorSettingsDefaults.duration}
|
|
||||||
/>
|
|
||||||
</Panel.ListItem>
|
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='Link previous'
|
title='Link previous'
|
||||||
@@ -57,6 +50,33 @@ export default function EditorSettingsForm() {
|
|||||||
onChange={(event) => setLinkPrevious(event.target.checked)}
|
onChange={(event) => setLinkPrevious(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field
|
||||||
|
title='Timer strategy'
|
||||||
|
description='Which time should be maintained when event schedule is recalculated'
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
variant='ontime'
|
||||||
|
size='sm'
|
||||||
|
width='auto'
|
||||||
|
value={defaultTimeStrategy}
|
||||||
|
onChange={(event) => setTimeStrategy(event.target.value as TimeStrategy)}
|
||||||
|
>
|
||||||
|
<option value={TimeStrategy.LockDuration}>Duration</option>
|
||||||
|
<option value={TimeStrategy.LockEnd}>End Time</option>
|
||||||
|
</Select>
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title='Default duration' description='Default duration for new events' />
|
||||||
|
<TimeInput<'defaultDuration'>
|
||||||
|
name='defaultDuration'
|
||||||
|
submitHandler={(_field, value) => setDefaultDuration(value)}
|
||||||
|
time={durationInMs}
|
||||||
|
placeholder={editorSettingsDefaults.duration}
|
||||||
|
/>
|
||||||
|
</Panel.ListItem>
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field title='Timer type' description='Default type of timer for new events' />
|
<Panel.Field title='Timer type' description='Default type of timer for new events' />
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -4,3 +4,9 @@
|
|||||||
gap: $section-spacing;
|
gap: $section-spacing;
|
||||||
row-gap: $element-inner-spacing;
|
row-gap: $element-inner-spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.goIcon {
|
||||||
|
@include rotate-fourty-five;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
|
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||||
|
|
||||||
import { serverPort } from '../../../../common/api/constants';
|
import { serverPort } from '../../../../common/api/constants';
|
||||||
import AppLink from '../../../../common/components/app-link/AppLink';
|
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||||
|
import { openLink } from '../../../../common/utils/linkUtils';
|
||||||
|
|
||||||
import style from './NetworkInterfaces.module.scss';
|
import style from './NetworkInterfaces.module.scss';
|
||||||
|
|
||||||
export default function InfoNif() {
|
export default function InfoNif() {
|
||||||
const { data } = useInfo();
|
const { data } = useInfo();
|
||||||
|
|
||||||
|
const handleClick = (address: string) => openLink(address);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.interfaces}>
|
<div className={style.interfaces}>
|
||||||
{data?.networkInterfaces?.map((nif) => (
|
{data?.networkInterfaces?.map((nif) => {
|
||||||
<AppLink key={nif.address} href={`http://${nif.address}:${serverPort}`}>
|
const address = `http://${nif.address}:${serverPort}`;
|
||||||
{`${nif.name} - ${nif.address}`}
|
|
||||||
</AppLink>
|
return (
|
||||||
))}
|
<CopyTag
|
||||||
|
key={nif.name}
|
||||||
|
copyValue={address}
|
||||||
|
onClick={() => handleClick(address)}
|
||||||
|
label='Copy IP or navigate to address'
|
||||||
|
>
|
||||||
|
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
|
||||||
|
</CopyTag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ChangeEvent, useRef, useState } from 'react';
|
import { ChangeEvent, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Button, Input } from '@chakra-ui/react';
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||||
|
|
||||||
@@ -13,14 +14,17 @@ import ProjectList from './ProjectList';
|
|||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
export default function ManageProjects() {
|
export default function ManageProjects() {
|
||||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState<'import' | null>(null);
|
const [loading, setLoading] = useState<'import' | null>(null);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const isCreatingProject = searchParams.get('create') === 'true';
|
||||||
|
|
||||||
const handleToggleCreate = () => {
|
const handleToggleCreate = () => {
|
||||||
setIsCreatingProject((prev) => !prev);
|
searchParams.set('create', isCreatingProject ? 'false' : 'true');
|
||||||
|
setSearchParams(searchParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectFile = () => {
|
const handleSelectFile = () => {
|
||||||
@@ -49,7 +53,8 @@ export default function ManageProjects() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
const handleCloseForm = () => {
|
||||||
setIsCreatingProject(false);
|
searchParams.delete('create');
|
||||||
|
setSearchParams(searchParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
|||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
<div className={style.buttonRow}>
|
<div className={style.buttonRow}>
|
||||||
{isAuthenticating && <Spinner />}
|
{isAuthenticating && <Spinner />}
|
||||||
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
|
<CopyTag copyValue={authKey ?? ''} 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>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
|||||||
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
|
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
|
||||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||||
import { ImportMap } from 'ontime-utils';
|
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
|
||||||
|
|
||||||
import { isAlphanumeric } from '../../../../../common/utils/regex';
|
|
||||||
import * as Panel from '../../PanelUtils';
|
import * as Panel from '../../PanelUtils';
|
||||||
import useGoogleSheet from '../useGoogleSheet';
|
import useGoogleSheet from '../useGoogleSheet';
|
||||||
import { useSheetStore } from '../useSheetStore';
|
import { useSheetStore } from '../useSheetStore';
|
||||||
@@ -190,9 +189,10 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
|||||||
defaultValue={ontimeName}
|
defaultValue={ontimeName}
|
||||||
placeholder='Name of the field as shown in Ontime'
|
placeholder='Name of the field as shown in Ontime'
|
||||||
{...register(`custom.${index}.ontimeName`, {
|
{...register(`custom.${index}.ontimeName`, {
|
||||||
pattern: {
|
validate: (value) => {
|
||||||
value: isAlphanumeric,
|
if (!isAlphanumericWithSpace(value))
|
||||||
message: 'Custom field name must be alphanumeric',
|
return 'Only alphanumeric characters and space are allowed';
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
// we only count Ontime Events which are 1 based in client
|
// we only count Ontime Events which are 1 based in client
|
||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
|
|
||||||
const fieldHeaders = Object.keys(customFields);
|
const fieldKeys = Object.keys(customFields);
|
||||||
|
const fieldLabels = fieldKeys.map((key) => customFields[key].label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
@@ -44,8 +45,8 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
<th>Colour</th>
|
<th>Colour</th>
|
||||||
<th>Timer Type</th>
|
<th>Timer Type</th>
|
||||||
<th>End Action</th>
|
<th>End Action</th>
|
||||||
{fieldHeaders.map((field) => (
|
{fieldLabels.map((label) => (
|
||||||
<th key={field}>{field}</th>
|
<th key={label}>{label}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -102,7 +103,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
<Tag>{event.endAction}</Tag>
|
<Tag>{event.endAction}</Tag>
|
||||||
</td>
|
</td>
|
||||||
{isOntimeEvent(event) &&
|
{isOntimeEvent(event) &&
|
||||||
fieldHeaders.map((field) => {
|
fieldKeys.map((field) => {
|
||||||
let value = '';
|
let value = '';
|
||||||
if (field in event.custom) {
|
if (field in event.custom) {
|
||||||
value = event.custom[field];
|
value = event.custom[field];
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.corner {
|
.corner {
|
||||||
transform: rotate(45deg);
|
@include rotate-fourty-five;
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function TimerPreview() {
|
|||||||
<div className={contentClasses}>
|
<div className={contentClasses}>
|
||||||
<div
|
<div
|
||||||
className={style.mainContent}
|
className={style.mainContent}
|
||||||
data-phase={phase}
|
data-phase={showColourOverride && phase}
|
||||||
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
|
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
|
||||||
>
|
>
|
||||||
{main}
|
{main}
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
|||||||
showPrevious,
|
showPrevious,
|
||||||
togglePreviousVisibility,
|
togglePreviousVisibility,
|
||||||
showDelayBlock,
|
showDelayBlock,
|
||||||
|
hideSeconds,
|
||||||
showDelayedTimes,
|
showDelayedTimes,
|
||||||
toggleIndexColumn,
|
toggleIndexColumn,
|
||||||
toggleDelayedTimes,
|
toggleDelayedTimes,
|
||||||
toggleDelayVisibility,
|
toggleDelayVisibility,
|
||||||
|
toggleSecondsVisibility,
|
||||||
} = useCuesheetSettings();
|
} = useCuesheetSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -80,6 +82,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
|||||||
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
|
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
|
||||||
Show delay blocks
|
Show delay blocks
|
||||||
</label>
|
</label>
|
||||||
|
<label className={style.option}>
|
||||||
|
<Switch variant='ontime' size='sm' isChecked={hideSeconds} onChange={() => toggleSecondsVisibility()} />
|
||||||
|
Hide seconds
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.rightPanel}>
|
<div className={style.rightPanel}>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useCallback } from 'react';
|
|||||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||||
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
|
||||||
|
|
||||||
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
||||||
import RunningTime from '../viewers/common/running-time/RunningTime';
|
import RunningTime from '../viewers/common/running-time/RunningTime';
|
||||||
@@ -19,20 +18,28 @@ function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
|
|||||||
|
|
||||||
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
||||||
|
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
|
||||||
const cellValue = (getValue() as number | null) ?? 0;
|
const cellValue = (getValue() as number | null) ?? 0;
|
||||||
const delayValue = (original as OntimeEvent)?.delay ?? 0;
|
const delayValue = (original as OntimeEvent)?.delay ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={style.time}>
|
<span className={style.time}>
|
||||||
<DelayIndicator delayValue={delayValue} />
|
<DelayIndicator delayValue={delayValue} />
|
||||||
<RunningTime value={cellValue} />
|
<RunningTime value={cellValue} hideSeconds={hideSeconds} />
|
||||||
{delayValue !== 0 && showDelayedTimes && (
|
{delayValue !== 0 && showDelayedTimes && (
|
||||||
<RunningTime className={style.delayedTime} value={cellValue + delayValue} />
|
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideSeconds} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
|
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
|
||||||
|
const cellValue = (getValue() as number | null) ?? 0;
|
||||||
|
|
||||||
|
return <RunningTime value={cellValue} hideSeconds={hideSeconds} />;
|
||||||
|
}
|
||||||
|
|
||||||
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
@@ -97,7 +104,7 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
|||||||
accessorKey: 'duration',
|
accessorKey: 'duration',
|
||||||
id: 'duration',
|
id: 'duration',
|
||||||
header: 'Duration',
|
header: 'Duration',
|
||||||
cell: (row) => millisToString(row.getValue() as number | null),
|
cell: MakeDuration,
|
||||||
size: 75,
|
size: 75,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface CuesheetSettings {
|
|||||||
showPrevious: boolean;
|
showPrevious: boolean;
|
||||||
showDelayBlock: boolean;
|
showDelayBlock: boolean;
|
||||||
showDelayedTimes: boolean;
|
showDelayedTimes: boolean;
|
||||||
|
hideSeconds: boolean;
|
||||||
|
|
||||||
toggleSettings: (newValue?: boolean) => void;
|
toggleSettings: (newValue?: boolean) => void;
|
||||||
toggleFollow: (newValue?: boolean) => void;
|
toggleFollow: (newValue?: boolean) => void;
|
||||||
@@ -16,6 +17,7 @@ interface CuesheetSettings {
|
|||||||
toggleIndexColumn: (newValue?: boolean) => void;
|
toggleIndexColumn: (newValue?: boolean) => void;
|
||||||
toggleDelayVisibility: (newValue?: boolean) => void;
|
toggleDelayVisibility: (newValue?: boolean) => void;
|
||||||
toggleDelayedTimes: (newValue?: boolean) => void;
|
toggleDelayedTimes: (newValue?: boolean) => void;
|
||||||
|
toggleSecondsVisibility: (newValue?: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle(oldValue: boolean, value?: boolean) {
|
function toggle(oldValue: boolean, value?: boolean) {
|
||||||
@@ -31,6 +33,7 @@ enum CuesheetKeys {
|
|||||||
PreviousVisibility = 'ontime-cuesheet-show-previous',
|
PreviousVisibility = 'ontime-cuesheet-show-previous',
|
||||||
ColumnIndex = 'ontime-cuesheet-show-index-column',
|
ColumnIndex = 'ontime-cuesheet-show-index-column',
|
||||||
DelayedTimes = 'ontime-cuesheet-show-delayed',
|
DelayedTimes = 'ontime-cuesheet-show-delayed',
|
||||||
|
Seconds = 'ontime-cuesheet-hide-sceconds',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
||||||
@@ -40,6 +43,7 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
|||||||
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
|
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
|
||||||
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
|
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
|
||||||
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
|
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
|
||||||
|
hideSeconds: booleanFromLocalStorage(CuesheetKeys.Seconds, false),
|
||||||
|
|
||||||
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
|
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
|
||||||
toggleFollow: (newValue?: boolean) =>
|
toggleFollow: (newValue?: boolean) =>
|
||||||
@@ -72,4 +76,10 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
|||||||
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
|
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
|
||||||
return { showDelayedTimes };
|
return { showDelayedTimes };
|
||||||
}),
|
}),
|
||||||
|
toggleSecondsVisibility: (newValue?: boolean) =>
|
||||||
|
set((state) => {
|
||||||
|
const hideSeconds = toggle(state.hideSeconds, newValue);
|
||||||
|
localStorage.setItem(CuesheetKeys.Seconds, String(hideSeconds));
|
||||||
|
return { hideSeconds };
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CSSProperties, useCallback, useEffect, useState } from 'react';
|
import { CSSProperties, memo, useCallback, useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||||
@@ -126,10 +126,32 @@ export default function EventEditor() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.footer}>
|
<EventEditorFooter id={event.id} cue={event.cue} />
|
||||||
<CopyTag label='OSC trigger by id'>{`/ontime/load/id "${event.id}"`}</CopyTag>
|
</div>
|
||||||
<CopyTag label='OSC trigger by cue'>{`/ontime/load/cue "${event.cue}"`}</CopyTag>
|
);
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
interface EventEditorFooterProps {
|
||||||
|
id: string;
|
||||||
|
cue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventEditorFooter = memo(_EventEditorFooter);
|
||||||
|
|
||||||
|
function _EventEditorFooter(props: EventEditorFooterProps) {
|
||||||
|
const { id, cue } = props;
|
||||||
|
|
||||||
|
const loadById = `/ontime/load/id "${id}"`;
|
||||||
|
const loadByCue = `/ontime/load/cue "${cue}"`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.footer}>
|
||||||
|
<CopyTag copyValue={loadById} label='OSC trigger by ID'>
|
||||||
|
{loadById}
|
||||||
|
</CopyTag>
|
||||||
|
<CopyTag copyValue={loadByCue} label='OSC trigger by cue'>
|
||||||
|
{loadByCue}
|
||||||
|
</CopyTag>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { MaybeString, OntimeEvent, TimeStrategy } from 'ontime-types';
|
|||||||
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
import { tooltipDelayFast, tooltipDelayMid } from '../../../ontimeConfig';
|
||||||
|
|
||||||
import style from './TimeInputFlow.module.scss';
|
import style from './TimeInputFlow.module.scss';
|
||||||
|
|
||||||
@@ -66,10 +66,12 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
|||||||
placeholder='Start'
|
placeholder='Start'
|
||||||
disabled={Boolean(linkStart)}
|
disabled={Boolean(linkStart)}
|
||||||
>
|
>
|
||||||
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
|
<Tooltip label='Link start to previous end' openDelay={tooltipDelayMid}>
|
||||||
<span className={style.timeLabel}>S</span>
|
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
|
||||||
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
|
<span className={style.timeLabel}>S</span>
|
||||||
</InputRightElement>
|
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
|
||||||
|
</InputRightElement>
|
||||||
|
</Tooltip>
|
||||||
</TimeInputWithButton>
|
</TimeInputWithButton>
|
||||||
|
|
||||||
<TimeInputWithButton<TimeActions>
|
<TimeInputWithButton<TimeActions>
|
||||||
@@ -80,14 +82,16 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
|||||||
disabled={isLockedDuration}
|
disabled={isLockedDuration}
|
||||||
placeholder='End'
|
placeholder='End'
|
||||||
>
|
>
|
||||||
<InputRightElement
|
<Tooltip label='Lock end' openDelay={tooltipDelayMid}>
|
||||||
className={activeEnd}
|
<InputRightElement
|
||||||
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
|
className={activeEnd}
|
||||||
data-testid='lock__end'
|
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
|
||||||
>
|
data-testid='lock__end'
|
||||||
<span className={style.timeLabel}>E</span>
|
>
|
||||||
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
|
<span className={style.timeLabel}>E</span>
|
||||||
</InputRightElement>
|
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||||
|
</InputRightElement>
|
||||||
|
</Tooltip>
|
||||||
</TimeInputWithButton>
|
</TimeInputWithButton>
|
||||||
|
|
||||||
<TimeInputWithButton<TimeActions>
|
<TimeInputWithButton<TimeActions>
|
||||||
@@ -97,14 +101,16 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
|||||||
disabled={isLockedEnd}
|
disabled={isLockedEnd}
|
||||||
placeholder='Duration'
|
placeholder='Duration'
|
||||||
>
|
>
|
||||||
<InputRightElement
|
<Tooltip label='Lock duration' openDelay={tooltipDelayMid}>
|
||||||
className={activeDuration}
|
<InputRightElement
|
||||||
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
|
className={activeDuration}
|
||||||
data-testid='lock__duration'
|
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
|
||||||
>
|
data-testid='lock__duration'
|
||||||
<span className={style.timeLabel}>D</span>
|
>
|
||||||
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
|
<span className={style.timeLabel}>D</span>
|
||||||
</InputRightElement>
|
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||||
|
</InputRightElement>
|
||||||
|
</Tooltip>
|
||||||
</TimeInputWithButton>
|
</TimeInputWithButton>
|
||||||
|
|
||||||
{overMidnight && (
|
{overMidnight && (
|
||||||
|
|||||||
@@ -86,11 +86,6 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
timerType: eventNow?.timerType ?? null,
|
timerType: eventNow?.timerType ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// prevent render until we get all the data we need
|
|
||||||
if (!viewSettings) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewNavigationMenu />
|
<ViewNavigationMenu />
|
||||||
|
|||||||
@@ -2,17 +2,15 @@ import { useEffect, useState } 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';
|
||||||
import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
|
import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent } from 'ontime-types';
|
||||||
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
|
||||||
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
|
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
|
||||||
import Schedule from '../../../common/components/schedule/Schedule';
|
import Schedule from '../../../common/components/schedule/Schedule';
|
||||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
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 { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||||
import { timerPlaceholderMin } from '../../../common/utils/styleUtils';
|
import { timerPlaceholderMin } from '../../../common/utils/styleUtils';
|
||||||
@@ -37,25 +35,12 @@ interface BackstageProps {
|
|||||||
backstageEvents: OntimeEvent[];
|
backstageEvents: OntimeEvent[];
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
general: ProjectData;
|
general: ProjectData;
|
||||||
viewSettings: ViewSettings;
|
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Backstage(props: BackstageProps) {
|
export default function Backstage(props: BackstageProps) {
|
||||||
const {
|
const { customFields, isMirrored, eventNow, eventNext, time, backstageEvents, selectedId, general, settings } = props;
|
||||||
customFields,
|
|
||||||
isMirrored,
|
|
||||||
eventNow,
|
|
||||||
eventNext,
|
|
||||||
time,
|
|
||||||
backstageEvents,
|
|
||||||
selectedId,
|
|
||||||
general,
|
|
||||||
viewSettings,
|
|
||||||
settings,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [blinkClass, setBlinkClass] = useState(false);
|
const [blinkClass, setBlinkClass] = useState(false);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -73,11 +58,6 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
|
|
||||||
// defer rendering until we load stylesheets
|
|
||||||
if (!shouldRender) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clock = formatTime(time.clock);
|
const clock = formatTime(time.clock);
|
||||||
const startedAt = formatTime(time.startedAt);
|
const startedAt = formatTime(time.startedAt);
|
||||||
const isNegative = (time.current ?? 0) < 0;
|
const isNegative = (time.current ?? 0) < 0;
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Settings, ViewSettings } from 'ontime-types';
|
import { Settings } from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/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 { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
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';
|
||||||
@@ -17,22 +15,15 @@ import './Clock.scss';
|
|||||||
interface ClockProps {
|
interface ClockProps {
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
time: ViewExtendedTimer;
|
time: ViewExtendedTimer;
|
||||||
viewSettings: ViewSettings;
|
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Clock(props: ClockProps) {
|
export default function Clock(props: ClockProps) {
|
||||||
const { isMirrored, time, viewSettings, settings } = props;
|
const { isMirrored, time, settings } = props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
useWindowTitle('Clock');
|
useWindowTitle('Clock');
|
||||||
|
|
||||||
// defer rendering until we load stylesheets
|
|
||||||
if (!shouldRender) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// get config from url: key, text, font, size, hidenav
|
// get config from url: key, text, font, size, hidenav
|
||||||
// eg. http://localhost:3000/clock?key=f00&text=fff
|
// eg. http://localhost:3000/clock?key=f00&text=fff
|
||||||
// Check for user options
|
// Check for user options
|
||||||
|
|||||||
@@ -8,26 +8,23 @@ export const getClockOptions = (timeFormat: string): ViewOption[] => [
|
|||||||
{
|
{
|
||||||
id: 'key',
|
id: 'key',
|
||||||
title: 'Key Colour',
|
title: 'Key Colour',
|
||||||
description: 'Background colour in hexadecimal',
|
description: 'Background or key colour for entire view. Default: #000000',
|
||||||
prefix: '#',
|
type: 'colour',
|
||||||
type: 'string',
|
defaultValue: '000000',
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'text',
|
id: 'text',
|
||||||
title: 'Text Colour',
|
title: 'Text Colour',
|
||||||
description: 'Text colour in hexadecimal',
|
description: 'Text colour. Default: #FFFFFF',
|
||||||
prefix: '#',
|
type: 'colour',
|
||||||
type: 'string',
|
defaultValue: 'FFFFFF',
|
||||||
placeholder: 'fffff (default)',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'textbg',
|
id: 'textbg',
|
||||||
title: 'Text Background',
|
title: 'Text Background',
|
||||||
description: 'Colour of text background in hexadecimal',
|
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
|
||||||
prefix: '#',
|
type: 'colour',
|
||||||
type: 'string',
|
defaultValue: 'FFF0',
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'font',
|
id: 'font',
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import { OntimeEvent, OntimeRundownEntry, Playback, Runtime, Settings, SupportedEvent, TimerPhase } from 'ontime-types';
|
||||||
OntimeEvent,
|
|
||||||
OntimeRundownEntry,
|
|
||||||
Playback,
|
|
||||||
Runtime,
|
|
||||||
Settings,
|
|
||||||
SupportedEvent,
|
|
||||||
TimerPhase,
|
|
||||||
ViewSettings,
|
|
||||||
} from 'ontime-types';
|
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/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 { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
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';
|
||||||
@@ -34,12 +23,10 @@ interface CountdownProps {
|
|||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
time: ViewExtendedTimer;
|
time: ViewExtendedTimer;
|
||||||
viewSettings: ViewSettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Countdown(props: CountdownProps) {
|
export default function Countdown(props: CountdownProps) {
|
||||||
const { isMirrored, backstageEvents, runtime, selectedId, settings, time, viewSettings } = props;
|
const { isMirrored, backstageEvents, runtime, selectedId, settings, time } = props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
|
|
||||||
@@ -80,11 +67,6 @@ export default function Countdown(props: CountdownProps) {
|
|||||||
}
|
}
|
||||||
}, [backstageEvents, searchParams]);
|
}, [backstageEvents, searchParams]);
|
||||||
|
|
||||||
// defer rendering until we load stylesheets
|
|
||||||
if (!shouldRender) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
|
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
|
||||||
|
|
||||||
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
|
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
|
||||||
|
|||||||
@@ -39,16 +39,16 @@ const defaultOptions: Readonly<LowerOptions> = {
|
|||||||
width: 45,
|
width: 45,
|
||||||
topSrc: 'title',
|
topSrc: 'title',
|
||||||
bottomSrc: 'lowerMsg',
|
bottomSrc: 'lowerMsg',
|
||||||
topColour: '000000ff',
|
topColour: '000000',
|
||||||
bottomColour: '000000ff',
|
bottomColour: '000000',
|
||||||
topBg: '00000000',
|
topBg: 'FFF0',
|
||||||
bottomBg: '00000000',
|
bottomBg: 'FFF0',
|
||||||
topSize: '65px',
|
topSize: '65px',
|
||||||
bottomSize: '40px',
|
bottomSize: '40px',
|
||||||
transition: 3,
|
transition: 3,
|
||||||
delay: 3,
|
delay: 3,
|
||||||
key: 'ffffffff',
|
key: 'FFF0',
|
||||||
lineColour: 'ff0000ff',
|
lineColour: 'FF0000',
|
||||||
lineHeight: '0.4em',
|
lineHeight: '0.4em',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { ViewOption } from '../../../common/components/view-params-editor/types'
|
|||||||
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
|
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
|
||||||
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
|
note: 'Note',
|
||||||
});
|
});
|
||||||
|
|
||||||
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
|
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
|
note: 'Note',
|
||||||
none: 'None',
|
none: 'None',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,39 +33,22 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
|
|||||||
values: bottomSourceOptions,
|
values: bottomSourceOptions,
|
||||||
defaultValue: 'none',
|
defaultValue: 'none',
|
||||||
},
|
},
|
||||||
|
{ section: 'View animation' },
|
||||||
|
{
|
||||||
|
id: 'transition',
|
||||||
|
title: 'Transition',
|
||||||
|
description: 'Transition in time in seconds (default 3)',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '3 (default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delay',
|
||||||
|
title: 'Delay',
|
||||||
|
description: 'Delay between transition in and out in seconds (default 3)',
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '3 (default)',
|
||||||
|
},
|
||||||
{ section: 'View style override' },
|
{ section: 'View style override' },
|
||||||
{
|
|
||||||
id: 'top-colour',
|
|
||||||
title: 'Top Text Colour',
|
|
||||||
description: 'Top text colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '0000ff (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bottom-colour',
|
|
||||||
title: 'Bottom Text Colour',
|
|
||||||
description: 'Bottom text colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '0000ff (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'top-bg',
|
|
||||||
title: 'Top Background Colour',
|
|
||||||
description: 'Top text background colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bottom-bg',
|
|
||||||
title: 'Bottom Background Colour',
|
|
||||||
description: 'Bottom text background colour in hexadecimal',
|
|
||||||
prefix: '#',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'top-size',
|
id: 'top-size',
|
||||||
title: 'Top Text Size',
|
title: 'Top Text Size',
|
||||||
@@ -86,35 +71,47 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
|
|||||||
prefix: '%',
|
prefix: '%',
|
||||||
placeholder: '45 (default)',
|
placeholder: '45 (default)',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'transition',
|
|
||||||
title: 'Transition',
|
|
||||||
description: 'Transition in time in seconds (default 3)',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '3 (default)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'delay',
|
|
||||||
title: 'Delay',
|
|
||||||
description: 'Delay between transition in and out in seconds (default 3)',
|
|
||||||
type: 'number',
|
|
||||||
placeholder: '3 (default)',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'key',
|
id: 'key',
|
||||||
title: 'Key Colour',
|
title: 'Key Colour',
|
||||||
description: 'Colour of the background',
|
description: 'Colour of the background. Default: #FFF0 (transparent)',
|
||||||
prefix: '#',
|
type: 'colour',
|
||||||
type: 'string',
|
defaultValue: 'FFF0',
|
||||||
placeholder: 'ffffffff (default)',
|
},
|
||||||
|
{
|
||||||
|
id: 'top-colour',
|
||||||
|
title: 'Top Text Colour',
|
||||||
|
description: 'Top text colour. Default: #000000',
|
||||||
|
type: 'colour',
|
||||||
|
defaultValue: '000000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bottom-colour',
|
||||||
|
title: 'Bottom Text Colour',
|
||||||
|
description: 'Bottom text colour. Default: #000000',
|
||||||
|
type: 'colour',
|
||||||
|
defaultValue: '000000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'top-bg',
|
||||||
|
title: 'Top Background Colour',
|
||||||
|
description: 'Top text background colour. Default: #FFF0 (transparent)',
|
||||||
|
type: 'colour',
|
||||||
|
defaultValue: 'FFF0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bottom-bg',
|
||||||
|
title: 'Bottom Background Colour',
|
||||||
|
description: 'Bottom text background colour. Default: #FFF0 (transparent)',
|
||||||
|
type: 'colour',
|
||||||
|
defaultValue: 'FFF0',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'line-colour',
|
id: 'line-colour',
|
||||||
title: 'Line Colour',
|
title: 'Line Colour',
|
||||||
description: 'Colour of the line',
|
description: 'Colour of the line. Default: #FF0000',
|
||||||
prefix: '#',
|
type: 'colour',
|
||||||
type: 'string',
|
defaultValue: 'FF0000',
|
||||||
placeholder: 'ff0000ff (default)',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
|
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/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 { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
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';
|
||||||
@@ -22,17 +20,11 @@ interface MinimalTimerProps {
|
|||||||
|
|
||||||
export default function MinimalTimer(props: MinimalTimerProps) {
|
export default function MinimalTimer(props: MinimalTimerProps) {
|
||||||
const { isMirrored, time, viewSettings } = props;
|
const { isMirrored, time, viewSettings } = props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
useWindowTitle('Minimal Timer');
|
useWindowTitle('Minimal Timer');
|
||||||
|
|
||||||
// defer rendering until we load stylesheets
|
|
||||||
if (!shouldRender) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: this should be tied to the params
|
// TODO: this should be tied to the params
|
||||||
// USER OPTIONS
|
// USER OPTIONS
|
||||||
const userOptions: OverridableOptions = {
|
const userOptions: OverridableOptions = {
|
||||||
@@ -169,6 +161,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
|||||||
<div
|
<div
|
||||||
className={timerClasses}
|
className={timerClasses}
|
||||||
style={{
|
style={{
|
||||||
|
color: userOptions.textColour,
|
||||||
fontSize: `${timerFontSize}vw`,
|
fontSize: `${timerFontSize}vw`,
|
||||||
fontFamily: userOptions.font,
|
fontFamily: userOptions.font,
|
||||||
top: userOptions.top,
|
top: userOptions.top,
|
||||||
|
|||||||
@@ -23,26 +23,23 @@ export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
|
|||||||
{
|
{
|
||||||
id: 'key',
|
id: 'key',
|
||||||
title: 'Key Colour',
|
title: 'Key Colour',
|
||||||
description: 'Background colour in hexadecimal',
|
description: 'Background or key colour for entire view. Default: #000000',
|
||||||
prefix: '#',
|
type: 'colour',
|
||||||
type: 'string',
|
defaultValue: '000000',
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'text',
|
id: 'text',
|
||||||
title: 'Text Colour',
|
title: 'Text Colour',
|
||||||
description: 'Text colour in hexadecimal',
|
description: 'Text colour. Default: #FFFFFF',
|
||||||
prefix: '#',
|
type: 'colour',
|
||||||
type: 'string',
|
defaultValue: 'FFFFFF',
|
||||||
placeholder: 'fffff (default)',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'textbg',
|
id: 'textbg',
|
||||||
title: 'Text Background',
|
title: 'Text Background',
|
||||||
description: 'Colour of text background in hexadecimal',
|
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
|
||||||
prefix: '#',
|
type: 'colour',
|
||||||
type: 'string',
|
defaultValue: 'FFF0',
|
||||||
placeholder: '00000000 (default)',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'font',
|
id: 'font',
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
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';
|
||||||
import { CustomFields, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
|
import { CustomFields, OntimeEvent, ProjectData, Settings } from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
|
||||||
import Schedule from '../../../common/components/schedule/Schedule';
|
import Schedule from '../../../common/components/schedule/Schedule';
|
||||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
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 { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
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';
|
||||||
@@ -33,7 +31,6 @@ interface BackstageProps {
|
|||||||
events: OntimeEvent[];
|
events: OntimeEvent[];
|
||||||
publicSelectedId: string | null;
|
publicSelectedId: string | null;
|
||||||
general: ProjectData;
|
general: ProjectData;
|
||||||
viewSettings: ViewSettings;
|
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,21 +44,14 @@ export default function Public(props: BackstageProps) {
|
|||||||
events,
|
events,
|
||||||
publicSelectedId,
|
publicSelectedId,
|
||||||
general,
|
general,
|
||||||
viewSettings,
|
|
||||||
settings,
|
settings,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
useWindowTitle('Public Schedule');
|
useWindowTitle('Public Schedule');
|
||||||
|
|
||||||
// defer rendering until we load stylesheets
|
|
||||||
if (!shouldRender) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clock = formatTime(time.clock);
|
const clock = formatTime(time.clock);
|
||||||
const qrSize = Math.max(window.innerWidth / 15, 128);
|
const qrSize = Math.max(window.innerWidth / 15, 128);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/comp
|
|||||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||||
const secondaryOptions = makeOptionsFromCustomFields(customFields);
|
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ section: 'Clock Options' },
|
{ section: 'Clock Options' },
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import type { MaybeString, OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
|
import type { MaybeString, OntimeEvent, OntimeRundown, Settings } from 'ontime-types';
|
||||||
import { Playback } from 'ontime-types';
|
import { Playback } from 'ontime-types';
|
||||||
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
|
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
|
||||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
import { FitText } from '../../../common/components/fit-text/FitText';
|
||||||
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 { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
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';
|
||||||
@@ -25,24 +23,16 @@ interface StudioClockProps {
|
|||||||
selectedId: MaybeString;
|
selectedId: MaybeString;
|
||||||
nextId: MaybeString;
|
nextId: MaybeString;
|
||||||
onAir: boolean;
|
onAir: boolean;
|
||||||
viewSettings: ViewSettings;
|
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StudioClock(props: StudioClockProps) {
|
export default function StudioClock(props: StudioClockProps) {
|
||||||
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings, settings } = props;
|
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, settings } = props;
|
||||||
|
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
useWindowTitle('Studio Clock');
|
useWindowTitle('Studio Clock');
|
||||||
|
|
||||||
// defer rendering until we load stylesheets
|
|
||||||
if (!shouldRender) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeIndicators = [...Array(12).keys()];
|
const activeIndicators = [...Array(12).keys()];
|
||||||
const secondsIndicators = [...Array(60).keys()];
|
const secondsIndicators = [...Array(60).keys()];
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
import { useViewportSize } from '@mantine/hooks';
|
|
||||||
import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types';
|
|
||||||
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
|
||||||
|
|
||||||
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
|
||||||
import TimelineProgressBar from './timeline-progress-bar/TimelineProgressBar';
|
|
||||||
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
|
|
||||||
import { ProgressStatus, TimelineEntry } from './TimelineEntry';
|
|
||||||
|
|
||||||
import style from './Timeline.module.scss';
|
|
||||||
|
|
||||||
interface TimelineProps {
|
|
||||||
firstStart: number;
|
|
||||||
rundown: OntimeRundown;
|
|
||||||
selectedEventId: string | null;
|
|
||||||
totalDuration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(Timeline);
|
|
||||||
|
|
||||||
function Timeline(props: TimelineProps) {
|
|
||||||
const { firstStart, rundown, selectedEventId, totalDuration } = props;
|
|
||||||
const { width: screenWidth } = useViewportSize();
|
|
||||||
|
|
||||||
if (totalDuration === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lastEvent } = getLastEvent(rundown);
|
|
||||||
const startHour = getStartHour(firstStart);
|
|
||||||
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
|
|
||||||
|
|
||||||
let previousEventStartTime: MaybeNumber = null;
|
|
||||||
// we use selectedEventId as a signifier on whether the timeline is live
|
|
||||||
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
|
||||||
let elapsedDays = 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.timeline}>
|
|
||||||
<TimelineMarkers startHour={startHour} endHour={endHour} />
|
|
||||||
<TimelineProgressBar startHour={startHour} endHour={endHour} />
|
|
||||||
<div className={style.timelineEvents}>
|
|
||||||
{rundown.map((event) => {
|
|
||||||
// for now we dont render delays and blocks
|
|
||||||
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// keep track of progress of rundown
|
|
||||||
if (eventStatus === 'live') {
|
|
||||||
eventStatus = 'future';
|
|
||||||
}
|
|
||||||
if (event.id === selectedEventId) {
|
|
||||||
eventStatus = 'live';
|
|
||||||
}
|
|
||||||
|
|
||||||
// we only need to check for next day if we have a previous event
|
|
||||||
if (
|
|
||||||
previousEventStartTime !== null &&
|
|
||||||
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
|
|
||||||
) {
|
|
||||||
elapsedDays++;
|
|
||||||
}
|
|
||||||
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
|
|
||||||
|
|
||||||
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
|
||||||
startHour * MILLIS_PER_HOUR,
|
|
||||||
endHour * MILLIS_PER_HOUR,
|
|
||||||
normalisedStart + (event.delay ?? 0),
|
|
||||||
event.duration,
|
|
||||||
screenWidth,
|
|
||||||
);
|
|
||||||
|
|
||||||
// prepare values for next iteration
|
|
||||||
previousEventStartTime = normalisedStart;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TimelineEntry
|
|
||||||
key={event.id}
|
|
||||||
colour={event.colour}
|
|
||||||
delay={event.delay ?? 0}
|
|
||||||
duration={event.duration}
|
|
||||||
left={elementLeftPosition}
|
|
||||||
status={eventStatus}
|
|
||||||
start={normalisedStart} // dataset solves issues related to crossing midnight
|
|
||||||
title={event.title}
|
|
||||||
width={elementWidth}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
.markers {
|
|
||||||
width: 100%;
|
|
||||||
color: $ui-white;
|
|
||||||
display: flex;
|
|
||||||
height: 1rem;
|
|
||||||
line-height: 1rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
font-size: calc(1rem - 2px);
|
|
||||||
justify-content: space-evenly;
|
|
||||||
|
|
||||||
& > span {
|
|
||||||
flex-grow: 1;
|
|
||||||
border-left: 1px solid $white-7;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
.progressBar {
|
|
||||||
width: 100%;
|
|
||||||
height: 1rem;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
background-color: $gray-1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
|
||||||
|
|
||||||
background-color: $active-red;
|
|
||||||
transition-duration: 0.3s;
|
|
||||||
transition-property: width;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { useClock } from '../../../../common/hooks/useSocket';
|
|
||||||
import { getRelativePositionX } from '../timeline.utils';
|
|
||||||
|
|
||||||
import style from './TimelineProgressBar.module.scss';
|
|
||||||
|
|
||||||
interface ProgressBarProps {
|
|
||||||
startHour: number;
|
|
||||||
endHour: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProgressBar(props: ProgressBarProps) {
|
|
||||||
const { startHour, endHour } = props;
|
|
||||||
// TODO: how to account for days?
|
|
||||||
const { clock } = useClock();
|
|
||||||
|
|
||||||
const width = getRelativePositionX(startHour * MILLIS_PER_HOUR, endHour * MILLIS_PER_HOUR, clock);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.progressBar}>
|
|
||||||
<div className={style.progress} style={{ width: `${width}%` }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,12 +12,10 @@ import {
|
|||||||
ViewSettings,
|
ViewSettings,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
|
||||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
import { FitText } from '../../../common/components/fit-text/FitText';
|
||||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
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 { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
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';
|
||||||
@@ -62,17 +60,11 @@ interface TimerProps {
|
|||||||
export default function Timer(props: TimerProps) {
|
export default function Timer(props: TimerProps) {
|
||||||
const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props;
|
const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props;
|
||||||
|
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
useWindowTitle('Timer');
|
useWindowTitle('Timer');
|
||||||
|
|
||||||
// defer rendering until we load stylesheets
|
|
||||||
if (!shouldRender) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// USER OPTIONS
|
// USER OPTIONS
|
||||||
const userOptions = {
|
const userOptions = {
|
||||||
hideClock: false,
|
hideClock: false,
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||||
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
|
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
const secondaryOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ section: 'Clock Options' },
|
{ section: 'Clock Options' },
|
||||||
getTimeOption(timeFormat),
|
getTimeOption(timeFormat),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const ontimeAlertOnDark = {
|
export const ontimeAlertOnDark = {
|
||||||
container: {
|
container: {
|
||||||
fontSize: 'calc(1rem - 1px)',
|
fontSize: 'calc(1rem - 1px)',
|
||||||
backgroundColor: '#1a1a1a', // $gray-1300
|
backgroundColor: '#202020', // $gray-1200
|
||||||
color: '#e2e2e2', // $gray-200
|
color: '#e2e2e2', // $gray-200
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,3 +21,11 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin rotate-fourty-five {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fourtyfive {
|
||||||
|
@include rotate-fourty-five;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
$transition-time-action: 0.1s;
|
$transition-time-action: 0.1s;
|
||||||
$transition-time-feedback: 0.3s;
|
$transition-time-feedback: 0.3s;
|
||||||
|
|
||||||
$blinking-time: 1.2s;
|
|
||||||
|
|
||||||
$component-border-radius-md: 3px;
|
$component-border-radius-md: 3px;
|
||||||
$component-border-radius-sm: 2px;
|
$component-border-radius-sm: 2px;
|
||||||
|
|
||||||
@@ -61,10 +59,13 @@ $text-body-size: calc(1rem - 1px);
|
|||||||
$min-tablet: 500px;
|
$min-tablet: 500px;
|
||||||
|
|
||||||
.blink {
|
.blink {
|
||||||
animation: blink $blinking-time linear infinite;
|
animation: blink 1s step-start infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
|
0% {
|
||||||
|
opacity: 100%;
|
||||||
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 20%;
|
opacity: 20%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export const ontimeDrawer = {
|
|||||||
backgroundColor: '#202020', // $gray-1250
|
backgroundColor: '#202020', // $gray-1250
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
color: '#fefefe', // $gray-50
|
color: '#fefefe', // $gray-50
|
||||||
backgroundColor: '#202020', // $gray-1250
|
backgroundColor: '#202020', // $gray-1250
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const navigatorConstants = [
|
export const navigatorConstants = [
|
||||||
{ url: 'timer', label: 'Timer' },
|
{ url: 'timer', label: 'Timer' },
|
||||||
{ url: 'clock', label: 'Clock' },
|
|
||||||
{ url: 'minimal', label: 'Minimal Timer' },
|
{ url: 'minimal', label: 'Minimal Timer' },
|
||||||
|
{ url: 'clock', label: 'Wall Clock' },
|
||||||
{ url: 'backstage', label: 'Backstage' },
|
{ url: 'backstage', label: 'Backstage' },
|
||||||
{ url: 'timeline', label: 'Timeline (beta)' },
|
{ url: 'timeline', label: 'Timeline (beta)' },
|
||||||
{ url: 'public', label: 'Public' },
|
{ url: 'public', label: 'Public' },
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
@use '../theme/viewerDefs' as *;
|
||||||
|
|
||||||
|
$dot-size: 0.5rem;
|
||||||
|
$dot-spacing: 1.5rem;
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background-color: var(--background-color-override, $viewer-background-color);
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: 5rem;
|
||||||
|
|
||||||
|
div {
|
||||||
|
position: absolute;
|
||||||
|
width: $dot-size;
|
||||||
|
height: $dot-size;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--accent-color-override, $ontime-color);
|
||||||
|
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||||
|
|
||||||
|
&:nth-child(1) {
|
||||||
|
left: $dot-size;
|
||||||
|
animation: lds-ellipsis1 0.6s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
left: $dot-size;
|
||||||
|
animation: lds-ellipsis2 0.6s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(3) {
|
||||||
|
left: calc($dot-size + $dot-spacing);
|
||||||
|
animation: lds-ellipsis2 0.6s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(4) {
|
||||||
|
left: calc($dot-size + 2 * $dot-spacing);
|
||||||
|
animation: lds-ellipsis3 0.6s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lds-ellipsis1 {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lds-ellipsis2 {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate($dot-spacing, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lds-ellipsis3 {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
import { overrideStylesURL } from '../common/api/constants';
|
||||||
|
import { useRuntimeStylesheet } from '../common/hooks/useRuntimeStylesheet';
|
||||||
|
import useViewSettings from '../common/hooks-query/useViewSettings';
|
||||||
|
|
||||||
|
import style from './ViewLoader.module.scss';
|
||||||
|
|
||||||
|
export default function ViewLoader({ children }: PropsWithChildren) {
|
||||||
|
const { data } = useViewSettings();
|
||||||
|
const { shouldRender } = useRuntimeStylesheet(data.overrideStyles && overrideStylesURL);
|
||||||
|
|
||||||
|
// eventually we would want to leverage suspense here
|
||||||
|
// while the feature is not ready, we simply trigger a loader
|
||||||
|
// suspense would have the advantage of being triggered also by react-query
|
||||||
|
|
||||||
|
if (!shouldRender) {
|
||||||
|
return (
|
||||||
|
<div className={style.loader}>
|
||||||
|
<div className={style.ellipsis}>
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react/jsx-no-useless-fragment -- ensuring JSX return
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,39 +1,40 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
$timeline-entry-height: 20px;
|
|
||||||
$lane-height: 120px;
|
|
||||||
$timeline-height: 1rem;
|
$timeline-height: 1rem;
|
||||||
|
$timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color));
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $ui-white;
|
color: var(--color-override, $viewer-color);
|
||||||
background-color: $ui-black;
|
background: var(--background-color-override, $viewer-background-color);
|
||||||
}
|
box-sizing: content-box;
|
||||||
|
// create progress background
|
||||||
.timelineEvents {
|
box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-left: 1px solid $ui-black;
|
border-left: 1px solid var(--background-color-override, $viewer-background-color);
|
||||||
// avoiding content being larger than the view
|
// avoiding content being larger than the view
|
||||||
height: calc(100% - 3rem);
|
height: calc(100% - 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
// decorate timeline element
|
// generate combined timeline
|
||||||
&::before {
|
.timelineBlock {
|
||||||
content: '';
|
height: $timeline-height;
|
||||||
position: absolute;
|
background: $timeline-color;
|
||||||
box-sizing: content-box;
|
width: 100%;
|
||||||
top: -$timeline-height;
|
|
||||||
left: 0;
|
&[data-status='done'] {
|
||||||
right: 0;
|
background: $active-red;
|
||||||
height: $timeline-height;
|
}
|
||||||
background-color: $white-40;
|
|
||||||
|
&[data-status='live'] {
|
||||||
|
background: linear-gradient(to right, $active-red var(--progress, 0%), $timeline-color var(--progress, 0%));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +66,9 @@ $timeline-height: 1rem;
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
line-height: 1rem;
|
line-height: 1rem;
|
||||||
|
|
||||||
background-color: var(--lighter, $viewer-card-bg-color);
|
background-color: var(--lighter, var(--card-background-color-override, $viewer-card-bg-color));
|
||||||
border-bottom: 2px solid $ui-black;
|
border-bottom: 2px solid var(--background-color-override, $viewer-background-color);
|
||||||
|
border-top: 2px solid var(--background-color-override, $viewer-background-color);
|
||||||
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
|
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
|
||||||
|
|
||||||
&[data-status='done'] {
|
&[data-status='done'] {
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { useViewportSize } from '@mantine/hooks';
|
||||||
|
import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types';
|
||||||
|
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
|
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
||||||
|
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
|
||||||
|
import { ProgressStatus, TimelineEntry } from './TimelineEntry';
|
||||||
|
|
||||||
|
import style from './Timeline.module.scss';
|
||||||
|
|
||||||
|
interface TimelineProps {
|
||||||
|
firstStart: number;
|
||||||
|
rundown: OntimeRundown;
|
||||||
|
selectedEventId: string | null;
|
||||||
|
totalDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Timeline);
|
||||||
|
|
||||||
|
function Timeline(props: TimelineProps) {
|
||||||
|
const { firstStart, rundown, selectedEventId, totalDuration } = props;
|
||||||
|
const { width: screenWidth } = useViewportSize();
|
||||||
|
|
||||||
|
if (totalDuration === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lastEvent } = getLastEvent(rundown);
|
||||||
|
const startHour = getStartHour(firstStart);
|
||||||
|
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
|
||||||
|
|
||||||
|
let previousEventStartTime: MaybeNumber = null;
|
||||||
|
// we use selectedEventId as a signifier on whether the timeline is live
|
||||||
|
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
||||||
|
let elapsedDays = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.timeline}>
|
||||||
|
<TimelineMarkers startHour={startHour} endHour={endHour} />
|
||||||
|
{rundown.map((event) => {
|
||||||
|
// for now we dont render delays and blocks
|
||||||
|
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep track of progress of rundown
|
||||||
|
if (eventStatus === 'live') {
|
||||||
|
eventStatus = 'future';
|
||||||
|
}
|
||||||
|
if (event.id === selectedEventId) {
|
||||||
|
eventStatus = 'live';
|
||||||
|
}
|
||||||
|
|
||||||
|
// we only need to check for next day if we have a previous event
|
||||||
|
if (
|
||||||
|
previousEventStartTime !== null &&
|
||||||
|
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
|
||||||
|
) {
|
||||||
|
elapsedDays++;
|
||||||
|
}
|
||||||
|
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
|
||||||
|
|
||||||
|
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
||||||
|
startHour * MILLIS_PER_HOUR,
|
||||||
|
endHour * MILLIS_PER_HOUR,
|
||||||
|
normalisedStart + (event.delay ?? 0),
|
||||||
|
event.duration,
|
||||||
|
screenWidth,
|
||||||
|
);
|
||||||
|
|
||||||
|
// prepare values for next iteration
|
||||||
|
previousEventStartTime = normalisedStart;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimelineEntry
|
||||||
|
key={event.id}
|
||||||
|
colour={event.colour}
|
||||||
|
delay={event.delay ?? 0}
|
||||||
|
duration={event.duration}
|
||||||
|
left={elementLeftPosition}
|
||||||
|
status={eventStatus}
|
||||||
|
start={normalisedStart} // dataset solves issues related to crossing midnight
|
||||||
|
title={event.title}
|
||||||
|
width={elementWidth}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useTimelineStatus } from '../../../common/hooks/useSocket';
|
import { useTimelineStatus, useTimer } from '../../common/hooks/useSocket';
|
||||||
import { alpha, cx } from '../../../common/utils/styleUtils';
|
import { getProgress } from '../../common/utils/getProgress';
|
||||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
import { alpha, cx } from '../../common/utils/styleUtils';
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
import { formatDuration, formatTime } from '../../common/utils/time';
|
||||||
|
import { useTranslation } from '../../translation/TranslationProvider';
|
||||||
|
|
||||||
import { getStatusLabel } from './timeline.utils';
|
import { getStatusLabel, getTimeToStart } from './timeline.utils';
|
||||||
|
|
||||||
import style from './Timeline.module.scss';
|
import style from './Timeline.module.scss';
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ export function TimelineEntry(props: TimelineEntryProps) {
|
|||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
|
||||||
<div
|
<div
|
||||||
className={contentClasses}
|
className={contentClasses}
|
||||||
data-status={status}
|
data-status={status}
|
||||||
@@ -63,7 +65,7 @@ export function TimelineEntry(props: TimelineEntryProps) {
|
|||||||
{status !== 'done' && (
|
{status !== 'done' && (
|
||||||
<>
|
<>
|
||||||
<div className={style.duration}>{formattedDuration}</div>
|
<div className={style.duration}>{formattedDuration}</div>
|
||||||
<TimelineEntryStatus status={status} start={delayedStart} />
|
<TimelineEntryStatus delay={delay} start={start} status={status} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -72,18 +74,19 @@ export function TimelineEntry(props: TimelineEntryProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TimelineEntryStatusProps {
|
interface TimelineEntryStatusProps {
|
||||||
status: ProgressStatus;
|
delay: number;
|
||||||
start: number;
|
start: number;
|
||||||
|
status: ProgressStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
// we isolate this component to avoid isolate re-renders provoked by the clock changes
|
// extract component to isolate re-renders provoked by the clock changes
|
||||||
function TimelineEntryStatus(props: TimelineEntryStatusProps) {
|
function TimelineEntryStatus(props: TimelineEntryStatusProps) {
|
||||||
const { status, start } = props;
|
const { delay, start, status } = props;
|
||||||
const { clock, offset } = useTimelineStatus();
|
const { clock, offset } = useTimelineStatus();
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
|
|
||||||
// start times need to be normalised in a rundown that crosses midnight
|
// start times need to be normalised in a rundown that crosses midnight
|
||||||
let statusText = getStatusLabel(start - clock + offset, status);
|
let statusText = getStatusLabel(getTimeToStart(clock, start, delay, offset), status);
|
||||||
if (statusText === 'live') {
|
if (statusText === 'live') {
|
||||||
statusText = getLocalizedString('timeline.live');
|
statusText = getLocalizedString('timeline.live');
|
||||||
} else if (statusText === 'pending') {
|
} else if (statusText === 'pending') {
|
||||||
@@ -92,3 +95,10 @@ function TimelineEntryStatus(props: TimelineEntryStatusProps) {
|
|||||||
|
|
||||||
return <div className={style.status}>{statusText}</div>;
|
return <div className={style.status}>{statusText}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generates a block level progress bar */
|
||||||
|
function ActiveBlock() {
|
||||||
|
const { current, duration } = useTimer();
|
||||||
|
const progress = getProgress(current, duration);
|
||||||
|
return <div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` }} />;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@@ -85,6 +85,14 @@
|
|||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
|
max-height: 2em;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-content--now {
|
.section-content--now {
|
||||||
@@ -1,29 +1,27 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { MaybeString, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
|
import { MaybeString, OntimeEvent, ProjectData, Runtime, Settings } from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/constants';
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
import { formatDuration, formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
import { useTranslation } from '../../translation/TranslationProvider';
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
|
||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
|
||||||
|
|
||||||
import Section from './timeline-section/TimelineSection';
|
import Section from './timeline-section/TimelineSection';
|
||||||
import Timeline from './Timeline';
|
import Timeline from './Timeline';
|
||||||
import { getTimelineOptions } from './timeline.options';
|
import { getTimelineOptions } from './timeline.options';
|
||||||
import { getFormattedTimeToStart, getUpcomingEvents, useScopedRundown } from './timeline.utils';
|
import { getTimeToStart, getUpcomingEvents, useScopedRundown } from './timeline.utils';
|
||||||
|
|
||||||
import './TimelinePage.scss';
|
import './TimelinePage.scss';
|
||||||
|
|
||||||
interface TimelinePageProps {
|
interface TimelinePageProps {
|
||||||
backstageEvents: OntimeEvent[];
|
backstageEvents: OntimeEvent[];
|
||||||
general: ProjectData;
|
general: ProjectData;
|
||||||
|
runtime: Runtime;
|
||||||
selectedId: MaybeString;
|
selectedId: MaybeString;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
time: ViewExtendedTimer;
|
time: ViewExtendedTimer;
|
||||||
viewSettings: ViewSettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,8 +30,7 @@ interface TimelinePageProps {
|
|||||||
* There is little point splitting or memoising top level elements
|
* There is little point splitting or memoising top level elements
|
||||||
*/
|
*/
|
||||||
export default function TimelinePage(props: TimelinePageProps) {
|
export default function TimelinePage(props: TimelinePageProps) {
|
||||||
const { backstageEvents, general, selectedId, settings, time, viewSettings } = props;
|
const { backstageEvents, general, runtime, selectedId, settings, time } = props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
|
||||||
// holds copy of the rundown with only relevant events
|
// holds copy of the rundown with only relevant events
|
||||||
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(backstageEvents, selectedId);
|
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(backstageEvents, selectedId);
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
@@ -45,10 +42,6 @@ export default function TimelinePage(props: TimelinePageProps) {
|
|||||||
|
|
||||||
useWindowTitle('Timeline');
|
useWindowTitle('Timeline');
|
||||||
|
|
||||||
if (!shouldRender) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// populate options
|
// populate options
|
||||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
const progressOptions = getTimelineOptions(defaultFormat);
|
const progressOptions = getTimelineOptions(defaultFormat);
|
||||||
@@ -57,10 +50,26 @@ export default function TimelinePage(props: TimelinePageProps) {
|
|||||||
const dueText = getLocalizedString('timeline.due').toUpperCase();
|
const dueText = getLocalizedString('timeline.due').toUpperCase();
|
||||||
const nextText = next !== null ? next.title : '-';
|
const nextText = next !== null ? next.title : '-';
|
||||||
const followedByText = followedBy !== null ? followedBy.title : '-';
|
const followedByText = followedBy !== null ? followedBy.title : '-';
|
||||||
|
let nextStatus: string | undefined;
|
||||||
|
let followedByStatus: string | undefined;
|
||||||
|
|
||||||
const nextStatus = next !== null ? getFormattedTimeToStart(next, time.clock, dueText) : undefined;
|
if (next !== null) {
|
||||||
const followedByStatus = followedBy !== null ? getFormattedTimeToStart(followedBy, time.clock, dueText) : undefined;
|
const timeToStart = getTimeToStart(time.clock, next.timeStart, next?.delay ?? 0, runtime.offset);
|
||||||
|
if (timeToStart < 0) {
|
||||||
|
nextStatus = dueText;
|
||||||
|
} else {
|
||||||
|
nextStatus = `T - ${formatDuration(timeToStart)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (followedBy !== null) {
|
||||||
|
const timeToStart = getTimeToStart(time.clock, followedBy.timeStart, followedBy?.delay ?? 0, runtime.offset);
|
||||||
|
if (timeToStart < 0) {
|
||||||
|
followedByStatus = dueText;
|
||||||
|
} else {
|
||||||
|
followedByStatus = `T - ${formatDuration(timeToStart)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className='timeline'>
|
<div className='timeline'>
|
||||||
<ViewParamsEditor viewOptions={progressOptions} />
|
<ViewParamsEditor viewOptions={progressOptions} />
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { dayInMs } from 'ontime-utils';
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
import { getElementPosition, makeTimelineSections } from '../timeline.utils';
|
import { getElementPosition, getTimeToStart, makeTimelineSections } from '../timeline.utils';
|
||||||
|
|
||||||
describe('getCSSPosition()', () => {
|
describe('getCSSPosition()', () => {
|
||||||
it('accounts for rundown with one event', () => {
|
it('accounts for rundown with one event', () => {
|
||||||
@@ -53,14 +53,40 @@ describe('getCSSPosition()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('makeTmelineSections', () => {
|
describe('makeTimelineSections', () => {
|
||||||
it('creates an array between the hours given, end excluded', () => {
|
it('creates an array between the hours given, end excluded', () => {
|
||||||
const result = makeTimelineSections(11, 17);
|
const result = makeTimelineSections(11, 17);
|
||||||
expect(result).toEqual(['11:00', '12:00', '13:00', '14:00', '15:00', '16:00']);
|
expect(result).toEqual([11, 12, 13, 14, 15, 16]);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
it('wraps around midnight', () => {
|
|
||||||
const result = makeTimelineSections(22, 26);
|
describe('getTimeToStart()', () => {
|
||||||
expect(result).toEqual(['22:00', '23:00', '00:00', '01:00']);
|
it("is the gap between now and the event's start time accounted for delays", () => {
|
||||||
|
const now = 150;
|
||||||
|
const start = 150;
|
||||||
|
const delay = 50;
|
||||||
|
|
||||||
|
const result = getTimeToStart(now, start, delay, 0);
|
||||||
|
expect(result).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accounts for offsets when running behind', () => {
|
||||||
|
const now = 150;
|
||||||
|
const start = 150;
|
||||||
|
const delay = 50;
|
||||||
|
const offset = -50; // running behind
|
||||||
|
|
||||||
|
const result = getTimeToStart(now, start, delay, offset);
|
||||||
|
expect(result).toBe(50 + 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accounts for offsets when running ahead', () => {
|
||||||
|
const now = 150;
|
||||||
|
const start = 150;
|
||||||
|
const delay = 50;
|
||||||
|
const offset = 10; // running behind
|
||||||
|
|
||||||
|
const result = getTimeToStart(now, start, delay, offset);
|
||||||
|
expect(result).toBe(50 - 10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.markers {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
border-left: 1px solid $white-7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ interface TimelineMarkersProps {
|
|||||||
endHour: number;
|
endHour: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Creates a line for every hour in the timeline */
|
||||||
export default function TimelineMarkers(props: TimelineMarkersProps) {
|
export default function TimelineMarkers(props: TimelineMarkersProps) {
|
||||||
const { startHour, endHour } = props;
|
const { startHour, endHour } = props;
|
||||||
|
|
||||||
@@ -14,9 +15,9 @@ export default function TimelineMarkers(props: TimelineMarkersProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.markers}>
|
<div className={style.markers}>
|
||||||
{elements.map((tag, index) => {
|
{elements.map((tag) => (
|
||||||
return <span key={`${index}-${tag}`}>{tag}</span>;
|
<span key={tag} />
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { MaybeString } from 'ontime-types';
|
import { MaybeString } from 'ontime-types';
|
||||||
|
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
interface SectionProps {
|
interface SectionProps {
|
||||||
category: 'now' | 'next';
|
category: 'now' | 'next';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
|
import { getTimeOption } from '../../common/components/view-params-editor/constants';
|
||||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||||
|
|
||||||
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
|
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
|
||||||
return [
|
return [
|
||||||
@@ -9,13 +9,11 @@ import {
|
|||||||
getTimeFromPrevious,
|
getTimeFromPrevious,
|
||||||
isNewLatest,
|
isNewLatest,
|
||||||
MILLIS_PER_HOUR,
|
MILLIS_PER_HOUR,
|
||||||
millisToString,
|
|
||||||
removeSeconds,
|
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { clamp } from '../../../common/utils/math';
|
import { clamp } from '../../common/utils/math';
|
||||||
import { formatDuration } from '../../../common/utils/time';
|
import { formatDuration } from '../../common/utils/time';
|
||||||
import { isStringBoolean } from '../common/viewUtils';
|
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
import type { ProgressStatus } from './TimelineEntry';
|
import type { ProgressStatus } from './TimelineEntry';
|
||||||
|
|
||||||
@@ -71,7 +69,7 @@ export function getEndHour(endTime: number): number {
|
|||||||
export function makeTimelineSections(firstHour: number, lastHour: number) {
|
export function makeTimelineSections(firstHour: number, lastHour: number) {
|
||||||
const timelineSections = [];
|
const timelineSections = [];
|
||||||
for (let i = firstHour; i < lastHour; i++) {
|
for (let i = firstHour; i < lastHour; i++) {
|
||||||
timelineSections.push(removeSeconds(millisToString((i % 24) * MILLIS_PER_HOUR)));
|
timelineSections.push(i);
|
||||||
}
|
}
|
||||||
return timelineSections;
|
return timelineSections;
|
||||||
}
|
}
|
||||||
@@ -200,12 +198,9 @@ export function getUpcomingEvents(events: OntimeRundown, selectedId: MaybeString
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFormattedTimeToStart(event: OntimeEvent, now: number, dueText: string): string {
|
/**
|
||||||
const timeToStart = event.timeStart - now;
|
* Utility function calculates time to start
|
||||||
|
*/
|
||||||
if (timeToStart < 0) {
|
export function getTimeToStart(now: number, start: number, delay: number, offset: number): number {
|
||||||
return dueText;
|
return start + delay - now - offset;
|
||||||
}
|
|
||||||
|
|
||||||
return `T - ${formatDuration(timeToStart)}`;
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.6.0",
|
"version": "3.6.2",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"timer"
|
"timer"
|
||||||
],
|
],
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"main": "main.js",
|
"main": "src/main.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^31.2.0",
|
"electron": "^31.2.0",
|
||||||
"electron-builder": "^24.13.3",
|
"electron-builder": "^24.13.3",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"**/*",
|
"**/*",
|
||||||
"assets/",
|
"src/assets/",
|
||||||
"!**/{yarn.lock,yarn-error.log}",
|
"!**/{yarn.lock,yarn-error.log}",
|
||||||
"!**/{pnpm-lock.yaml}",
|
"!**/{pnpm-lock.yaml}",
|
||||||
"!**/{test,tests,__test__,__tests__}",
|
"!**/{test,tests,__test__,__tests__}",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
"!*{.spec.js,*.test.js,*.spec.ts,.test.ts}"
|
"!*{.spec.js,*.test.js,*.spec.ts,.test.ts}"
|
||||||
],
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"buildResources": "./assets/"
|
"buildResources": "./src/assets/"
|
||||||
},
|
},
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
@@ -113,15 +113,15 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "../server/src/preloaded-db/",
|
"from": "../server/src/external/",
|
||||||
"to": "extraResources/preloaded-db/",
|
"to": "extraResources/external/",
|
||||||
"filter": [
|
"filter": [
|
||||||
"**/*"
|
"**/*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "../server/src/external/",
|
"from": "../server/src/user/",
|
||||||
"to": "extraResources/external/",
|
"to": "extraResources/user/",
|
||||||
"filter": [
|
"filter": [
|
||||||
"**/*"
|
"**/*"
|
||||||
]
|
]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 567 B After Width: | Height: | Size: 567 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
@@ -7,7 +7,7 @@ module.exports = {
|
|||||||
production: (port = 4001) => `http://localhost:${port}`,
|
production: (port = 4001) => `http://localhost:${port}`,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
pathToEntrypoint: '../extraResources/server/index.cjs',
|
pathToEntrypoint: '../../extraResources/server/index.cjs',
|
||||||
},
|
},
|
||||||
assets: {
|
assets: {
|
||||||
pathToAssets: './assets/',
|
pathToAssets: './assets/',
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* This file contains a list of constants that may need to be resolved at runtime
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
|
const electronConfig = require('./electron.config.js');
|
||||||
|
|
||||||
|
// external links
|
||||||
|
const linkToDocs = 'https://docs.getontime.no/';
|
||||||
|
const linkToGitHub = 'https://github.com/cpvalente/ontime';
|
||||||
|
const linkToDiscord = 'https://discord.com/invite/eje3CSUEXm';
|
||||||
|
|
||||||
|
// environment and platform constants
|
||||||
|
const env = process.env.NODE_ENV || 'production';
|
||||||
|
const isProduction = env === 'production';
|
||||||
|
const isMac = process.platform === 'darwin';
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
const isLinux = process.platform === 'linux';
|
||||||
|
|
||||||
|
const releaseTag = `v${version}`;
|
||||||
|
|
||||||
|
/** path to server directory */
|
||||||
|
const nodePath = isProduction
|
||||||
|
? path.join(__dirname, electronConfig.server.pathToEntrypoint)
|
||||||
|
: path.join(__dirname, '../../server/dist/index.cjs');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves correct URL for client
|
||||||
|
* @param {number | undefined} port - the port at which the server is running
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const getClientUrl = (port) =>
|
||||||
|
isProduction ? electronConfig.reactAppUrl.production(port) : electronConfig.reactAppUrl.development(port);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves correct URL for server
|
||||||
|
* @param {number | undefined} port - the port at which the server is running
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const getServerUrl = (port) => `http://localhost:${port}`;
|
||||||
|
|
||||||
|
/** Resolves URL path to download resources */
|
||||||
|
const downloadPath = '/data/db/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Returns public path depending on OS
|
||||||
|
* This is the correct path for the app running in production mode
|
||||||
|
*/
|
||||||
|
function getAppDataPath() {
|
||||||
|
if (isMac) {
|
||||||
|
return path.join(process.env.HOME, 'Library', 'Application Support', 'Ontime');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWindows) {
|
||||||
|
return path.join(process.env.APPDATA, 'Ontime');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLinux) {
|
||||||
|
return path.join(process.env.HOME, '.Ontime');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectsPath = path.join(getAppDataPath(), 'projects');
|
||||||
|
const corruptProjectsPath = path.join(getAppDataPath(), 'corrupt files');
|
||||||
|
const crashLogPath = path.join(getAppDataPath(), 'crash logs');
|
||||||
|
const stylesPath = path.join(getAppDataPath(), 'user', 'styles');
|
||||||
|
const externalPath = path.join(getAppDataPath(), 'external');
|
||||||
|
|
||||||
|
/** path to tray icon */
|
||||||
|
const trayIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'background.png');
|
||||||
|
/** path to app icon directory */
|
||||||
|
const appIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'logo.png');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
linkToDocs,
|
||||||
|
linkToGitHub,
|
||||||
|
linkToDiscord,
|
||||||
|
env,
|
||||||
|
isProduction,
|
||||||
|
isMac,
|
||||||
|
isWindows,
|
||||||
|
releaseTag,
|
||||||
|
nodePath,
|
||||||
|
getClientUrl,
|
||||||
|
getServerUrl,
|
||||||
|
projectsPath,
|
||||||
|
corruptProjectsPath,
|
||||||
|
crashLogPath,
|
||||||
|
stylesPath,
|
||||||
|
externalPath,
|
||||||
|
downloadPath,
|
||||||
|
trayIcon,
|
||||||
|
appIcon,
|
||||||
|
};
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron');
|
const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const electronConfig = require('./electron.config');
|
|
||||||
const { version } = require('./package.json');
|
|
||||||
const { getApplicationMenu } = require('./src/menu/applicationMenu.js');
|
|
||||||
|
|
||||||
const env = process.env.NODE_ENV || 'production';
|
const { getApplicationMenu } = require('./menu/applicationMenu.js');
|
||||||
const isProduction = env === 'production';
|
const { getTrayMenu } = require('./menu/trayMenu.js');
|
||||||
const isMac = process.platform === 'darwin';
|
|
||||||
const isWindows = process.platform === 'win32';
|
|
||||||
|
|
||||||
// path to server
|
const electronConfig = require('./electron.config.js');
|
||||||
const nodePath = isProduction
|
const {
|
||||||
? path.join(__dirname, electronConfig.server.pathToEntrypoint)
|
env,
|
||||||
: path.join(__dirname, '../server/dist/index.cjs');
|
isProduction,
|
||||||
|
isWindows,
|
||||||
|
nodePath,
|
||||||
|
getClientUrl,
|
||||||
|
trayIcon,
|
||||||
|
appIcon,
|
||||||
|
getServerUrl,
|
||||||
|
} = require('./externals.js');
|
||||||
|
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
console.log(`Electron running in ${env} environment`);
|
console.log(`Electron running in ${env} environment`);
|
||||||
@@ -20,10 +22,13 @@ if (!isProduction) {
|
|||||||
process.traceProcessWarnings = true;
|
process.traceProcessWarnings = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// path to icons
|
/** Flag holds server loading state */
|
||||||
const trayIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'background.png');
|
let loaded = 'Ontime running';
|
||||||
const appIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'logo.png');
|
|
||||||
let loaded = 'Nothing loaded';
|
/**
|
||||||
|
* Flag whether user has requested a quit
|
||||||
|
* Used to coordinate window closes without exit
|
||||||
|
*/
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
|
|
||||||
// initialise
|
// initialise
|
||||||
@@ -56,13 +61,13 @@ async function startBackend() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @description utility function to create a notification
|
* @description utility function to create a notification
|
||||||
* @param title
|
* @param {string} title - Notification title
|
||||||
* @param text
|
* @param {string} body - Notification body
|
||||||
*/
|
*/
|
||||||
function showNotification(title, text) {
|
function showNotification(title, body) {
|
||||||
new Notification({
|
new Notification({
|
||||||
title,
|
title,
|
||||||
body: text,
|
body,
|
||||||
silent: true,
|
silent: true,
|
||||||
}).show();
|
}).show();
|
||||||
}
|
}
|
||||||
@@ -108,6 +113,14 @@ function escalateError(error) {
|
|||||||
dialog.showErrorBox('An unrecoverable error occurred', error);
|
dialog.showErrorBox('An unrecoverable error occurred', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows electron to ask react app redirect
|
||||||
|
* @param string location
|
||||||
|
*/
|
||||||
|
function redirectWindow(location) {
|
||||||
|
win.webContents.send('request-editor-location', location);
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure there isn't another instance of the app running already
|
// Ensure there isn't another instance of the app running already
|
||||||
const lock = app.requestSingleInstanceLock();
|
const lock = app.requestSingleInstanceLock();
|
||||||
if (!lock) {
|
if (!lock) {
|
||||||
@@ -141,7 +154,7 @@ function createWindow() {
|
|||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
});
|
});
|
||||||
splash.setIgnoreMouseEvents(true);
|
splash.setIgnoreMouseEvents(true);
|
||||||
const splashPath = path.join('file://', __dirname, '/src/splash/splash.html');
|
const splashPath = path.join('file://', __dirname, '/splash/splash.html');
|
||||||
splash.loadURL(splashPath);
|
splash.loadURL(splashPath);
|
||||||
|
|
||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
@@ -156,7 +169,7 @@ function createWindow() {
|
|||||||
enableWebSQL: false,
|
enableWebSQL: false,
|
||||||
darkTheme: true,
|
darkTheme: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, './src/preload.js'),
|
preload: path.join(__dirname, './preload.js'),
|
||||||
nodeIntegration: true,
|
nodeIntegration: true,
|
||||||
contextIsolation: false,
|
contextIsolation: false,
|
||||||
},
|
},
|
||||||
@@ -173,18 +186,13 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
startBackend()
|
startBackend()
|
||||||
.then((port) => {
|
.then((port) => {
|
||||||
// Load page served by node or use React dev run
|
const clientUrl = getClientUrl(port);
|
||||||
const clientUrl = isProduction
|
const serverUrl = getServerUrl(port);
|
||||||
? electronConfig.reactAppUrl.production(port)
|
const menu = getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, (url) =>
|
||||||
: electronConfig.reactAppUrl.development(port);
|
win.webContents.downloadURL(url),
|
||||||
|
);
|
||||||
const template = getApplicationMenu(isMac, askToQuit, clientUrl, `v${version}`, (path) => {
|
|
||||||
win.loadURL(`${clientUrl}/${path}`);
|
|
||||||
});
|
|
||||||
const menu = Menu.buildFromTemplate(template);
|
|
||||||
Menu.setApplicationMenu(menu);
|
Menu.setApplicationMenu(menu);
|
||||||
|
|
||||||
win
|
win
|
||||||
@@ -229,13 +237,9 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// create tray
|
// create tray and set its context menu
|
||||||
tray = new Tray(trayIcon);
|
tray = new Tray(trayIcon);
|
||||||
|
const trayContextMenu = getTrayMenu(bringToFront, askToQuit);
|
||||||
// Define context menu
|
|
||||||
const { getTrayMenu } = require('./src/menu/trayMenu.js');
|
|
||||||
const trayMenuTemplate = getTrayMenu(bringToFront, askToQuit);
|
|
||||||
const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate);
|
|
||||||
tray.setContextMenu(trayContextMenu);
|
tray.setContextMenu(trayContextMenu);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,7 +265,7 @@ ipcMain.on('shutdown', () => {
|
|||||||
/**
|
/**
|
||||||
* Handles requests to set window properties
|
* Handles requests to set window properties
|
||||||
*/
|
*/
|
||||||
ipcMain.on('set-window', (event, arg) => {
|
ipcMain.on('set-window', (_event, arg) => {
|
||||||
switch (arg) {
|
switch (arg) {
|
||||||
case 'show-dev':
|
case 'show-dev':
|
||||||
win.webContents.openDevTools({ mode: 'detach' });
|
win.webContents.openDevTools({ mode: 'detach' });
|
||||||
@@ -274,6 +278,10 @@ ipcMain.on('set-window', (event, arg) => {
|
|||||||
/**
|
/**
|
||||||
* Handles requests to open external links
|
* Handles requests to open external links
|
||||||
*/
|
*/
|
||||||
ipcMain.on('send-to-link', (event, arg) => {
|
ipcMain.on('send-to-link', (_event, arg) => {
|
||||||
shell.openExternal(arg);
|
try {
|
||||||
|
shell.openExternal(arg);
|
||||||
|
} catch (_error) {
|
||||||
|
/** unhandled error */
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -1,185 +1,315 @@
|
|||||||
const { shell } = require('electron');
|
const { Menu, shell } = require('electron');
|
||||||
|
|
||||||
|
const {
|
||||||
|
linkToGitHub,
|
||||||
|
linkToDocs,
|
||||||
|
linkToDiscord,
|
||||||
|
isProduction,
|
||||||
|
isMac,
|
||||||
|
releaseTag,
|
||||||
|
projectsPath,
|
||||||
|
corruptProjectsPath,
|
||||||
|
crashLogPath,
|
||||||
|
stylesPath,
|
||||||
|
externalPath,
|
||||||
|
downloadPath,
|
||||||
|
} = require('../externals.js');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build description of application menu
|
* Creates the application menu
|
||||||
* @param {boolean} isMac - Whether the target platform is mac
|
|
||||||
* @param {function} askToQuit - function for quitting process
|
* @param {function} askToQuit - function for quitting process
|
||||||
|
* @param {string} clientUrl - base url for the application
|
||||||
|
* @param {string} serverUrl - base url for the application
|
||||||
|
* @param {function} redirectWindow - function to redirect main window content
|
||||||
|
* @param {function} download - function to download a resource from url
|
||||||
|
* @returns {Menu} - application menu
|
||||||
*/
|
*/
|
||||||
function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow) {
|
function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, download) {
|
||||||
return [
|
const template = [
|
||||||
...(isMac
|
...(isMac ? [makeMacMenu(askToQuit)] : []),
|
||||||
? [
|
makeFileMenu(serverUrl, redirectWindow, download),
|
||||||
{
|
makeViewMenu(clientUrl),
|
||||||
label: 'Ontime',
|
makeSettingsMenu(redirectWindow),
|
||||||
submenu: [
|
makeHelpMenu(redirectWindow),
|
||||||
{ role: 'about' },
|
...(isProduction ? [] : [{ label: 'Dev', submenu: [{ role: 'toggleDevTools' }] }]),
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'hide' },
|
|
||||||
{ role: 'hideOthers' },
|
|
||||||
{ role: 'unhide' },
|
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
|
||||||
label: 'Quit',
|
|
||||||
click: () => askToQuit(),
|
|
||||||
accelerator: 'Cmd+Q',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
label: 'File',
|
|
||||||
submenu: [isMac ? { role: 'close' } : { role: 'quit' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Edit',
|
|
||||||
submenu: [
|
|
||||||
{ role: 'undo' },
|
|
||||||
{ role: 'redo' },
|
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'cut' },
|
|
||||||
{ role: 'copy' },
|
|
||||||
{ role: 'paste' },
|
|
||||||
...(isMac
|
|
||||||
? [
|
|
||||||
{ role: 'pasteAndMatchStyle' },
|
|
||||||
{ role: 'delete' },
|
|
||||||
{ role: 'selectAll' },
|
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
|
||||||
label: 'Speech',
|
|
||||||
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Views',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'Ontime Views (opens in browser)',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'Public',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal(`${urlBase}/public`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Lower Thirds',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal(`${urlBase}/lower`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
|
||||||
label: 'Timer',
|
|
||||||
accelerator: 'CmdOrCtrl+V',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal(`${urlBase}/timer`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Clock',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal(`${urlBase}/clock`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Minimal Timer',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal(`${urlBase}/minimal`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Backstage',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal(`${urlBase}/backstage`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Timeline (beta)',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal(`${urlBase}/timeline`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Studio Clock',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal(`${urlBase}/studio`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Countdown',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal(`${urlBase}/countdown`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
|
||||||
label: 'Editor',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal(`${urlBase}/editor`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Cuesheet',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal(`${urlBase}/cuesheet`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Operator',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal(`${urlBase}/op`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'forceReload' },
|
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'resetZoom' },
|
|
||||||
{ role: 'zoomIn' },
|
|
||||||
{ role: 'zoomOut' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Window',
|
|
||||||
submenu: [
|
|
||||||
{ role: 'minimize' },
|
|
||||||
{ role: 'zoom' },
|
|
||||||
...(isMac
|
|
||||||
? [{ type: 'separator' }, { role: 'front' }, { type: 'separator' }, { role: 'window' }]
|
|
||||||
: [{ role: 'close' }]),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'help',
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: 'About',
|
|
||||||
click: () => redirectWindow('editor?settings=about'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: version,
|
|
||||||
click: () => redirectWindow('editor?settings=about'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'See on github',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal('https://github.com/cpvalente/ontime');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Online documentation',
|
|
||||||
click: async () => {
|
|
||||||
await shell.openExternal('https://docs.getontime.no/');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
return Menu.buildFromTemplate(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function generates the app menu (macOS only)
|
||||||
|
* @param {function} askToQuit - function for quitting process
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function makeMacMenu(askToQuit) {
|
||||||
|
return {
|
||||||
|
label: 'Ontime',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'about', label: 'About Ontime' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'hide', label: 'Hide Ontime' },
|
||||||
|
{ role: 'hideOthers' },
|
||||||
|
{ role: 'unhide' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Quit',
|
||||||
|
click: () => askToQuit(),
|
||||||
|
accelerator: isMac ? 'Cmd+Q' : 'Alt+F4',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function generates the file menu
|
||||||
|
* @param {string} serverUrl - base url for the application
|
||||||
|
* @param {function} redirectWindow - function to redirect main window content
|
||||||
|
* @param {function} download - function to download a resource from url
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function makeFileMenu(serverUrl, redirectWindow, download) {
|
||||||
|
const downloadProject = () => {
|
||||||
|
try {
|
||||||
|
download(serverUrl + downloadPath);
|
||||||
|
} catch (_error) {
|
||||||
|
/** unhandled error */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: 'File',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'New project...',
|
||||||
|
click: () => redirectWindow('/editor?settings=project__manage&create=true'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Edit project info',
|
||||||
|
click: () => redirectWindow('/editor?settings=project__data'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Manage projects...',
|
||||||
|
click: () => redirectWindow('/editor?settings=project__manage'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Download project',
|
||||||
|
click: downloadProject,
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Open directory',
|
||||||
|
submenu: [
|
||||||
|
makeItemOpenInDesktop('Projects', projectsPath),
|
||||||
|
makeItemOpenInDesktop('Corrupted projects', corruptProjectsPath),
|
||||||
|
makeItemOpenInDesktop('Crash logs', crashLogPath),
|
||||||
|
makeItemOpenInDesktop('CSS override', stylesPath),
|
||||||
|
makeItemOpenInDesktop('External', externalPath),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: isMac ? 'close' : 'quit' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function generates the views menu
|
||||||
|
* @param {string} clientUrl - base url for the application
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function makeViewMenu(clientUrl) {
|
||||||
|
return {
|
||||||
|
label: 'Views',
|
||||||
|
submenu: [
|
||||||
|
makeItemOpenInBrowser('Public', `${clientUrl}/public`),
|
||||||
|
makeItemOpenInBrowser('Lower Thirds', `${clientUrl}/lower`),
|
||||||
|
{ type: 'separator' },
|
||||||
|
makeItemOpenInBrowser('Timer', `${clientUrl}/timer`),
|
||||||
|
makeItemOpenInBrowser('Minimal Timer', `${clientUrl}/minimal`),
|
||||||
|
makeItemOpenInBrowser('Clock', `${clientUrl}/clock`),
|
||||||
|
makeItemOpenInBrowser('Backstage', `${clientUrl}/backstage`),
|
||||||
|
makeItemOpenInBrowser('Timeline (beta)', `${clientUrl}/timeline`),
|
||||||
|
makeItemOpenInBrowser('Studio Clock', `${clientUrl}/studio`),
|
||||||
|
makeItemOpenInBrowser('Countdown', `${clientUrl}/countdown`),
|
||||||
|
{ type: 'separator' },
|
||||||
|
makeItemOpenInBrowser('Editor', `${clientUrl}/editor`),
|
||||||
|
makeItemOpenInBrowser('Cuesheet', `${clientUrl}/cuesheet`),
|
||||||
|
makeItemOpenInBrowser('Operator', `${clientUrl}/op`),
|
||||||
|
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'forceReload' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'resetZoom' },
|
||||||
|
// NOTE: I still contend this zoomin mess is an electron bug
|
||||||
|
{ role: 'zoomIn', accelerator: 'CmdOrCtrl+Plus' },
|
||||||
|
{ role: 'zoomIn', accelerator: 'CmdOrCtrl+=', visible: false },
|
||||||
|
{ role: 'zoomIn', accelerator: 'CmdOrCtrl+numadd', visible: false },
|
||||||
|
{ role: 'zoomOut' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'togglefullscreen' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function generates the settings menu
|
||||||
|
* @param {function} redirectWindow - function to redirect main window content
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function makeSettingsMenu(redirectWindow) {
|
||||||
|
return {
|
||||||
|
label: 'Settings',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Open Settings',
|
||||||
|
accelerator: 'CommandOrControl+,',
|
||||||
|
click: () => redirectWindow('/editor?settings=project'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Project',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Project data',
|
||||||
|
click: () => redirectWindow('/editor?settings=project__data'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Manage projects',
|
||||||
|
click: () => redirectWindow('/editor?settings=project__manage'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'App Settings',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'General settings',
|
||||||
|
click: () => redirectWindow('/editor?settings=general__settings'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Editor settings',
|
||||||
|
click: () => redirectWindow('/editor?settings=general__editor'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'View settings',
|
||||||
|
click: () => redirectWindow('/editor?settings=general__view'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Feature Settings',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Custom fields',
|
||||||
|
click: () => redirectWindow('/editor?settings=feature_settings__custom'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'URL presets',
|
||||||
|
click: () => redirectWindow('/editor?settings=feature_settings__urlpresets'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Data Sources',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Import spreadsheet',
|
||||||
|
click: () => redirectWindow('/editor?settings=sources__xlsx'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sync with Google Sheet',
|
||||||
|
click: () => redirectWindow('/editor?settings=sources__gsheet'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Integrations',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'OSC settings',
|
||||||
|
click: () => redirectWindow('/editor?settings=integrations__osc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'HTTP settings',
|
||||||
|
click: () => redirectWindow('/editor?settings=integrations__http'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Network',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Event log',
|
||||||
|
click: () => redirectWindow('/editor?settings=network__log'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Manage cleints',
|
||||||
|
click: () => redirectWindow('/editor?settings=network__clients'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function generates the help menu
|
||||||
|
* @param {function} redirectWindow - function to redirect main window content
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function makeHelpMenu(redirectWindow) {
|
||||||
|
return {
|
||||||
|
role: 'help',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: `Ontime ${releaseTag}`,
|
||||||
|
click: () => redirectWindow('/editor?settings=about'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'separator',
|
||||||
|
},
|
||||||
|
makeItemOpenInBrowser('See on github', linkToGitHub),
|
||||||
|
makeItemOpenInBrowser('Online documentation', linkToDocs),
|
||||||
|
makeItemOpenInBrowser('Join us on Discord', linkToDiscord),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to safely open a URL in the default browser
|
||||||
|
* @param {string} label
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {object} - MenuItem
|
||||||
|
*/
|
||||||
|
function makeItemOpenInBrowser(label, url) {
|
||||||
|
return {
|
||||||
|
label: `${label} ↗`,
|
||||||
|
click: async () => {
|
||||||
|
try {
|
||||||
|
await shell.openExternal(url);
|
||||||
|
} catch (_error) {
|
||||||
|
/** unhandled error */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to open a file in the OS explorer / finder
|
||||||
|
* @param {string} label
|
||||||
|
* @param {string} path
|
||||||
|
* @returns {object} - MenuItem
|
||||||
|
*/
|
||||||
|
function makeItemOpenInDesktop(label, path) {
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
click: () => {
|
||||||
|
try {
|
||||||
|
shell.openPath(path);
|
||||||
|
} catch (_error) {
|
||||||
|
/** unhandled error */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getApplicationMenu };
|
module.exports = { getApplicationMenu };
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
|
const { Menu } = require('electron');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build description of tray context menu
|
* Creates the application tray menu
|
||||||
* @param {function} showApp - function for making the window visible
|
* @param {function} showApp - function for making the window visible
|
||||||
* @param {function} askToQuit - function for quitting process
|
* @param {function} askToQuit - function for quitting process
|
||||||
|
* @returns {Menu} - application tray menu
|
||||||
*/
|
*/
|
||||||
function getTrayMenu(showApp, askToQuit) {
|
function getTrayMenu(showApp, askToQuit) {
|
||||||
return [
|
return Menu.buildFromTemplate([
|
||||||
{
|
{
|
||||||
label: 'Show App',
|
label: 'Show App',
|
||||||
click: () => showApp(),
|
click: showApp,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Shutdown',
|
label: 'Shutdown',
|
||||||
click: () => askToQuit(),
|
click: askToQuit,
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getTrayMenu };
|
module.exports = { getTrayMenu };
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<img src="../../assets/logo.png" />
|
<img src="../assets/logo.png" />
|
||||||
<h1>ontime · event timers</h1>
|
<h1>ontime · event timers</h1>
|
||||||
<div class="lds-ellipsis">
|
<div class="lds-ellipsis">
|
||||||
<div></div>
|
<div></div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "3.6.0",
|
"version": "3.6.2",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
@@ -17,11 +17,11 @@
|
|||||||
"lowdb": "^7.0.1",
|
"lowdb": "^7.0.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-osc": "^9.0.2",
|
"node-osc": "^9.0.2",
|
||||||
"node-xlsx": "^0.23.0",
|
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"steno": "^4.0.2",
|
"steno": "^4.0.2",
|
||||||
"ws": "^8.13.0"
|
"ws": "^8.13.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export class SocketServer implements IAdapter {
|
|||||||
|
|
||||||
private wss: WebSocketServer | null;
|
private wss: WebSocketServer | null;
|
||||||
private readonly clients: Map<string, Client>;
|
private readonly clients: Map<string, Client>;
|
||||||
|
private lastConnection: Date | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (instance) {
|
if (instance) {
|
||||||
@@ -58,6 +59,7 @@ export class SocketServer implements IAdapter {
|
|||||||
path: '',
|
path: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.lastConnection = new Date();
|
||||||
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientId}`);
|
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientId}`);
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
@@ -171,6 +173,13 @@ export class SocketServer implements IAdapter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
connectedClients: this.clients.size,
|
||||||
|
lastConnection: this.lastConnection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private sendClientList(): void {
|
private sendClientList(): void {
|
||||||
const payload = Object.fromEntries(this.clients.entries());
|
const payload = Object.fromEntries(this.clients.entries());
|
||||||
this.sendAsJson({ type: 'client-list', payload });
|
this.sendAsJson({ type: 'client-list', payload });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isAlphanumeric } from 'ontime-utils';
|
import { isAlphanumericWithSpace } from 'ontime-utils';
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { body, param, validationResult } from 'express-validator';
|
import { body, param, validationResult } from 'express-validator';
|
||||||
@@ -9,7 +9,7 @@ export const validateCustomField = [
|
|||||||
.isString()
|
.isString()
|
||||||
.trim()
|
.trim()
|
||||||
.custom((value) => {
|
.custom((value) => {
|
||||||
return isAlphanumeric(value);
|
return isAlphanumericWithSpace(value);
|
||||||
}),
|
}),
|
||||||
body('type').exists().isString().trim(),
|
body('type').exists().isString().trim(),
|
||||||
body('colour').exists().isString().trim(),
|
body('colour').exists().isString().trim(),
|
||||||
|
|||||||