Compare commits

...

54 Commits

Author SHA1 Message Date
Carlos Valente b13d5792f3 fixup! feat: implement speed controls 2025-03-02 14:45:40 +01:00
Carlos Valente d0027d4016 fixup! feat: implement speed controls 2025-03-02 14:45:32 +01:00
Carlos Valente ae1639802c feat: implement speed controls 2025-03-02 14:13:53 +01:00
Carlos Valente 9167a50eda refactor: project info design review 2025-02-28 19:28:55 +01:00
Carlos Valente 8adca908b3 refactor: improve links inside editor 2025-02-28 19:21:48 +01:00
Carlos Valente bf99646ad5 refactor: restructure directories 2025-02-28 19:21:48 +01:00
Carlos Valente 308285ce3e chore: add missing variables 2025-02-28 19:21:48 +01:00
Carlos Valente dd781a32f6 bump version to 3.13.1 2025-02-27 08:28:21 +01:00
Carlos Valente c2a6de6195 fix: memoisation of row should allow changes 2025-02-27 08:28:21 +01:00
Carlos Valente 0ac2ce8e84 bump version to 3.13.0 2025-02-26 21:43:24 +01:00
Carlos Valente 3bdd8424eb chore: remove console statement 2025-02-26 21:43:24 +01:00
Carlos Valente b3a099255f fix: missing backstage prop 2025-02-26 21:43:24 +01:00
Carlos Valente 570ff59cfc feat: add custom field type of image 2025-02-26 21:42:24 +01:00
Alex Christoffer Rasmussen d0b4c6a279 import id's from sheet (#1516)
* import id's from sheet

* fix test

* fix test in utils

* remove comment

* ensure uri safe ids
2025-02-26 21:12:36 +01:00
Carlos Valente e90161aa33 refactor: consistent logo sizes 2025-02-25 21:25:28 +01:00
Carlos Valente e37254107e style: reword prompt 2025-02-25 21:25:28 +01:00
Carlos Valente 9d6bc0a137 style: clarify resource 2025-02-25 21:25:28 +01:00
Carlos Valente 4cd2dcbab0 refactor: move logic to service 2025-02-25 21:25:28 +01:00
Carlos Valente 8c593b3487 refactor: use memoised selector 2025-02-23 21:50:01 +01:00
Carlos Valente ba74622569 refactor: use native button 2025-02-23 21:50:01 +01:00
Carlos Valente f76c0d1a6d refactor: improve composition performance 2025-02-23 21:50:01 +01:00
Carlos Valente 8e8d3a7824 refactor: send client list on patch 2025-02-23 21:49:53 +01:00
Carlos Valente 6c3ae1f914 refactor: show schedule in smaller screens 2025-02-23 21:49:53 +01:00
Alex Christoffer Rasmussen b6507c27a6 Report (#1469)
* create report service

* write report from runtimeState

* use in UI

* clear report

* update types

* clear all from settings menu

* rearence rightclik menu

* refactor styling

* also report roll events

* ontime/under time is same colour

* refactor reporter

* add target to ontime-refetch

* remove menu

* fectch only on message from server

* memo useGetEventReport

* refactor

* use staleTime

* dont add to menu yet

* implement review

* clear all from menu

* fix merge

* start end show

* combine test for the go button text and action

* add report to menu

* extract csv utility

* report management

* unneeded async

* small refacort of triggerReportEntry

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-02-23 17:02:09 +01:00
Carlos Valente 21877e4bff refactor: allow redirecting to view 2025-02-22 16:15:24 +01:00
Carlos Valente b8d7324be0 feat: allow redirecting to preset 2025-02-22 16:15:24 +01:00
Carlos Valente 88b8e83494 Time until (#1497)
* Timeuntil in UI (#1461)

* cherry pick EventBlockChip

* pull data down throug components

* add comment

* cleanup css

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* fix: account for running day

* refactor: improve composition

* add partial test

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2025-02-19 13:17:53 +01:00
Carlos Valente abce0c498c bump version to 3.12.0 2025-02-18 21:43:16 +01:00
Carlos Valente 6b2e45e7de refactor: improve template input 2025-02-18 21:43:03 +01:00
Carlos Valente 5fb30c75c4 feat: ontime actions in automation 2025-02-18 21:43:03 +01:00
Carlos Valente 636f78e21e fix: unique reference for filter items 2025-02-18 21:43:03 +01:00
Carlos Valente 1636745fed refactor: improve automation flow logging 2025-02-18 21:43:03 +01:00
Carlos Valente e01ab0c88b fix: update navigation references 2025-02-18 21:43:03 +01:00
Carlos Valente 0ede753652 fix: missing reference on template input 2025-02-18 21:43:03 +01:00
Alex Christoffer Rasmussen 1f6f3df0f2 ensure extension during rename and delete (#1508) 2025-02-18 21:25:36 +01:00
Carlos Valente a5afb32b8a refactor: show projected time 2025-02-18 14:55:24 +01:00
Carlos Valente 9c41f8ff6b refactor: public design review 2025-02-18 14:55:24 +01:00
Carlos Valente 1edde03bb4 chore: directory restructure 2025-02-18 14:55:24 +01:00
Carlos Valente 946b6717d2 refactor: backstage design review
refactor: improve empty state

refactor: review schedule design

- fit as many elements as possible
- expose cycle interval as an option
- stabilise rundown reference
- style tweaks
2025-02-18 14:55:24 +01:00
Carlos Valente f99ad83049 chore: folder structure 2025-02-18 14:55:24 +01:00
Carlos Valente 665496907e refactor: improve overflow style 2025-02-18 14:14:14 +01:00
Carlos Valente 2867c8301d refactor: stabilise table options 2025-02-18 14:14:14 +01:00
Carlos Valente 84a5b4fe5b refactor: use a single menu for table 2025-02-18 14:14:14 +01:00
Carlos Valente 86bb739d90 chore: upgrade zustand 2025-02-18 14:14:14 +01:00
Carlos Valente 2c8b6180d7 refactor: show menu only if enabled 2025-02-18 14:14:14 +01:00
Carlos Valente 4cc138e3c0 chore: directory structure 2025-02-18 14:14:14 +01:00
Carlos Valente d21e1f996f refactor: simplify typing 2025-02-17 21:36:18 +01:00
Carlos Valente 11c1b472a2 refactor: do not test automations without outputs 2025-02-17 21:36:18 +01:00
Carlos Valente 891bda6661 refactor: improve async handling 2025-02-17 21:36:18 +01:00
Carlos Valente c65761448d refactor: simplify import paths 2025-02-17 21:36:18 +01:00
Carlos Valente 70558c5104 refactor: remove unused template literals 2025-02-17 21:36:18 +01:00
Carlos Valente c8bf74a719 refactor: migrate chakra alert to custom component 2025-02-17 14:45:23 +01:00
Carlos Valente 10bf4b93ee doc: add osc disabled warning for cloud users 2025-02-17 14:45:23 +01:00
Carlos Valente f6449a4326 refactor: tweak reusable info component 2025-02-17 14:45:23 +01:00
215 changed files with 4696 additions and 1965 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.11.1", "version": "3.13.1",
"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",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.11.1", "version": "3.13.1",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -31,7 +31,7 @@
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
"zustand": "^4.5.2" "zustand": "^5.0.3"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
+2 -2
View File
@@ -27,9 +27,9 @@ const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-tim
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock')); 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('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage')); const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Public = React.lazy(() => import('./features/viewers/public/Public')); const Public = React.lazy(() => import('./views/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'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo')); const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
+1
View File
@@ -14,6 +14,7 @@ export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets']; export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
// API URLs // API URLs
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
+3 -2
View File
@@ -1,7 +1,8 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils'; import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -42,7 +43,7 @@ export async function downloadCSV(fileName: string = 'rundown') {
const { project, rundown, customFields } = data; const { project, rundown, customFields } = data;
const sheetData = makeTable(project, rundown, customFields); const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSV(sheetData); const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`); downloadBlob(blob, `${name}.csv`);
+26
View File
@@ -0,0 +1,26 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(`${reportUrl}/`);
return res.data;
}
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
@@ -1,12 +0,0 @@
@use "../../../theme/mixins" as *;
.link {
@include action-link;
font-size: $inner-section-text-size;
}
.linkIcon {
margin-left: $element-inner-spacing;
display: inline-block;
@include rotate-fourty-five;
}
@@ -1,26 +0,0 @@
import { MouseEvent, ReactNode } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { openLink } from '../../utils/linkUtils';
import style from './AppLink.module.scss';
interface AppLinkProps {
href: string;
children: ReactNode;
}
export default function AppLink(props: AppLinkProps) {
const { href, children } = props;
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
{children} <IoArrowUp className={style.linkIcon} />
</a>
);
}
@@ -0,0 +1,26 @@
.subtle {
aspect-ratio: 1;
height: 2rem;
height: 2rem;
display: grid;
place-content: center;
background: $gray-1050;
color: $blue-400;
border: 1px solid transparent;
border-radius: 3px;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
@@ -0,0 +1,14 @@
import { ButtonHTMLAttributes } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss';
export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
const { className, children, ...buttonProps } = props;
return (
<button className={cx([style.subtle, className])} {...buttonProps}>
{children}
</button>
);
}
@@ -0,0 +1,24 @@
.textEntry {
grid-area: input;
display: flex;
align-items: center;
gap: 0.5rem;
color: $label-gray;
}
.inlineEntry {
display: grid;
grid-template-areas:
'label label'
'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
}
.redirect {
grid-area: btn;
}
.label {
color: $label-gray;
}
@@ -1,64 +1,125 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Button, IconButton,
Input, Input,
InputGroup,
InputLeftAddon,
Modal, Modal,
ModalBody, ModalBody,
ModalCloseButton, ModalCloseButton,
ModalContent, ModalContent,
ModalFooter,
ModalHeader, ModalHeader,
ModalOverlay, ModalOverlay,
Select,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { IoArrowForward } from '@react-icons/all-files/io5/IoArrowForward';
import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import Info from '../info/Info';
import AppLink from '../link/app-link/AppLink';
import style from './RedirectClientModal.module.scss';
interface RedirectClientModalProps { interface RedirectClientModalProps {
id: string; id: string;
name?: string; name: string;
path?: string; currentPath: string;
origin: string;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
} }
export function RedirectClientModal(props: RedirectClientModalProps) { export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name = '', path: currentPath = '', onClose } = props; const { id, isOpen, name, currentPath, origin, onClose } = props;
const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath); const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/');
const { setRedirect } = setClientRemote; const { setRedirect } = setClientRemote;
const handleRedirect = () => { const handleRedirect = (newPath: string) => {
if (path !== currentPath && path !== '') { if (newPath === '/' || newPath === currentPath) {
setRedirect({ target: id, redirect: path }); return;
} }
setRedirect({ target: id, redirect: newPath });
onClose(); onClose();
}; };
const host = window.location.origin; const enabledPresets = data.filter((preset) => preset.enabled);
const canSubmit = path !== currentPath && path !== '';
return ( return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'> <Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay /> <ModalOverlay />
<ModalContent> <ModalContent maxWidth='max(480px, 35vw)'>
<ModalHeader>Redirect: {name}</ModalHeader> <ModalHeader>Redirect: {name}</ModalHeader>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody> <ModalBody>
<InputGroup variant='ontime-filled' size='md'> <Info>
<InputLeftAddon>{host}</InputLeftAddon> Remotely redirect the client to a different URL. <br />
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} /> Either by selecting a URL Preset or entering a custom path.
</InputGroup> <br />
<br />
<AppLink search='settings=feature_settings__urlpresets'>Manage URL Presets</AppLink>
</Info>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.textEntry}>
<Select
size='md'
variant='ontime'
isDisabled={enabledPresets.length === 0}
onChange={(event) => setSelected(event.target.value)}
>
<option value='/'>Select view or preset</option>
{navigatorConstants.map((view) => {
return (
<option key={view.url} value={`/${view.url}`}>
{view.label}
</option>
);
})}
{enabledPresets.map((preset) => {
return (
<option key={preset.pathAndParams} value={preset.pathAndParams}>
{`Preset: ${preset.alias}`}
</option>
);
})}
</Select>
<IconButton
variant='ontime-filled'
size='md'
aria-label='Redirect to preset'
className={style.redirect}
icon={<IoArrowForward />}
isDisabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
/>
</div>
</div>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
{origin}
<Input
variant='ontime-filled'
size='md'
placeholder='eg. /minimal?key=0000ffff'
value={path}
onChange={(event) => setPath(event.target.value)}
/>
</label>
<IconButton
variant='ontime-filled'
size='md'
aria-label='Redirect'
isDisabled={path === currentPath || path === ''}
className={style.redirect}
icon={<IoArrowForward />}
onClick={() => handleRedirect(path)}
/>
</div>
</ModalBody> </ModalBody>
<ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
Submit
</Button>
</ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
); );
@@ -2,14 +2,19 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: $element-spacing; gap: $element-spacing;
padding: 1rem;
margin-bottom: 1rem;
background-color: $gray-1100; background-color: $gray-1200;
border-radius: 2px; border-radius: 3px;
font-size: $inner-section-text-size; font-size: $text-body-size;
padding: 1rem;
.content {
color: $gray-200;
}
svg { svg {
min-width: 1.5rem;
align-self: start;
font-size: 1.5rem; font-size: 1.5rem;
color: $info-blue; color: $info-blue;
} }
@@ -1,13 +1,21 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle'; import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import { cx } from '../../utils/styleUtils';
import style from './Info.module.scss'; import style from './Info.module.scss';
export default function Info({ children }: PropsWithChildren) { interface InfoProps {
className?: string;
}
export default function Info(props: PropsWithChildren<InfoProps>) {
const { className, children } = props;
return ( return (
<div className={style.infoLabel}> <div className={cx([style.infoLabel, className])}>
<IoAlertCircle /> <IoAlertCircle />
{children} <div>{children}</div>
</div> </div>
); );
} }
@@ -3,9 +3,9 @@ import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop'; import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop';
import { ViewSettings } from 'ontime-types'; import { ViewSettings } from 'ontime-types';
import PopoverPicker from '../../../../common/components/input/popover-picker/PopoverPicker'; import { debounce } from '../../../utils/debounce';
import { debounce } from '../../../../common/utils/debounce';
import { cx, getAccessibleColour } from '../../../utils/styleUtils'; import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import PopoverPicker from '../popover-picker/PopoverPicker';
import style from './SwatchSelect.module.scss'; import style from './SwatchSelect.module.scss';
@@ -0,0 +1,10 @@
.link {
color: $label-gray;
font-size: $text-body-size;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: $blue-400;
}
}
@@ -0,0 +1,28 @@
import { type PropsWithChildren } from 'react';
import { useNavigate } from 'react-router-dom';
import { cx } from '../../../utils/styleUtils';
import style from './AppLink.module.scss';
interface AppLinkProps {
className?: string;
search: string;
}
/**
* Component used to navigate to an editor link inside the same window
* Handles the path to respect Ontime Clouds base URL
*/
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
const { className, search, children } = props;
const navigate = useNavigate();
const handleClick = () => navigate({ search });
return (
<button onClick={handleClick} className={cx([style.link, className])}>
{children}
</button>
);
}
@@ -1,8 +1,8 @@
import { MouseEvent, ReactNode } from 'react'; import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline'; import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../utils/linkUtils'; import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
import style from './ExternalLink.module.scss'; import style from './ExternalLink.module.scss';
@@ -14,7 +14,6 @@ interface ExternalLinkProps {
export default function ExternalLink(props: ExternalLinkProps) { export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props; const { href, inline, children } = props;
const classes = cx([style.link, inline ? style.inline : null]);
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
@@ -22,8 +21,14 @@ export default function ExternalLink(props: ExternalLinkProps) {
}; };
return ( return (
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}> <a
{children} <IoOpenOutline /> href='#!'
target='_blank'
rel='noreferrer'
className={cx([style.link, inline && style.inline])}
onClick={handleClick}
>
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
</a> </a>
); );
} }
@@ -1,7 +1,7 @@
import { IoApps } from '@react-icons/all-files/io5/IoApps'; import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -1,6 +1,6 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline'; import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -9,10 +9,6 @@ $progress-bar-br: 3px;
border-radius: $progress-bar-br; border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color); background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
overflow: clip; overflow: clip;
&--hidden {
display: none;
}
} }
.progress-bar__indicator { .progress-bar__indicator {
@@ -7,16 +7,15 @@ import './ProgressBar.scss';
interface ProgressBarProps { interface ProgressBarProps {
current: MaybeNumber; current: MaybeNumber;
duration: MaybeNumber; duration: MaybeNumber;
hidden?: boolean;
className?: string; className?: string;
} }
export default function ProgressBar(props: ProgressBarProps) { export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, hidden, className = '' } = props; const { current, duration, className } = props;
const progress = getProgress(current, duration); const progress = getProgress(current, duration);
return ( return (
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}> <div className={`progress-bar__bg ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} /> <div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
</div> </div>
); );
@@ -1,76 +0,0 @@
@use '../../../theme/viewerDefs' as *;
.schedule {
width: 100%;
border-spacing: 50px;
.entry {
font-size: clamp(16px, 1.5vw, 24px);
.entry-colour {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
height: clamp(8px, 0.75vw, 12px);
width: clamp(8px, 0.75vw, 12px);
border-radius: 6px;
display: inline-block;
}
.entry-times {
font-family: $viewer-font-family;
color: var(--secondary-color-override, $viewer-secondary-color);
font-weight: 300;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 8px;
}
.entry-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry-secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:last-child) {
padding-bottom: 8px;
}
&--past {
color: var(--secondary-color-override, $viewer-secondary-color);
}
&--now {
.entry-title {
color: var(--accent-color-override, $accent-color);
font-weight: 600;
}
}
&.skip {
text-decoration: line-through;
}
}
}
.schedule-nav {
display: flex;
justify-content: flex-end;
.schedule-nav__item {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
width: 12px;
height: 12px;
border-radius: 6px;
margin-left: 8px;
&--selected {
background-color: var(--color-override, $viewer-color);
}
}
}
@@ -1,52 +0,0 @@
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
import './Schedule.scss';
interface ScheduleProps {
isProduction?: boolean;
className?: string;
}
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
// TODO: design a nice placeholder for empty schedules
if (paginatedEvents?.length < 1) {
return null;
}
let selectedState: 'past' | 'now' | 'future' = 'past';
return (
<ul className={`schedule ${className}`}>
{paginatedEvents.map((event) => {
if (scheduleType === 'past' || scheduleType === 'future') {
selectedState = scheduleType;
} else {
if (event.id === selectedEventId) {
selectedState = 'now';
} else if (selectedState === 'now') {
selectedState = 'future';
}
}
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
return (
<ScheduleItem
key={event.id}
selected={selectedState}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic}
skip={event.skip}
/>
);
})}
</ul>
);
}
@@ -1,102 +0,0 @@
import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { useInterval } from '../../hooks/useInterval';
interface ScheduleContextState {
events: OntimeEvent[];
paginatedEvents: OntimeEvent[];
selectedEventId: string | null;
scheduleType: 'past' | 'now' | 'future';
numPages: number;
visiblePage: number;
isBackstage: boolean;
}
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
interface ScheduleProviderProps {
events: OntimeEvent[];
selectedEventId: string | null;
isBackstage?: boolean;
time?: number;
}
const numEventsPerPage = 8;
export const ScheduleProvider = ({
children,
events,
selectedEventId,
isBackstage = false,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
const [searchParams] = useSearchParams();
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
const viewEvents = [...events];
if (hidePast) {
// we want to show the event after the next
viewEvents.splice(0, selectedEventIndex + 2);
selectedEventIndex = 0;
}
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
const eventStart = eventsPerPage * visiblePage;
const eventEnd = eventsPerPage * (visiblePage + 1);
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
const resolveScheduleType = () => {
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
return 'now';
}
if (selectedEventIndex > eventEnd) {
return 'past';
}
return 'future';
};
const scheduleType = resolveScheduleType();
// every SCROLL_TIME go to the next array
useInterval(() => {
if (stopCycle) {
setVisiblePage(0);
} else if (events.length > eventsPerPage) {
const next = (visiblePage + 1) % numPages;
setVisiblePage(next);
}
}, time * 1000);
return (
<ScheduleContext.Provider
value={{
events,
paginatedEvents,
selectedEventId,
scheduleType,
numPages,
visiblePage,
isBackstage,
}}
>
{children}
</ScheduleContext.Provider>
);
};
export const useSchedule = () => {
const context = useContext(ScheduleContext);
if (!context) {
throw new Error('useSchedule() can only be used inside a ScheduleContext');
}
return context;
};
@@ -1,43 +0,0 @@
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { formatTime } from '../../utils/time';
import './Schedule.scss';
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
interface ScheduleItemProps {
selected: 'past' | 'now' | 'future';
timeStart: number;
timeEnd: number;
title: string;
backstageEvent: boolean;
colour: string;
skip: boolean;
}
export default function ScheduleItem(props: ScheduleItemProps) {
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
const userColour = colour !== '' ? colour : '';
const selectStyle = `entry--${selected}`;
return (
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: userColour }} />
<div style={{ display: 'flex' }}>
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent ? '*' : ''}
</div>
</div>
<div className='entry-title'>{title}</div>
</li>
);
}
@@ -1,23 +0,0 @@
import { useSchedule } from './ScheduleContext';
import './Schedule.scss';
interface ScheduleNavProps {
className?: string;
}
export default function ScheduleNav({ className }: ScheduleNavProps) {
const { numPages, visiblePage } = useSchedule();
return (
<div className={`schedule-nav ${className}`}>
{numPages > 1 &&
[...Array(numPages).keys()].map((i) => (
<div
key={i}
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
/>
))}
</div>
);
}
@@ -5,7 +5,7 @@
.empty { .empty {
width: 100%; width: 100%;
opacity: 0.6; opacity: 0.8;
} }
.text { .text {
@@ -1,18 +1,20 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import EmptyImage from '../../../assets/images/empty.svg?react'; import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
import style from './Empty.module.scss'; import style from './Empty.module.scss';
interface EmptyProps { interface EmptyProps {
text?: string; text?: string;
style?: CSSProperties; style?: CSSProperties;
className?: string;
} }
export default function Empty(props: EmptyProps) { export default function Empty(props: EmptyProps) {
const { text, ...rest } = props; const { text, className, ...rest } = props;
return ( return (
<div className={style.emptyContainer} {...rest}> <div className={cx([style.emptyContainer, className])} {...rest}>
<EmptyImage className={style.empty} /> <EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>} {text && <span className={style.text}>{text}</span>}
</div> </div>
@@ -1,8 +1,5 @@
@use '../../../theme/viewerDefs' as *; @use '../../../theme/viewerDefs' as *;
$title-primary-size: clamp(1.5rem, 3vw, 3rem);
$title-secondary-size: clamp(1rem, 2vw, 2.25rem);
.title-card { .title-card {
position: relative; position: relative;
display: flex; display: flex;
@@ -10,21 +7,23 @@ $title-secondary-size: clamp(1rem, 2vw, 2.25rem);
} }
.title-card__title, .title-card__title,
.title-card__secondary { .title-card__placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title-card__title {
font-weight: 600; font-weight: 600;
font-size: $title-primary-size; font-size: $title-font-size;
color: var(--color-override, $viewer-color);
line-height: 1.2em; line-height: 1.2em;
} }
.title-card__title {
color: var(--color-override, $viewer-color);
padding-right: 1em;
}
.title-card__placeholder {
color: var(--label-color-override, $viewer-label-color);
}
.title-card__secondary { .title-card__secondary {
font-size: $title-secondary-size; font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em; line-height: 1.2em;
} }
@@ -35,7 +34,6 @@ $title-secondary-size: clamp(1rem, 2vw, 2.25rem);
top: 0.5rem; top: 0.5rem;
font-size: $timer-label-size; font-size: $timer-label-size;
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase; text-transform: uppercase;
&--accent { &--accent {
@@ -1,6 +1,7 @@
import { ForwardedRef, forwardRef } from 'react'; import { ForwardedRef, forwardRef } from 'react';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import { cx } from '../../utils/styleUtils';
import './TitleCard.scss'; import './TitleCard.scss';
@@ -18,9 +19,9 @@ const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivEl
const accent = label === 'now'; const accent = label === 'now';
return ( return (
<div className={`title-card ${className}`} ref={ref}> <div className={cx(['title-card', className])} ref={ref}>
<span className='title-card__title'>{title}</span> <span className='title-card__title'>{title}</span>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}> <span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
{label && getLocalizedString(`common.${label}`)} {label && getLocalizedString(`common.${label}`)}
</span> </span>
<div className='title-card__secondary'>{secondary}</div> <div className='title-card__secondary'>{secondary}</div>
@@ -0,0 +1,5 @@
.viewLogo {
max-width: 100%;
max-height: 75px;
display: block;
}
@@ -1,5 +1,7 @@
import { projectLogoPath } from '../../api/constants'; import { projectLogoPath } from '../../api/constants';
import './ViewLogo.scss';
interface ViewLogoProps { interface ViewLogoProps {
name: string; name: string;
className: string; className: string;
@@ -7,5 +9,11 @@ interface ViewLogoProps {
export default function ViewLogo(props: ViewLogoProps) { export default function ViewLogo(props: ViewLogoProps) {
const { name, className } = props; const { name, className } = props;
return <img src={`${projectLogoPath}/${name}`} className={className} />;
// we wrap the image in a div to help maintain the aspect ratio
return (
<div className={className}>
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
</div>
);
} }
@@ -16,3 +16,7 @@
overflow-y: scroll; overflow-y: scroll;
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.info {
margin-bottom: 1rem;
}
@@ -12,7 +12,7 @@ import {
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import useViewSettings from '../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import Info from '../info/Info'; import Info from '../info/Info';
import { ViewOption } from './types'; import { ViewOption } from './types';
@@ -132,7 +132,9 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
</DrawerHeader> </DrawerHeader>
<DrawerBody> <DrawerBody>
{viewSettings.overrideStyles && <Info>This view style is being modified by a custom CSS file.</Info>} {viewSettings.overrideStyles && (
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
)}
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}> <form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
{viewOptions.map((section) => ( {viewOptions.map((section) => (
<ViewParamsSection <ViewParamsSection
@@ -0,0 +1,45 @@
import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields } from '../constants';
describe('makeOptionsFromCustomFields', () => {
const testCustomFields: CustomFields = {
field1: { label: 'Field 1', colour: 'red', type: 'string' },
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
};
it('creates a record of keys for the given custom fields', () => {
const result = makeOptionsFromCustomFields(testCustomFields);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
it('appends additional data', () => {
const additionalData = {
test1: 'test1',
test2: 'test2',
};
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
test1: 'test1',
test2: 'test2',
});
});
it('filtersImageTypes', () => {
const customFieldsWIthImage: CustomFields = {
...testCustomFields,
field3: { label: 'Field 3', colour: 'green', type: 'image' },
};
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
});
@@ -5,11 +5,17 @@ import type { ParamField } from './types';
export const makeOptionsFromCustomFields = ( export const makeOptionsFromCustomFields = (
customFields: CustomFields, customFields: CustomFields,
additionalOptions: Record<string, string> = {}, additionalOptions: Record<string, string> = {},
filterImageType = true,
) => { ) => {
return Object.entries(customFields).reduce((options, [key, value]) => { const options = structuredClone(additionalOptions);
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[`custom-${key}`] = `Custom: ${value.label}`; options[`custom-${key}`] = `Custom: ${value.label}`;
return options; }
}, additionalOptions); return options;
}; };
export const getTimeOption = (timeFormat: string): ParamField => { export const getTimeOption = (timeFormat: string): ParamField => {
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { OntimeReport } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT } from '../api/constants';
import { fetchReport } from '../api/report';
export default function useReport() {
const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT,
queryFn: fetchReport,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
networkMode: 'always',
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? {}, refetch };
}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types'; import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
@@ -11,6 +11,9 @@ import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher // revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 }; const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
/**
* Normalised rundown data
*/
export default function useRundown() { export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({ const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
@@ -24,6 +27,10 @@ export default function useRundown() {
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
/**
* Provides access to a flat rundown
* built from the order and rundown fields
*/
export function useFlatRundown() { export function useFlatRundown() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { data: projectData } = useProjectData(); const { data: projectData } = useProjectData();
@@ -52,3 +59,15 @@ export function useFlatRundown() {
return { data: flatRunDown, status }; return { data: flatRunDown, status };
} }
/**
* Provides access to a partial rundown based on a filter callback
*/
export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) {
const { data, status } = useFlatRundown();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
return { data: filteredData, status };
}
@@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useShallow } from 'zustand/shallow';
import { useClientStore } from '../stores/clientStore'; import { useClientStore } from '../stores/clientStore';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
@@ -9,10 +10,12 @@ import { useIsOnline } from './useSocket';
export const useClientPath = () => { export const useClientPath = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname, search } = useLocation(); const { pathname, search } = useLocation();
const { redirect, setRedirect } = useClientStore((store) => ({ const { redirect, setRedirect } = useClientStore(
redirect: store.redirect, useShallow((store) => ({
setRedirect: store.setRedirect, redirect: store.redirect,
})); setRedirect: store.setRedirect,
})),
);
const isOnline = useIsOnline(); const isOnline = useIsOnline();
// notify of client path changes // notify of client path changes
@@ -55,6 +55,17 @@ export const useEventAction = () => {
defaultEndAction, defaultEndAction,
} = useEditorSettings(); } = useEditorSettings();
const getEventById = useCallback(
(eventId: string) => {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.rundown) {
return;
}
return cachedRundown.rundown[eventId];
},
[queryClient],
);
/** /**
* Calls mutation to add new event * Calls mutation to add new event
* @private * @private
@@ -615,6 +626,7 @@ export const useEventAction = () => {
batchUpdateEvents, batchUpdateEvents,
deleteEvent, deleteEvent,
deleteAllEvents, deleteAllEvents,
getEventById,
reorderEvent, reorderEvent,
swapEvents, swapEvents,
updateEvent, updateEvent,
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../utils/throttle';
export const useFadeOutOnInactivity = () => { export const useFadeOutOnInactivity = () => {
const [isMouseMoved, setIsMouseMoved] = useState(false); const [isMouseMoved, setIsMouseMoved] = useState(false);
@@ -1,27 +0,0 @@
import { useEffect, useRef } from 'react';
/**
* @description utility hook to around setInterval
* @param callback
* @param delay
*/
export const useInterval = (callback, delay) => {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
/**
* @description function to be called
*/
function tick() {
savedCallback.current();
}
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
};
+119 -159
View File
@@ -3,73 +3,65 @@ import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ont
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
const createSelector =
<T>(selector: (state: RuntimeStore) => T) =>
() =>
useRuntimeStore(selector);
export const setClientRemote = { export const setClientRemote = {
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload), setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload), setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload), setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
}; };
export const useRundownEditor = () => { export const useRundownEditor = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => ({ playback: state.timer.playback,
playback: state.timer.playback, selectedEventId: state.eventNow?.id ?? null,
selectedEventId: state.eventNow?.id ?? null, nextEventId: state.eventNext?.id ?? null,
nextEventId: state.eventNext?.id ?? null, }));
});
return useRuntimeStore(featureSelector); export const useOperator = createSelector((state: RuntimeStore) => ({
}; playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
}));
export const useOperator = () => { export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => ({ blackout: state.message.timer.blackout,
playback: state.timer.playback, blink: state.message.timer.blink,
selectedEventId: state.eventNow?.id ?? null, secondarySource: state.message.timer.secondarySource,
}); }));
return useRuntimeStore(featureSelector); export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
}; text: state.message.timer.text,
visible: state.message.timer.visible,
}));
export const useTimerViewControl = () => { export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => ({ text: state.message.external,
blackout: state.message.timer.blackout, visible: state.message.timer.secondarySource === 'external',
blink: state.message.timer.blink, }));
secondarySource: state.message.timer.secondarySource,
});
return useRuntimeStore(featureSelector); export const useTimerSchedule = createSelector((state: RuntimeStore) => ({
}; startedAt: state.timer.startedAt,
expectedFinish: state.timer.expectedFinish,
phase: state.timer.phase,
playback: state.timer.playback,
}));
export const useTimerMessageInput = () => { export const useTimerCurrent = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => ({ current: state.timer.current,
text: state.message.timer.text, }));
visible: state.message.timer.visible,
});
return useRuntimeStore(featureSelector); export const useMessagePreview = createSelector((state: RuntimeStore) => ({
}; blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
export const useExternalMessageInput = () => { phase: state.timer.phase,
const featureSelector = (state: RuntimeStore) => ({ showAuxTimer: state.message.timer.secondarySource === 'aux',
text: state.message.external, showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
visible: state.message.timer.secondarySource === 'external', showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
}); timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
return useRuntimeStore(featureSelector); }));
};
export const useMessagePreview = () => {
const featureSelector = (state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
showAuxTimer: state.message.timer.secondarySource === 'aux',
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
});
return useRuntimeStore(featureSelector);
};
export const setMessage = { export const setMessage = {
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }), timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
@@ -81,16 +73,12 @@ export const setMessage = {
socketSendJson('message', { timer: { secondarySource: payload } }), socketSendJson('message', { timer: { secondarySource: payload } }),
}; };
export const usePlaybackControl = () => { export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => ({ playback: state.timer.playback,
playback: state.timer.playback, selectedEventIndex: state.runtime.selectedEventIndex,
selectedEventIndex: state.runtime.selectedEventIndex, numEvents: state.runtime.numEvents,
numEvents: state.runtime.numEvents, timerPhase: state.timer.phase,
timerPhase: state.timer.phase, }));
});
return useRuntimeStore(featureSelector);
};
export const setPlayback = { export const setPlayback = {
start: () => socketSendJson('start'), start: () => socketSendJson('start'),
@@ -114,32 +102,20 @@ export const setPlayback = {
}, },
}; };
export const useInfoPanel = () => { export const useInfoPanel = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => ({ eventNow: state.eventNow,
eventNow: state.eventNow, eventNext: state.eventNext,
eventNext: state.eventNext, playback: state.timer.playback,
playback: state.timer.playback, selectedEventIndex: state.runtime.selectedEventIndex,
selectedEventIndex: state.runtime.selectedEventIndex, numEvents: state.runtime.numEvents,
numEvents: state.runtime.numEvents, }));
});
return useRuntimeStore(featureSelector); export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current);
};
export const useAuxTimerTime = () => { export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => state.auxtimer1.current; playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
return useRuntimeStore(featureSelector); }));
};
export const useAuxTimerControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
});
return useRuntimeStore(featureSelector);
};
export const setAuxTimer = { export const setAuxTimer = {
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }), start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
@@ -149,20 +125,22 @@ export const setAuxTimer = {
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }), setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
}; };
export const useSelectedEventId = () => { export const useTimerSpeed = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => ({ speed: state.timer.speed,
selectedEventId: state.eventNow?.id ?? null, }));
});
return useRuntimeStore(featureSelector); export const setTimerSpeed = {
getSpeed: () => socketSendJson('get-speed'),
setSpeed: (speed: number) => socketSendJson('set-speed', speed),
}; };
export const useCurrentBlockId = () => { export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => ({ selectedEventId: state.eventNow?.id ?? null,
currentBlockId: state.currentBlock.block?.id ?? null, }));
});
return useRuntimeStore(featureSelector); export const useCurrentBlockId = createSelector((state: RuntimeStore) => ({
}; currentBlockId: state.currentBlock.block?.id ?? null,
}));
export const setEventPlayback = { export const setEventPlayback = {
loadEvent: (id: string) => socketSendJson('load', { id }), loadEvent: (id: string) => socketSendJson('load', { id }),
@@ -171,81 +149,63 @@ export const setEventPlayback = {
pause: () => socketSendJson('pause'), pause: () => socketSendJson('pause'),
}; };
export const useTimer = () => { export const useTimer = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => ({ ...state.timer,
...state.timer, }));
});
return useRuntimeStore(featureSelector); export const useClock = createSelector((state: RuntimeStore) => ({
}; clock: state.clock,
}));
export const useClock = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
});
return useRuntimeStore(featureSelector);
};
/** Used by the progress bar components */ /** Used by the progress bar components */
export const useProgressData = () => { export const useProgressData = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => ({ current: state.timer.current,
current: state.timer.current, duration: state.timer.duration,
duration: state.timer.duration, timeWarning: state.eventNow?.timeWarning ?? null,
timeWarning: state.eventNow?.timeWarning ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
timeDanger: state.eventNow?.timeDanger ?? null, }));
});
return useRuntimeStore(featureSelector);
};
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName); export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
export const useRuntimeOverview = () => { export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => ({ plannedStart: state.runtime.plannedStart,
plannedStart: state.runtime.plannedStart, actualStart: state.runtime.actualStart,
actualStart: state.runtime.actualStart, plannedEnd: state.runtime.plannedEnd,
plannedEnd: state.runtime.plannedEnd, expectedEnd: state.runtime.expectedEnd,
expectedEnd: state.runtime.expectedEnd, }));
});
return useRuntimeStore(featureSelector); export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
}; playback: state.timer.playback,
clock: state.clock,
export const useRuntimePlaybackOverview = () => { numEvents: state.runtime.numEvents,
const featureSelector = (state: RuntimeStore) => ({ selectedEventIndex: state.runtime.selectedEventIndex,
playback: state.timer.playback, offset: state.runtime.offset,
clock: state.clock,
numEvents: state.runtime.numEvents, currentBlock: state.currentBlock,
selectedEventIndex: state.runtime.selectedEventIndex, }));
offset: state.runtime.offset,
currentBlock: state.currentBlock, export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
}); clock: state.clock,
offset: state.runtime.offset,
}));
return useRuntimeStore(featureSelector); export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
}; offset: state.runtime.offset,
}));
export const useTimelineStatus = () => { export const usePing = createSelector((state: RuntimeStore) => ({
const featureSelector = (state: RuntimeStore) => ({ ping: state.ping,
clock: state.clock, }));
offset: state.runtime.offset,
});
return useRuntimeStore(featureSelector);
};
export const usePing = () => {
const featureSelector = (state: RuntimeStore) => ({
ping: state.ping,
});
return useRuntimeStore(featureSelector);
};
/** convert ping into a derived value which changes less often */ /** convert ping into a derived value which changes less often */
export const useIsOnline = () => { export const useIsOnline = createSelector((state: RuntimeStore) => ({
isOnline: state.ping > 0,
}));
export const usePlayback = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
isOnline: state.ping > 0, playback: state.timer.playback,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
@@ -0,0 +1,13 @@
import { makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => {
it('joins an array of arrays with commas and newlines', () => {
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
expect(makeCSVFromArrayOfArrays(testdata)).toMatchInlineSnapshot(`
"field
after newline,after comma
,after empty
"
`);
});
});
+10
View File
@@ -0,0 +1,10 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
/**
* @description Converts an array of arrays to a CSV file
* @param {string[][]} arrayOfArrays
* @return {string}
*/
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays);
}
@@ -6,7 +6,6 @@ import { baseURI, serverURL } from '../../externals';
* Open an external URLs: specifically for a electron / browser case * Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window * If electron: ask main process to call a new browser window
* If browser: open in new tab * If browser: open in new tab
* @param url
*/ */
export function openLink(url: string) { export function openLink(url: string) {
if (window.process?.type === 'renderer') { if (window.process?.type === 'renderer') {
+27 -21
View File
@@ -1,7 +1,7 @@
import { Log, RundownCached, RuntimeStore } from 'ontime-types'; import { Log, RundownCached, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals'; import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants'; import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
import { invalidateAllCaches } from '../api/utils'; import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { import {
@@ -34,12 +34,16 @@ export const connectSocket = () => {
hasConnected = true; hasConnected = true;
reconnectAttempts = 0; reconnectAttempts = 0;
socketSendJson('set-client-patch', {
type: 'ontime',
origin: window.location.origin,
path: window.location.pathname + window.location.search,
});
setOnlineStatus(true);
if (preferredClientName) { if (preferredClientName) {
socketSendJson('set-client-name', preferredClientName); socketSendJson('set-client-name', preferredClientName);
} }
socketSendJson('set-client-type', 'ontime');
setOnlineStatus(true);
}; };
websocket.onclose = () => { websocket.onclose = () => {
@@ -78,16 +82,14 @@ export const connectSocket = () => {
updateDevTools({ ping: offset }); updateDevTools({ ping: offset });
break; break;
} }
case 'client-id': { case 'client': {
if (typeof payload === 'string') { if (typeof payload === 'object' || payload !== null) {
setClientId(payload); if (payload.clientId && payload.clientName) {
} setClientId(payload.clientId);
break; if (!preferredClientName) {
} setClientName(payload.clientName);
}
case 'client-name': { }
if (typeof payload === 'string') {
setClientName(payload);
} }
break; break;
} }
@@ -196,19 +198,23 @@ export const connectSocket = () => {
} }
case 'ontime-refetch': { case 'ontime-refetch': {
// the refetch message signals that the rundown has changed in the server side // the refetch message signals that the rundown has changed in the server side
const { revision, reload } = payload; const { reload, target } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
if (reload) { if (reload) {
invalidateAllCaches(); invalidateAllCaches();
} else if (revision > currentRevision) { } else if (target === 'RUNDOWN') {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); const { revision } = payload;
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
}
} else if (target === 'REPORT') {
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
} }
break; break;
} }
case 'ontime-flush': { case 'ontime-flush': {
flushBatchUpdates() flushBatchUpdates();
break; break;
} }
} }
@@ -183,7 +183,7 @@ $inner-padding: 1rem;
color: $muted-gray; color: $muted-gray;
td { td {
padding-block: 1rem; padding-block: 5rem;
} }
button { button {
@@ -67,9 +67,17 @@ export function TableEmpty({ label, handleClick }: { label?: string; handleClick
<tr className={style.empty}> <tr className={style.empty}>
<td colSpan={99}> <td colSpan={99}>
<div>{label ?? 'No data yet'}</div> <div>{label ?? 'No data yet'}</div>
<Button onClick={handleClick} isDisabled={!handleClick} variant='ontime-filled' rightIcon={<IoAdd />} size='sm'> {handleClick && (
New <Button
</Button> onClick={handleClick}
isDisabled={!handleClick}
variant='ontime-filled'
rightIcon={<IoAdd />}
size='sm'
>
New
</Button>
)}
</td> </td>
</tr> </tr>
); );
@@ -1,4 +1,4 @@
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { import {
buyMeACoffeeUrl, buyMeACoffeeUrl,
discordUrl, discordUrl,
@@ -1,4 +1,4 @@
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion'; import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion } from '../../../../externals'; import { appVersion } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -27,7 +27,8 @@
.titleSection, .titleSection,
.filterSection, .filterSection,
.oscSection, .oscSection,
.httpSection { .httpSection,
.actionSection {
display: grid; display: grid;
grid-gap: 0.5rem; grid-gap: 0.5rem;
@@ -40,8 +41,10 @@
.ruleSection, .ruleSection,
.filterSection, .filterSection,
.oscSection, .oscSection,
.httpSection { .httpSection,
label, div { .actionSection {
label,
div {
// we use the div as non-interactive placeholder for button cells // we use the div as non-interactive placeholder for button cells
// it needs to match the size of the label element // it needs to match the size of the label element
font-size: calc(1rem - 3px); font-size: calc(1rem - 3px);
@@ -51,7 +54,6 @@
} }
} }
.titleSection { .titleSection {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -68,6 +70,14 @@
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
} }
.actionSection {
grid-template-columns: auto 1fr 1fr auto;
.test {
grid-column: -1;
}
}
.outputCard { .outputCard {
border-left: 0.25rem solid $gray-1200; border-left: 0.25rem solid $gray-1200;
padding-left: 0.5rem; padding-left: 0.5rem;
@@ -1,23 +1,23 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react';
Alert,
AlertDescription,
AlertIcon,
Button,
IconButton,
Input,
Radio,
RadioGroup,
Select,
} 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 { Automation, AutomationDTO, HTTPOutput, isHTTPOutput, isOSCOutput, OSCOutput } from 'ontime-types'; import {
Automation,
AutomationDTO,
HTTPOutput,
isHTTPOutput,
isOntimeAction,
isOSCOutput,
OntimeAction,
OSCOutput,
} from 'ontime-types';
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation'; import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../common/hooks-query/useCustomFields';
@@ -27,6 +27,7 @@ import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput'; import TemplateInput from './template-input/TemplateInput';
import { isAutomation, makeFieldList } from './automationUtils'; import { isAutomation, makeFieldList } from './automationUtils';
import OntimeActionForm from './OntimeActionForm';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
@@ -51,6 +52,7 @@ export default function AutomationForm(props: AutomationFormProps) {
register, register,
setError, setError,
setFocus, setFocus,
setValue,
formState: { errors, isSubmitting, isDirty, isValid }, formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<AutomationDTO>({ } = useForm<AutomationDTO>({
mode: 'onChange', mode: 'onChange',
@@ -101,6 +103,11 @@ export default function AutomationForm(props: AutomationFormProps) {
appendOutput({ type: 'http', url: '' }); appendOutput({ type: 'http', url: '' });
}; };
const handleAddnewOntimeAction = () => {
// @ts-expect-error -- we dont want to choose an action
appendOutput({ type: 'ontime', action: undefined });
};
const handleTestOSCOutput = async (index: number) => { const handleTestOSCOutput = async (index: number) => {
try { try {
const values = getValues(`outputs.${index}`) as OSCOutput; const values = getValues(`outputs.${index}`) as OSCOutput;
@@ -134,6 +141,19 @@ export default function AutomationForm(props: AutomationFormProps) {
} }
}; };
const handleTestOntimeAction = async (index: number) => {
try {
const values = getValues(`outputs.${index}`) as OntimeAction;
// NOTE: there is no meaningful validation to do here, we let the server deal with the data
await testOutput({
...values,
type: 'ontime',
});
} catch (_error) {
/** we dont handle errors here */
}
};
const onSubmit = async (values: AutomationDTO) => { const onSubmit = async (values: AutomationDTO) => {
if (isAutomation(automation)) { if (isAutomation(automation)) {
await handleEdit(automation.id, { id: automation.id, ...values }); await handleEdit(automation.id, { id: automation.id, ...values });
@@ -190,7 +210,7 @@ export default function AutomationForm(props: AutomationFormProps) {
</div> </div>
<div className={style.innerSection}> <div className={style.innerSection}>
<h3>Filters</h3> <h3>Filters (optional)</h3>
<div className={style.ruleSection}> <div className={style.ruleSection}>
<label> <label>
Trigger outputs if Trigger outputs if
@@ -205,74 +225,80 @@ export default function AutomationForm(props: AutomationFormProps) {
)} )}
/> />
</label> </label>
{fieldFilters.map((field, index) => ( {fieldFilters.map((field, index) => {
<div key={field.id} className={style.filterSection}> const key = `filters.${index}.field.${field.id}`;
<label> return (
Runtime data source <div key={key} className={style.filterSection}>
<Select <label>
{...register(`filters.${index}.field`, { required: { value: true, message: 'Required field' } })} Runtime data source
size='sm' <Select
variant='ontime' {...register(`filters.${index}.field`, { required: { value: true, message: 'Required field' } })}
> size='sm'
<option selected hidden disabled value=''> variant='ontime'
Event field >
</option> <option selected hidden disabled value=''>
{fieldList.map(({ value, label }) => ( Event field
<option key={value} value={value}>
{label}
</option> </option>
))} {fieldList.map(({ value, label }, localIndex) => {
</Select> const key = `filters.${index}.field.${localIndex}`;
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error> return (
</label> <option key={key} value={value}>
<label> {label}
Matching condition </option>
<Select );
{...register(`filters.${index}.operator`, { required: { value: true, message: 'Required field' } })} })}
size='sm' </Select>
variant='ontime' <Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
> </label>
<option selected hidden disabled value=''> <label>
Operator Matching condition
</option> <Select
<option value='equals'>equals</option> {...register(`filters.${index}.operator`, { required: { value: true, message: 'Required field' } })}
<option value='not_equals'>not equals</option> size='sm'
<option value='contains'>contains</option> variant='ontime'
{/* >
<option selected hidden disabled value=''>
Operator
</option>
<option value='equals'>equals</option>
<option value='not_equals'>not equals</option>
<option value='contains'>contains</option>
{/*
We dont currently offer a data source where these operators would make sense We dont currently offer a data source where these operators would make sense
<option value='greater_than'>greater than</option> <option value='greater_than'>greater than</option>
<option value='less_than'>less than</option> <option value='less_than'>less than</option>
*/} */}
</Select> </Select>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error> <Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label> </label>
<label> <label>
Value to match Value to match
<Input <Input
{...register(`filters.${index}.value`)} {...register(`filters.${index}.value`)}
variant='ontime-filled' variant='ontime-filled'
size='sm'
placeholder='<empty / no value>'
autoComplete='off'
/>
</label>
<div>
<span>&nbsp;</span>
<div>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm' size='sm'
color='#FA5656' // $red-500 placeholder='<empty / no value>'
onClick={() => removeFilter(index)} autoComplete='off'
isDisabled={false}
isLoading={false}
/> />
</label>
<div>
<span>&nbsp;</span>
<div>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
color='#FA5656' // $red-500
onClick={() => removeFilter(index)}
isDisabled={false}
isLoading={false}
/>
</div>
</div> </div>
</div> </div>
</div> );
))} })}
<div> <div>
<Button <Button
variant='ontime-subtle' variant='ontime-subtle'
@@ -291,13 +317,10 @@ export default function AutomationForm(props: AutomationFormProps) {
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Outputs</h3> <h3>Outputs</h3>
<Alert status='info' variant='ontime-on-dark-info'> <Info>
<AlertIcon /> Automation outputs can be used to send data from Ontime to external software.
<AlertDescription> <ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
Automation outputs can be used to send data from Ontime to external software. </Info>
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
</AlertDescription>
</Alert>
{fieldOutputs.map((output, index) => { {fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) { if (isOSCOutput(output)) {
@@ -357,13 +380,13 @@ export default function AutomationForm(props: AutomationFormProps) {
<Panel.Error>{rowErrors?.address?.message}</Panel.Error> <Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label> </label>
<label> <label>
Parameters Arguments
<TemplateInput <TemplateInput
{...register(`outputs.${index}.args`)} {...register(`outputs.${index}.args`)}
value={output.args}
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
placeholder='1' placeholder='1'
autoComplete='off'
/> />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error> <Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label> </label>
@@ -380,8 +403,6 @@ export default function AutomationForm(props: AutomationFormProps) {
size='sm' size='sm'
onClick={() => removeOutput(index)} onClick={() => removeOutput(index)}
color='#FA5656' // $red-500 color='#FA5656' // $red-500
isDisabled={false}
isLoading={false}
/> />
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
@@ -429,8 +450,6 @@ export default function AutomationForm(props: AutomationFormProps) {
size='sm' size='sm'
onClick={() => removeOutput(index)} onClick={() => removeOutput(index)}
color='#FA5656' // $red-500 color='#FA5656' // $red-500
isDisabled={false}
isLoading={false}
/> />
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
@@ -438,30 +457,59 @@ export default function AutomationForm(props: AutomationFormProps) {
</div> </div>
); );
} }
if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as
| {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag>
<OntimeActionForm
value={output.action}
index={index}
register={register}
rowErrors={rowErrors}
setValue={setValue}
>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
/>
</Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
// there should be no other output types // there should be no other output types
return null; return null;
})} })}
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner'>
<Button <Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewOSCOutput}>
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
onClick={handleAddNewOSCOutput}
isDisabled={false}
isLoading={false}
>
OSC OSC
</Button> </Button>
<Button <Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewHTTPOutput}>
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
onClick={handleAddNewHTTPOutput}
isDisabled={false}
isLoading={false}
>
HTTP HTTP
</Button> </Button>
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddnewOntimeAction}>
Ontime action
</Button>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
@@ -1,11 +1,13 @@
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react'; import { Button, Input, Switch } from '@chakra-ui/react';
import { editAutomationSettings } from '../../../../common/api/automation'; import { editAutomationSettings } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex'; import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/'; const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/';
@@ -75,14 +77,13 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'> <Info>
<AlertIcon /> <p>Control Ontime and share its data with external systems in your workflow.</p>
<AlertDescription> <p>- Automations allow Ontime to send its data on lifecycle triggers.</p>
Control Ontime and share its data with external systems in your workflow. <br /> <p>- OSC Input tells Ontime to listen to messages on the specific port.</p>
- Automations allow Ontime to send its data on lifecycle triggers. <br />- OSC Input tells Ontime to listen <br />
to messages on the specific port. <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink> <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</AlertDescription> </Info>
</Alert>
</Panel.Section> </Panel.Section>
<Panel.Section <Panel.Section
@@ -112,7 +113,9 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
</Panel.ListGroup> </Panel.ListGroup>
<Panel.Title>OSC Input</Panel.Title> <Panel.Title>OSC Input</Panel.Title>
<Panel.ListGroup> <Panel.ListGroup>
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='OSC input' title='OSC input'
@@ -0,0 +1,110 @@
import { PropsWithChildren, useState } from 'react';
import { UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { Input, Select } from '@chakra-ui/react';
import { AutomationDTO, OntimeAction } from 'ontime-types';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './AutomationForm.module.scss';
interface OntimeActionFormProps {
index: number;
register: UseFormRegister<AutomationDTO>;
rowErrors?: {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
};
value: OntimeAction['action'];
setValue: UseFormSetValue<AutomationDTO>;
}
export default function OntimeActionForm(props: PropsWithChildren<OntimeActionFormProps>) {
const { index, register, setValue, rowErrors, value, children } = props;
const [selectedAction, setSelectedAction] = useState<OntimeAction['action']>(value || 'aux-start');
const updateSelectedAction = (value: string) => {
setSelectedAction(value as OntimeAction['action']);
setValue(`outputs.${index}.action`, value as OntimeAction['action']);
};
return (
<div className={cx([style.actionSection, selectedAction && style[selectedAction]])}>
<input type='hidden' {...register(`outputs.${index}.action`)} value={selectedAction} />
<label>
Action
<Select
variant='ontime'
size='sm'
value={selectedAction}
onChange={(event) => updateSelectedAction(event.target.value)}
>
<option value='aux-start'>Auxiliary timer: start</option>
<option value='aux-pause'>Auxiliary timer: pause</option>
<option value='aux-stop'>Auxiliary timer: stop</option>
<option value='aux-set'>Auxiliary timer: set</option>
<option value='message-set'>Timer: timer message</option>
<option value='message-secondary'>Timer: timer secondary</option>
</Select>
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
</label>
{selectedAction === 'aux-set' && (
<label>
New time
<Input
{...register(`outputs.${index}.time`, {
required: { value: true, message: 'Required field' },
})}
variant='ontime-filled'
size='sm'
placeholder='eg: 10m5s'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.time?.message}</Panel.Error>
</label>
)}
{selectedAction === 'message-set' && (
<>
<label>
Text (leave empty for no change)
<Input
{...register(`outputs.${index}.text`)}
variant='ontime-filled'
size='sm'
placeholder='eg: Timer is finished'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
Visibility
<Select variant='ontime' size='sm' {...register(`outputs.${index}.visible`)}>
<option value=''>Untouched</option>
<option value='true'>Show</option>
<option value='false'>Hide</option>
</Select>
<Panel.Error>{rowErrors?.visible?.message}</Panel.Error>
</label>
</>
)}
{selectedAction === 'message-secondary' && (
<label>
Timer secondary source
<Select variant='ontime' size='sm' {...register(`outputs.${index}.secondarySource`)}>
<option value='aux'>Auxiliary timer</option>
<option value='external'>External</option>
<option value='null'>None</option>
</Select>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
)}
<div className={style.test}>{children}</div>
</div>
);
}
@@ -82,7 +82,7 @@ export default function TriggersList(props: TriggersListProps) {
<tbody> <tbody>
{!showForm && triggers.length === 0 && ( {!showForm && triggers.length === 0 && (
<Panel.TableEmpty <Panel.TableEmpty
label='Create an automation before to attach triggers to' label='Create an automation to attach triggers to'
handleClick={canAdd ? () => setShowForm(true) : undefined} handleClick={canAdd ? () => setShowForm(true) : undefined}
/> />
)} )}
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { forwardRef, useMemo, useState } from 'react';
import { type InputProps, Input } from '@chakra-ui/react'; import { type InputProps, Input } from '@chakra-ui/react';
import { useClickOutside } from '@mantine/hooks'; import { mergeRefs, useClickOutside } from '@mantine/hooks';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
@@ -10,10 +10,10 @@ import style from './TemplateInput.module.scss';
interface TemplateInputProps extends InputProps {} interface TemplateInputProps extends InputProps {}
export default function TemplateInput(props: TemplateInputProps) { const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
const { value, onChange, ...rest } = props; const { value, onChange, ...rest } = props;
const { data } = useCustomFields(); const { data } = useCustomFields();
const ref = useClickOutside(() => setShowSuggestions(false)); const localRef = useClickOutside(() => setShowSuggestions(false));
const autocompleteList = useMemo(() => { const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data); return makeAutoCompleteList(data);
@@ -31,15 +31,13 @@ export default function TemplateInput(props: TemplateInputProps) {
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value); setInputValue(event.target.value);
if (event.target.value.endsWith('{{')) { if (event.target.value.endsWith('{')) {
setShowSuggestions(true); setShowSuggestions(true);
setSuggestions(updateSuggestions(event.target.value));
} else if (event.target.value === '' || event.target.value.endsWith('}}')) { } else if (event.target.value === '' || event.target.value.endsWith('}}')) {
setShowSuggestions(false); setShowSuggestions(false);
} } else if (showSuggestions) {
setSuggestions(updateSuggestions(event.target.value));
if (showSuggestions) {
const suggestions = updateSuggestions(event.target.value);
setSuggestions(suggestions);
} }
onChange?.(event); onChange?.(event);
@@ -54,8 +52,8 @@ export default function TemplateInput(props: TemplateInputProps) {
}; };
return ( return (
<div className={style.wrapper} ref={ref}> <div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
<Input {...rest} value={inputValue} onChange={handleInputChange} autoComplete='off' autoCorrect='off' /> <Input value={inputValue} {...rest} onChange={handleInputChange} autoComplete='off' autoCorrect='off' />
{showSuggestions && suggestions.length > 0 && ( {showSuggestions && suggestions.length > 0 && (
<ul className={style.suggestions}> <ul className={style.suggestions}>
{suggestions.map((suggestion) => ( {suggestions.map((suggestion) => (
@@ -67,4 +65,6 @@ export default function TemplateInput(props: TemplateInputProps) {
)} )}
</div> </div>
); );
} });
export default TemplateInput;
@@ -6,10 +6,8 @@ describe('matchRemaining()', () => {
expect(matchRemaining('{{{hum', '{{human}}')).toBe('an}}'); expect(matchRemaining('{{{hum', '{{human}}')).toBe('an}}');
expect(matchRemaining('send {', '{{human}}')).toBe('{human}}'); expect(matchRemaining('send {', '{{human}}')).toBe('{human}}');
// we should be able to match the following expect(matchRemaining('{', '{{human}}')).toBe('{human}}');
// however, the current implementation only needs to deal with strings that start with {{ expect(matchRemaining('{{', '{{human}}')).toBe('human}}');
// expect(matchRemaining('{', '{{human}}')).toBe('{human}}');
// expect(matchRemaining('{{', '{{human}}')).toBe('human}}');
}); });
it('should return an empty string if there are no matches or if it is complete', () => { it('should return an empty string if there are no matches or if it is complete', () => {
@@ -83,6 +83,16 @@ export function matchRemaining(a: string, b: string) {
return ''; return '';
} }
// naive match assuming that a template will start with {{
if (a.endsWith('{{') && b.startsWith('{{')) {
return b.substring(2);
}
// naive match assuming that a template will start with {
if (a.endsWith('{') && b.startsWith('{{')) {
return b.substring(1);
}
for (let i = 0; i < b.length; i++) { for (let i = 0; i < b.length; i++) {
const searchString = b.substring(0, i + 1); const searchString = b.substring(0, i + 1);
if (a.endsWith(searchString)) { if (a.endsWith(searchString)) {
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Badge, Button, useDisclosure } from '@chakra-ui/react'; import { Badge, Button, useDisclosure } from '@chakra-ui/react';
import { Client } from 'ontime-types';
import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal'; import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal'; import { RenameClientModal } from '../../../../common/components/client-modal/RenameClientModal';
@@ -31,15 +32,16 @@ export default function ClientList() {
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime'); const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime'); const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
const targetClient = clients[targetId]; const targetClient: Client | undefined = clients[targetId];
return ( return (
<> <>
{isOpenRedirect && ( {isOpenRedirect && targetClient !== undefined && (
<RedirectClientModal <RedirectClientModal
id={targetId} id={targetId}
name={targetClient?.name} name={targetClient.name}
path={targetClient?.path} origin={targetClient.origin}
currentPath={targetClient.path}
isOpen={isOpenRedirect} isOpen={isOpenRedirect}
onClose={onCloseRedirect} onClose={onCloseRedirect}
/> />
@@ -52,7 +54,7 @@ export default function ClientList() {
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<td className={style.halfWidth}>Client Name (Connection ID)</td> <td className={style.halfWidth}>Client Name</td>
<td className={style.fullWidth}>Path</td> <td className={style.fullWidth}>Path</td>
<td /> <td />
</tr> </tr>
@@ -64,9 +66,6 @@ export default function ClientList() {
return ( return (
<tr key={key}> <tr key={key}>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
<Badge variant='outline' size='xs'>
{key}
</Badge>
{isCurrent && ( {isCurrent && (
<Badge variant='outline' colorScheme='yellow' size='xs'> <Badge variant='outline' colorScheme='yellow' size='xs'>
self self
@@ -119,7 +118,7 @@ export default function ClientList() {
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<td className={style.halfWidthNoWrap}>Client Name (Connection ID)</td> <td className={style.halfWidthNoWrap}>Client Name</td>
<td className={style.halfWidthNoWrap}>Client type</td> <td className={style.halfWidthNoWrap}>Client type</td>
</tr> </tr>
</thead> </thead>
@@ -129,12 +128,7 @@ export default function ClientList() {
return ( return (
<tr key={key}> <tr key={key}>
<Panel.InlineElements relation='inner' as='td'> <td>{name}</td>
<Badge variant='outline' size='sx'>
{key}
</Badge>
{name}
</Panel.InlineElements>
<td>{type}</td> <td>{type}</td>
</tr> </tr>
); );
@@ -3,11 +3,13 @@ import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import CustomFields from './custom-fields/CustomFields'; import CustomFields from './custom-fields/CustomFields';
import ReportSettings from './ReportSettings';
import UrlPresetsForm from './UrlPresetsForm'; import UrlPresetsForm from './UrlPresetsForm';
export default function FeatureSettingsPanel({ location }: PanelBaseProps) { export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
const customFieldsRef = useScrollIntoView<HTMLDivElement>('custom', location); const customFieldsRef = useScrollIntoView<HTMLDivElement>('custom', location);
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location); const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
return ( return (
<> <>
@@ -19,6 +21,10 @@ export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
<div ref={urlPresetsRef}> <div ref={urlPresetsRef}>
<UrlPresetsForm /> <UrlPresetsForm />
</div> </div>
<div ref={reportRef}>
<ReportSettings />
</div>
</> </>
); );
} }
@@ -0,0 +1,7 @@
th.over {
color: $ontime-delay-text;
}
th.under {
color: $playback-ahead;
}
@@ -0,0 +1,113 @@
import { useMemo } from 'react';
import { Button } from '@chakra-ui/react';
import { IoTrashBin } from '@react-icons/all-files/io5/IoTrashBin';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils';
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
import style from './ReportSettings.module.scss';
export default function ReportSettings() {
const { data: reportData } = useReport();
const { data } = useRundown();
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
if (!combinedReport) {
return;
}
const csv = makeReportCSV(combinedReport);
const blob = createBlob(csv, 'text/csv;charset=utf-8;');
downloadBlob(blob, 'ontime-report.csv');
};
const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.rundown, data.order);
}, [reportData, data.rundown, data.order]);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Report</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Panel.Title>
Manage report
<Panel.InlineElements>
<Button
variant='ontime-subtle'
leftIcon={<IoTrashBin />}
size='sm'
onClick={() => downloadCSV(combinedReport)}
isDisabled={combinedReport.length === 0}
>
Export CSV
</Button>
<Button
variant='ontime-subtle'
leftIcon={<IoTrashBin />}
size='sm'
color='#FA5656'
onClick={clearReport}
isDisabled={combinedReport.length === 0}
>
Clear All
</Button>
</Panel.InlineElements>
</Panel.Title>
</Panel.Section>
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>#</th>
<th>Cue</th>
<th>Title</th>
<th>Scheduled Start</th>
<th>Actual Start</th>
<th>Scheduled End</th>
<th>Actual End</th>
</tr>
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty label='Reports are generated when running through the show.' />
)}
{combinedReport.map((entry) => {
const start = (() => {
if (entry.actualStart === null) return null;
if (entry.actualStart <= entry.scheduledStart) return 'under';
return 'over';
})();
const end = (() => {
if (entry.actualEnd === null) return null;
if (entry.actualEnd <= entry.scheduledEnd) return 'under';
return 'over';
})();
return (
<tr key={entry.index}>
<th>{entry.index}</th>
<th>{entry.cue}</th>
<th>{entry.title}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, Button, IconButton, Input, Switch } from '@chakra-ui/react'; import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline'; import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
@@ -9,7 +9,8 @@ import { URLPreset } from 'ontime-types';
import { postUrlPresets } from '../../../../common/api/urlPresets'; import { postUrlPresets } from '../../../../common/api/urlPresets';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn'; import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets'; import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { handleLinks } from '../../../../common/utils/linkUtils'; import { handleLinks } from '../../../../common/utils/linkUtils';
@@ -108,32 +109,29 @@ export default function UrlPresetsForm() {
</Panel.InlineElements> </Panel.InlineElements>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Alert status='info' variant='ontime-on-dark-info'> <Info>
<AlertIcon /> URL presets are user defined aliases to Ontime URLs
<AlertDescription> <br />
URL presets are user defined aliases to Ontime URLs <br />
<br /> <b>Preset Name</b> <br />
<br /> The alias for the URL. This will be the URL you will be calling. eg: <br />
<b>Preset Name</b> <br /> <Panel.BlockQuote>
The alias for the URL. This will be the URL you will be calling. eg: <br /> Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
<Panel.BlockQuote> <Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '} </Panel.BlockQuote>
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight> <br />
</Panel.BlockQuote> <b>URL Segment</b> <br />
<br /> The corresponding alias path and configuration parameters. eg: <br />
<b>URL Segment</b> <br /> <Panel.BlockQuote>
The corresponding alias path and configuration parameters. eg: <br /> URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to
<Panel.BlockQuote> complete URL
URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to <Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight>
complete URL </Panel.BlockQuote>
<Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight> <br />
</Panel.BlockQuote> You will need to save the changes before the presets are functional.
<br /> <br />
You will need to save the changes before the presets are functional. <ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
<br /> </Info>
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={isLoading} /> <Panel.Loader isLoading={isLoading} />
<Panel.Title> <Panel.Title>
@@ -6,6 +6,7 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; 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 Tag from '../../../../../common/components/tag/Tag';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldForm from './CustomFieldForm'; import CustomFieldForm from './CustomFieldForm';
@@ -13,19 +14,20 @@ import CustomFieldForm from './CustomFieldForm';
import style from '../FeatureSettings.module.scss'; import style from '../FeatureSettings.module.scss';
interface CustomFieldEntryProps { interface CustomFieldEntryProps {
field: string;
colour: string; colour: string;
label: string; label: string;
fieldKey: string;
type: 'string' | 'image';
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>; onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
onDelete: (label: CustomFieldLabel) => Promise<void>; onDelete: (label: CustomFieldLabel) => Promise<void>;
} }
export default function CustomFieldEntry(props: CustomFieldEntryProps) { export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, onEdit, onDelete, field } = props; const { colour, label, fieldKey, type, onEdit, onDelete } = props;
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const handleEdit = async (patch: CustomField) => { const handleEdit = async (patch: CustomField) => {
await onEdit(field, patch); await onEdit(fieldKey, patch);
setIsEditing(false); setIsEditing(false);
}; };
@@ -38,7 +40,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
onSubmit={handleEdit} onSubmit={handleEdit}
initialColour={colour} initialColour={colour}
initialLabel={label} initialLabel={label}
initialKey={field} initialKey={fieldKey}
/> />
</td> </td>
</tr> </tr>
@@ -50,10 +52,13 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
<td> <td>
<Swatch color={colour} /> <Swatch color={colour} />
</td> </td>
<td>
<Tag>{type}</Tag>
</td>
<td className={style.halfWidth}>{label}</td> <td className={style.halfWidth}>{label}</td>
<td className={style.fullWidth}> <td className={style.fullWidth}>
<CopyTag label='Copy key to use in integrations' copyValue={field}> <CopyTag label='Copy key to use in integrations' copyValue={fieldKey}>
{field} {fieldKey}
</CopyTag> </CopyTag>
</td> </td>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
@@ -71,7 +76,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
color='#FA5656' // $red-500 color='#FA5656' // $red-500
icon={<IoTrash />} icon={<IoTrash />}
aria-label='Delete entry' aria-label='Delete entry'
onClick={() => onDelete(field)} onClick={() => onDelete(fieldKey)}
/> />
</Panel.InlineElements> </Panel.InlineElements>
</tr> </tr>
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input, Radio, RadioGroup } from '@chakra-ui/react';
import { CustomField } from 'ontime-types'; import { CustomField } from 'ontime-types';
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils'; import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
import Info from '../../../../../common/components/info/Info';
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 useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../../common/utils/keyEvent';
@@ -20,6 +21,8 @@ interface CustomFieldsFormProps {
initialKey?: string; initialKey?: string;
} }
type CustomFieldFormData = CustomField & { key: string };
export default function CustomFieldForm(props: CustomFieldsFormProps) { export default function CustomFieldForm(props: CustomFieldsFormProps) {
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props; const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
const { data } = useCustomFields(); const { data } = useCustomFields();
@@ -28,6 +31,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
const [_, setColour] = useState(initialColour || ''); const [_, setColour] = useState(initialColour || '');
const { const {
control,
handleSubmit, handleSubmit,
register, register,
setFocus, setFocus,
@@ -35,17 +39,17 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
setValue, setValue,
getValues, getValues,
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm({ } = useForm<CustomFieldFormData>({
defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' }, defaultValues: { type: 'string', label: initialLabel || '', colour: initialColour || '' },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
}); });
const setupSubmit = async (values: { label: string; colour: string }) => { const setupSubmit = async (values: CustomFieldFormData) => {
const { label, colour } = values; const { type, label, colour } = values;
const newField: CustomField = { const newField: CustomField = {
type: 'string', // type is not user definable yet type,
colour, colour,
label, label,
}; };
@@ -77,6 +81,26 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
className={style.fieldForm} className={style.fieldForm}
onKeyDown={(event) => preventEscape(event, onCancel)} onKeyDown={(event) => preventEscape(event, onCancel)}
> >
<Info>
Please note that images can quickly deteriorate your app&apos;s performance.
<br />
Prefer using small, and compressed images.
</Info>
<div>
<Panel.Description>Type</Panel.Description>
<Controller
name='type'
control={control}
render={({ field }) => (
<RadioGroup {...field} size='sm' isDisabled={isEditMode} variant='ontime'>
<Panel.InlineElements relation='component'>
<Radio value='string'>Text</Radio>
<Radio value='image'>Image</Radio>
</Panel.InlineElements>
</RadioGroup>
)}
/>
</div>
<div className={style.twoCols}> <div className={style.twoCols}>
<div> <div>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description> <Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
@@ -105,12 +129,10 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' /> <Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
</div> </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>}
<Panel.InlineElements relation='inner' align='end'> <Panel.InlineElements relation='inner' align='end'>
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}> <Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
@@ -1,10 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { CustomField, CustomFieldLabel } from 'ontime-types'; 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 Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../../externals'; import { customFieldsDocsUrl } from '../../../../../externals';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
@@ -55,20 +56,17 @@ export default function CustomFields() {
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'> <Info>
<AlertIcon /> Custom fields allow for additional information to be added to an event.
<AlertDescription> <br />
Custom fields allow for additional information to be added to an event. <br />
<br /> This data is not used by Ontime, but provides place for cueing or department specific information (eg.
<br /> light, sound, camera).
This data is not used by Ontime, but provides place for cueing or department specific information (eg. <br />
light, sound, camera). <br />
<br /> Custom fields can be used width the Integrations feature using the generated key.
<br /> <ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
Custom fields can be used width the Integrations feature using the generated key. </Info>
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />} {isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
@@ -76,19 +74,21 @@ export default function CustomFields() {
<thead> <thead>
<tr> <tr>
<th>Colour</th> <th>Colour</th>
<th>Type</th>
<th>Name</th> <th>Name</th>
<th>Key (used in Integrations)</th> <th>Key (used in Integrations)</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{Object.entries(data).map(([key, { colour, label }]) => { {Object.entries(data).map(([key, { colour, label, type }]) => {
return ( return (
<CustomFieldEntry <CustomFieldEntry
key={key} key={key}
field={key} fieldKey={key}
colour={colour} colour={colour}
label={label} label={label}
type={type}
onEdit={handleEditField} onEdit={handleEditField}
onDelete={handleDelete} onDelete={handleDelete}
/> />
@@ -0,0 +1,64 @@
import { isOntimeEvent, MaybeNumber, NormalisedRundown, OntimeReport } from 'ontime-types';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time';
export type CombinedReport = {
index: number;
title: string;
cue: string;
scheduledStart: number;
actualStart: MaybeNumber;
scheduledEnd: number;
actualEnd: MaybeNumber;
};
/**
* Creates a combined report with the rundown data
*/
export function getCombinedReport(report: OntimeReport, rundown: NormalisedRundown, order: string[]): CombinedReport[] {
if (Object.keys(report).length === 0) return [];
if (order.length === 0) return [];
const combinedReport: CombinedReport[] = [];
for (const [key, value] of Object.entries(report)) {
if (!rundown[key] || !isOntimeEvent(rundown[key])) continue;
combinedReport.push({
index: order.findIndex((id) => id === key),
title: rundown[key].title,
cue: rundown[key].cue,
scheduledStart: rundown[key].timeStart,
actualEnd: value.endedAt,
scheduledEnd: rundown[key].timeEnd,
actualStart: value.startedAt,
});
}
return combinedReport;
}
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
/**
* Transforms a CombinedReport into a CSV string
*/
export function makeReportCSV(combinedReport: CombinedReport[]) {
const csv: string[][] = [];
csv.push(csvHeader);
for (const entry of combinedReport) {
csv.push([
String(entry.index),
entry.title,
entry.cue,
formatTime(entry.scheduledStart),
formatTime(entry.actualStart),
formatTime(entry.scheduledEnd),
formatTime(entry.actualEnd),
]);
}
return makeCSVFromArrayOfArrays(csv);
}
@@ -1,15 +1,17 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react'; import { Button, Input, Switch } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types'; import { ViewSettings } from 'ontime-types';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import { postViewSettings } from '../../../../common/api/viewSettings'; import { postViewSettings } from '../../../../common/api/viewSettings';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import Info from '../../../../common/components/info/Info';
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker'; import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import useViewSettings from '../../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/'; const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
@@ -85,16 +87,19 @@ export default function ViewSettingsForm() {
</Panel.InlineElements> </Panel.InlineElements>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Alert status='info' variant='ontime-on-dark-info'> <Info>
<AlertIcon /> You can the Ontime views or customise its styles by modifying the provided CSS file.
<AlertDescription> <br />
You can the Ontime views or customise its styles by modifying the provided CSS file. <br /> {!isOntimeCloud && (
The CSS file is in the user directory at {`${info.publicDir}/user/styles/override.css`} <>
<br /> <br />
<br /> The loaded CSS file is in the user directory at{' '}
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink> <Panel.BlockQuote>{`${info.publicDir}/user/styles/override.css`}</Panel.BlockQuote>
</AlertDescription> <br />
</Alert> </>
)}
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
</Info>
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={isLoading} /> <Panel.Loader isLoading={isLoading} />
<Panel.ListGroup> <Panel.ListGroup>
@@ -1,27 +0,0 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import * as Panel from '../../panel-utils/PanelUtils';
import EditorSettingsForm from './EditorSettingsForm';
export default function InterfacePanel() {
return (
<>
<Panel.Header>Interface</Panel.Header>
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Interface settings
<br />
<br />
These concern settings that are applied to this user in this browser.
<br />
It will not affect other users or other browsers.
</AlertDescription>
</Alert>
</Panel.Section>
<EditorSettingsForm />
</>
);
}
@@ -5,8 +5,8 @@ import { Button, Select, Switch } from '@chakra-ui/react';
import { generateUrl } from '../../../../common/api/session'; import { generateUrl } from '../../../../common/api/session';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets'; import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import copyToClipboard from '../../../../common/utils/copyToClipboard'; import copyToClipboard from '../../../../common/utils/copyToClipboard';
@@ -31,7 +31,7 @@ export default function ProjectList() {
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<th className={style.containCell}>Project Name</th> <th className={style.containCell}>File Name</th>
<th>Last Used</th> <th>Last Used</th>
<th /> <th />
</tr> </tr>
@@ -1,21 +1,18 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react'; import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/'; const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/';
export default function GSheetInfo() { export default function GSheetInfo() {
return ( return (
<Alert status='info' variant='ontime-on-dark-info'> <Info>
<AlertIcon /> Ontime allows you to synchronize your rundown with a Google Sheet.
<AlertDescription> <br />
Ontime allows you to synchronize your rundown with a Google Sheet. <br />
<br /> To enable this feature, you will need to generate tokens in your Google account and provide them to Ontime.
<br /> <br />
To enable this feature, you will need to generate tokens in your Google account and provide them to Ontime. Once set up, you will be able to synchronize data between Ontime and your Google Sheet. <br />
<br /> <ExternalLink href={googleSheetDocsUrl}>See the docs</ExternalLink>
Once set up, you will be able to synchronize data between Ontime and your Google Sheet. <br /> </Info>
<ExternalLink href={googleSheetDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
); );
} }
@@ -55,15 +55,14 @@ export default function GSheetSetup(props: GSheetSetupProps) {
setLoading(''); setLoading('');
}; };
const handleCancelFlow = async () => { const handleCancelFlow = () => {
onCancel(); onCancel();
}; };
/** /**
* Gets file from input * Gets file from input
* @param event
*/ */
const handleClientSecret = async (event: ChangeEvent<HTMLInputElement>) => { const handleClientSecret = (event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files?.length) { if (!event.target.files?.length) {
return; return;
} }
@@ -20,6 +20,7 @@ export const namedImportMap = {
'Timer type': 'timer type', 'Timer type': 'timer type',
'Time warning': 'warning time', 'Time warning': 'warning time',
'Time danger': 'danger time', 'Time danger': 'danger time',
ID: 'id',
custom: [] as ImportCustom[], custom: [] as ImportCustom[],
}; };
@@ -58,6 +59,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
timeWarning: namedImportMap['Time warning'], timeWarning: namedImportMap['Time warning'],
timeDanger: namedImportMap['Time danger'], timeDanger: namedImportMap['Time danger'],
custom, custom,
entryId: namedImportMap.ID,
}; };
} }
@@ -49,6 +49,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
{fieldLabels.map((label) => ( {fieldLabels.map((label) => (
<th key={label}>{label}</th> <th key={label}>{label}</th>
))} ))}
<th>ID</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -113,6 +114,9 @@ export default function PreviewRundown(props: PreviewRundownProps) {
} }
return <td key={field}>{value}</td>; return <td key={field}>{value}</td>;
})} })}
<td className={style.center}>
<Tag>{event.id}</Tag>
</td>
</tr> </tr>
{event.note && ( {event.note && (
<tr> <tr>
@@ -35,6 +35,7 @@ const staticOptions = [
secondary: [ secondary: [
{ id: 'feature_settings__custom', label: 'Custom fields' }, { id: 'feature_settings__custom', label: 'Custom fields' },
{ id: 'feature_settings__urlpresets', label: 'URL Presets' }, { id: 'feature_settings__urlpresets', label: 'URL Presets' },
{ id: 'feature_settings__report', label: 'Report' },
], ],
}, },
{ {
@@ -6,6 +6,7 @@ import AddTime from './add-time/AddTime';
import { AuxTimer } from './aux-timer/AuxTimer'; import { AuxTimer } from './aux-timer/AuxTimer';
import PlaybackButtons from './playback-buttons/PlaybackButtons'; import PlaybackButtons from './playback-buttons/PlaybackButtons';
import PlaybackTimer from './playback-timer/PlaybackTimer'; import PlaybackTimer from './playback-timer/PlaybackTimer';
import TimerSpeed from './timer-speed/TimerSpeed';
import style from './PlaybackControl.module.scss'; import style from './PlaybackControl.module.scss';
@@ -14,7 +15,7 @@ export default function PlaybackControl() {
return ( return (
<div className={style.mainContainer}> <div className={style.mainContainer}>
<PlaybackTimer playback={data.playback as Playback}> <PlaybackTimer playback={data.playback}>
<AddTime playback={data.playback} /> <AddTime playback={data.playback} />
</PlaybackTimer> </PlaybackTimer>
<PlaybackButtons <PlaybackButtons
@@ -23,6 +24,7 @@ export default function PlaybackControl() {
selectedEventIndex={data.selectedEventIndex} selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase} timerPhase={data.timerPhase}
/> />
<TimerSpeed isPlaying={data.playback === Playback.Play} eventIndex={data.selectedEventIndex} />
<AuxTimer /> <AuxTimer />
</div> </div>
); );
@@ -1,3 +1,4 @@
import { memo } from 'react';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -16,7 +17,9 @@ interface AddTimeProps {
playback: Playback; playback: Playback;
} }
export default function AddTime(props: AddTimeProps) { export default memo(AddTime);
function AddTime(props: AddTimeProps) {
const { playback } = props; const { playback } = props;
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause'; import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
@@ -34,7 +35,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const isLast = selectedEventIndex === numEvents - 1; const isLast = selectedEventIndex === numEvents - 1;
const noEvents = numEvents === 0; const noEvents = numEvents === 0;
const disableGo = isRolling || noEvents || (isLast && !isArmed); const disableGo = isRolling || noEvents;
const disableNext = isRolling || noEvents || isLast; const disableNext = isRolling || noEvents || isLast;
const disablePrev = isRolling || noEvents || isFirst; const disablePrev = isRolling || noEvents || isFirst;
@@ -45,14 +46,16 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const disableStop = !playbackCan.stop; const disableStop = !playbackCan.stop;
const disableReload = !playbackCan.reload; const disableReload = !playbackCan.reload;
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next'; const [goModeAction, goModeText] = useMemo(() => {
const goModeAction = () => {
if (isArmed) { if (isArmed) {
setPlayback.start(); return [setPlayback.start, 'Start'];
} else { } else if (isLast) {
setPlayback.startNext(); return [setPlayback.stop, 'Finish'];
} else if (selectedEventIndex === null) {
return [setPlayback.startNext, 'Start'];
} }
}; return [setPlayback.startNext, 'Next'];
}, [isArmed, isLast, selectedEventIndex]);
return ( return (
<div className={style.buttonContainer}> <div className={style.buttonContainer}>
@@ -1,9 +1,6 @@
.timeContainer { .timeContainer {
display: grid; display: grid;
grid-template-areas: grid-template-areas: 'indicators timer addtime';
'indicators timer addtime'
'status status addtime';
grid-template-rows: 1fr auto;
grid-template-columns: 1.25rem 1fr 6.5rem; grid-template-columns: 1.25rem 1fr 6.5rem;
gap: $element-inner-spacing; gap: $element-inner-spacing;
} }
@@ -50,29 +47,3 @@
background-color: $playback-negative; background-color: $playback-negative;
} }
} }
// ---------> LABELS
.status {
grid-area: status;
height: 1.5rem;
display: flex;
gap: $section-spacing;
margin-left: 1.5rem;
}
.tag {
color: $label-gray;
font-size: calc(1rem - 2px);
margin-right: 0.25rem;
}
.time {
color: $section-white;
font-size: $text-body-size;
}
.rolltag {
color: $ontime-roll;
font-size: $text-body-size;
}
@@ -1,7 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { Playback, TimerPhase } from 'ontime-types'; import { Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToString } from 'ontime-utils';
import { useTimer } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
import { formatDuration } from '../../../../common/utils/time'; import { formatDuration } from '../../../../common/utils/time';
@@ -29,16 +28,21 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
const { playback, children } = props; const { playback, children } = props;
const timer = useTimer(); const timer = useTimer();
const started = millisToString(timer.startedAt);
const expectedFinish = timer.expectedFinish !== null ? timer.expectedFinish % dayInMs : null;
const finish = millisToString(expectedFinish);
const isRolling = playback === Playback.Roll; const isRolling = playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending; const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime; const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime); const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : ''; const rollLabel = (() => {
if (!isRolling) {
return '';
}
if (isWaiting) {
return 'Roll: Countdown to start';
}
return 'Roll mode active';
})();
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime); const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
@@ -54,22 +58,6 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
</Tooltip> </Tooltip>
</div> </div>
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} /> <TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
<div className={style.status}>
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
) : (
<>
<span className={style.start}>
<span className={style.tag}>Started at</span>
<span className={style.time}>{started}</span>
</span>
<span className={style.finish}>
<span className={style.tag}>Expect end</span>
<span className={style.time}>{finish}</span>
</span>
</>
)}
</div>
{children} {children}
</div> </div>
); );
@@ -9,6 +9,7 @@
color: $timer-color; color: $timer-color;
line-height: 0.9em; line-height: 0.9em;
text-align: center; text-align: center;
align-self: center;
letter-spacing: 0.1em; letter-spacing: 0.1em;
font-weight: 600; font-weight: 600;
font-size: 3.5rem; font-size: 3.5rem;
@@ -0,0 +1,88 @@
import { Playback, TimerPhase } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import AppLink from '../../../../common/components/link/app-link/AppLink';
import { useClock, useTimerCurrent, useTimerSchedule } from '../../../../common/hooks/useSocket';
import useReport from '../../../../common/hooks-query/useReport';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import style from './TimerSpeed.module.scss';
interface MeetScheduleProps {
speed: number;
newSpeed: number;
}
/**
* We isolate this component since it has the potential to re-render on every second
*/
export default function TimerSchedule(props: MeetScheduleProps) {
const { speed, newSpeed } = props;
const { startedAt, expectedFinish, phase, playback } = useTimerSchedule();
const started = formatTime(startedAt);
const normalisedExpectedEnd = expectedFinish !== null ? expectedFinish % dayInMs : null;
const endTime = formatTime(normalisedExpectedEnd);
const isWaiting = phase === TimerPhase.Pending;
const hasNewSpeed = speed !== newSpeed;
if (isWaiting) {
return <div className={cx([style.entry, style.roll])}>Roll: Countdown to start</div>;
}
if (playback === Playback.Stop) {
return <StoppedStatus />;
}
return (
<div className={style.timers}>
<div className={style.entry}>
<div className={style.timerLabel}>Started at</div>
<div>{started}</div>
</div>
<div className={style.entry}>
<div className={style.timerLabel}>Expected end</div>
{hasNewSpeed ? <SpeedFinish newSpeed={newSpeed} /> : <div>{endTime}</div>}
</div>
</div>
);
}
// TODO: extract and test
// calculate the new finish time
function useExpectedTime(remainingTimeMs: number, speedFactor: number): number {
const { clock } = useClock();
const adjustedRemainingTimeMs = remainingTimeMs / speedFactor;
const newFinishTimeMs = clock + adjustedRemainingTimeMs;
return newFinishTimeMs;
}
function StoppedStatus() {
const { data } = useReport();
const hasReport = Object.keys(data).length > 0;
if (hasReport) {
return (
<AppLink className={style.entry} search='settings=feature_settings__report'>
Go to report management
</AppLink>
);
}
return <div className={style.entry}>No running playback</div>;
}
interface SpeedFinishProps {
newSpeed: number;
}
function SpeedFinish(props: SpeedFinishProps) {
const { newSpeed } = props;
const { current } = useTimerCurrent();
const newFinish = formatTime(useExpectedTime(current ?? 0, newSpeed));
return <div className={style.highlight}>{newFinish}</div>;
}
@@ -0,0 +1,48 @@
.panelContainer {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.inlineApart {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.inlineSiblings {
display: flex;
gap: 0.5rem;
}
.timers {
display: grid;
grid-template-columns: 1fr 1fr;
}
.entry {
display: flex;
gap: 0.25rem;
height: 1.2rem;
align-items: end;
line-height: 1em;
}
.timerLabel {
color: $label-gray;
font-size: calc(1rem - 3px);
}
.roll {
color: $ontime-roll;
}
.highlight {
color: $highlight-orange;
}
.disabled {
opacity: $opacity-disabled;
}
@@ -0,0 +1,78 @@
import { memo, useEffect, useState } from 'react';
import { Button, Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack } from '@chakra-ui/react';
import { MaybeNumber } from 'ontime-types';
import { setTimerSpeed, useTimerSpeed } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils';
import TimerSchedule from './TimerSchedule';
import style from './TimerSpeed.module.scss';
interface TimerSpeedProps {
isPlaying: boolean;
eventIndex: MaybeNumber;
}
export default memo(TimerSpeed);
function TimerSpeed(props: TimerSpeedProps) {
const { isPlaying, eventIndex } = props;
const { speed } = useTimerSpeed();
const [newSpeed, setNewSpeed] = useState(1);
const { setSpeed } = setTimerSpeed;
// when a new timer is set, we want to reset the speed
useEffect(() => {
setNewSpeed(1);
}, [eventIndex]);
const handleApply = () => setSpeed(newSpeed);
const handleReset = () => {
setNewSpeed(1.0);
setSpeed(1.0);
};
const canReset = isPlaying && speed === newSpeed && newSpeed !== 1;
const canApply = isPlaying && speed !== newSpeed;
const willChangeSpeed = speed === 1 && !canApply;
return (
<div className={style.panelContainer}>
<TimerSchedule speed={speed} newSpeed={newSpeed} />
<div className={style.inlineApart}>
<div className={style.entry}>
<span className={cx([willChangeSpeed && style.disabled])}>{`${speed}x`}</span>
{newSpeed !== speed && <span className={style.highlight}>{`${newSpeed}x`}</span>}
</div>
<div className={style.inlineSiblings}>
<Button size='sm' variant='ontime-subtle-white' onClick={handleReset} isDisabled={!canReset}>
Reset
</Button>
<Button size='sm' variant='ontime-subtle-white' onClick={handleApply} isDisabled={!canApply}>
Apply
</Button>
</div>
</div>
<Slider
variant={newSpeed === 1 ? 'ontime' : 'ontime-highlight'}
defaultValue={newSpeed}
min={0.5}
max={2.0}
step={0.01}
onChange={(v) => setNewSpeed(v)}
value={newSpeed}
isDisabled={!isPlaying}
>
<SliderMark value={0.5}>0.5x</SliderMark>
<SliderMark value={1.0}>1.0x</SliderMark>
<SliderMark value={1.5}>1.5x</SliderMark>
<SliderMark value={2.0}>2.0x</SliderMark>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</div>
);
}
@@ -4,7 +4,7 @@ import { Button, Checkbox, Modal, ModalBody, ModalCloseButton, ModalContent, Mod
import { loadDemo, loadProject } from '../../../common/api/db'; import { loadDemo, loadProject } from '../../../common/api/db';
import { postShowWelcomeDialog } from '../../../common/api/settings'; import { postShowWelcomeDialog } from '../../../common/api/settings';
import { invalidateAllCaches } from '../../../common/api/utils'; import { invalidateAllCaches } from '../../../common/api/utils';
import ExternalLink from '../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../common/components/link/external-link/ExternalLink';
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals'; import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
import * as Editor from '../editor-utils/EditorUtils'; import * as Editor from '../editor-utils/EditorUtils';
@@ -75,7 +75,7 @@ export default function Welcome(props: WelcomeProps) {
<table className={style.table}> <table className={style.table}>
<thead> <thead>
<tr> <tr>
<th>Project Name</th> <th>File Name</th>
<th>Last Used</th> <th>Last Used</th>
</tr> </tr>
</thead> </thead>
@@ -46,7 +46,7 @@
} }
.ahead { .ahead {
color: $green-500; color: $playback-ahead;
} }
.behind { .behind {
+7 -1
View File
@@ -7,6 +7,7 @@ import {
isOntimeEvent, isOntimeEvent,
isPlayableEvent, isPlayableEvent,
MaybeString, MaybeString,
OntimeEvent,
PlayableEvent, PlayableEvent,
Playback, Playback,
RundownCached, RundownCached,
@@ -269,8 +270,9 @@ export default function Rundown({ data }: RundownProps) {
// all events before the current selected are in the past // all events before the current selected are in the past
let isPast = Boolean(featureData?.selectedEventId); let isPast = Boolean(featureData?.selectedEventId);
let isNextDay = false; let isNextDay = false;
let totalGap = 0;
const isEditMode = appMode === AppMode.Edit; const isEditMode = appMode === AppMode.Edit;
let currentDay = 0;
return ( return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'> <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}> <DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
@@ -297,6 +299,7 @@ export default function Rundown({ data }: RundownProps) {
if (isPlayableEvent(entry)) { if (isPlayableEvent(entry)) {
isNextDay = checkIsNextDay(entry, lastEvent); isNextDay = checkIsNextDay(entry, lastEvent);
totalGap += !isPast ? entry.gap : 0;
if (isNewLatest(entry, lastEvent)) { if (isNewLatest(entry, lastEvent)) {
// populate previous entry // populate previous entry
thisEvent = entry; thisEvent = entry;
@@ -310,6 +313,7 @@ export default function Rundown({ data }: RundownProps) {
const hasCursor = entry.id === cursor; const hasCursor = entry.id === cursor;
if (isLoaded) { if (isLoaded) {
isPast = false; isPast = false;
currentDay = (entry as OntimeEvent).dayOffset;
} }
return ( return (
@@ -331,6 +335,8 @@ export default function Rundown({ data }: RundownProps) {
playback={isLoaded ? featureData.playback : undefined} playback={isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll} isRolling={featureData.playback === Playback.Roll}
isNextDay={isNextDay} isNextDay={isNextDay}
totalGap={totalGap}
currentDay={currentDay}
/> />
</div> </div>
</div> </div>
@@ -22,7 +22,8 @@ export type EventItemActions =
| 'delete' | 'delete'
| 'clone' | 'clone'
| 'update' | 'update'
| 'swap'; | 'swap'
| 'clear-report';
interface RundownEntryProps { interface RundownEntryProps {
type: SupportedEvent; type: SupportedEvent;
@@ -37,6 +38,8 @@ interface RundownEntryProps {
previousEventId?: string; previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event isRolling: boolean; // we need to know even if not related to this event
totalGap: number;
currentDay: number;
} }
export default function RundownEntry(props: RundownEntryProps) { export default function RundownEntry(props: RundownEntryProps) {
@@ -52,6 +55,8 @@ export default function RundownEntry(props: RundownEntryProps) {
isRolling, isRolling,
eventIndex, eventIndex,
isNextDay, isNextDay,
totalGap,
currentDay,
} = props; } = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
@@ -173,6 +178,8 @@ export default function RundownEntry(props: RundownEntryProps) {
isRolling={isRolling} isRolling={isRolling}
gap={data.gap} gap={data.gap}
isNextDay={isNextDay} isNextDay={isNextDay}
dayOffset={data.dayOffset - currentDay}
totalGap={totalGap}
actionHandler={actionHandler} actionHandler={actionHandler}
/> />
); );
@@ -9,7 +9,7 @@ $skip-opacity: 0.2;
display: grid; display: grid;
grid-template-areas: grid-template-areas:
'binder ... ... ...' 'binder ... ... ...'
'binder pb-actions times ...' 'binder pb-actions times chip'
'binder pb-actions title title' 'binder pb-actions title title'
'binder pb-actions estatus estatus' 'binder pb-actions estatus estatus'
'binder ... ... ...'; 'binder ... ... ...';
@@ -131,6 +131,10 @@ $skip-opacity: 0.2;
height: 100%; height: 100%;
} }
.chipSection {
grid-area: chip;
}
.titleSection { .titleSection {
grid-area: title; grid-area: title;
display: flex; display: flex;
@@ -49,6 +49,8 @@ interface EventBlockProps {
isRolling: boolean; isRolling: boolean;
gap: number; gap: number;
isNextDay: boolean; isNextDay: boolean;
dayOffset: number;
totalGap: number;
actionHandler: ( actionHandler: (
action: EventItemActions, action: EventItemActions,
payload?: payload?:
@@ -87,6 +89,8 @@ export default function EventBlock(props: EventBlockProps) {
isRolling, isRolling,
gap, gap,
isNextDay, isNextDay,
dayOffset,
totalGap,
actionHandler, actionHandler,
} = props; } = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
@@ -171,7 +175,7 @@ export default function EventBlock(props: EventBlockProps) {
}, },
isDisabled: selectedEventId == null || selectedEventId === eventId, isDisabled: selectedEventId == null || selectedEventId === eventId,
}, },
{ withDivider: true, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') }, { withDivider: false, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') }, { withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
], ],
); );
@@ -303,6 +307,9 @@ export default function EventBlock(props: EventBlockProps) {
loaded={loaded} loaded={loaded}
playback={playback} playback={playback}
isRolling={isRolling} isRolling={isRolling}
dayOffset={dayOffset}
isPast={isPast}
totalGap={totalGap}
/> />
)} )}
</div> </div>
@@ -11,12 +11,14 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward'
import { IoStop } from '@react-icons/all-files/io5/IoStop'; import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime'; import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig'; import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle'; import EditableBlockTitle from '../common/EditableBlockTitle';
import TimeInputFlow from '../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../time-input-flow/TimeInputFlow';
import EventBlockChip from './composite/EventBlockChip';
import EventBlockPlayback from './composite/EventBlockPlayback'; import EventBlockPlayback from './composite/EventBlockPlayback';
import EventBlockProgressBar from './composite/EventBlockProgressBar'; import EventBlockProgressBar from './composite/EventBlockProgressBar';
@@ -42,6 +44,9 @@ interface EventBlockInnerProps {
loaded: boolean; loaded: boolean;
playback?: Playback; playback?: Playback;
isRolling: boolean; isRolling: boolean;
dayOffset: number;
isPast: boolean;
totalGap: number;
} }
function EventBlockInner(props: EventBlockInnerProps) { function EventBlockInner(props: EventBlockInnerProps) {
@@ -64,6 +69,9 @@ function EventBlockInner(props: EventBlockInnerProps) {
loaded, loaded,
playback, playback,
isRolling, isRolling,
dayOffset,
isPast,
totalGap,
} = props; } = props;
const [renderInner, setRenderInner] = useState(false); const [renderInner, setRenderInner] = useState(false);
@@ -108,6 +116,18 @@ function EventBlockInner(props: EventBlockInnerProps) {
loaded={loaded} loaded={loaded}
disablePlayback={skip || isRolling} disablePlayback={skip || isRolling}
/> />
{!skip && (
<EventBlockChip
className={style.chipSection}
id={eventId}
trueTimeStart={timeStart + dayOffset * dayInMs}
isPast={isPast}
isLoaded={loaded}
totalGap={totalGap}
isLinkedAndNext={isNext && linkStart !== null}
duration={duration}
/>
)}
<div className={style.statusElements} id='block-status' data-ispublic={isPublic}> <div className={style.statusElements} id='block-status' data-ispublic={isPublic}>
<span className={style.eventNote}>{note}</span> <span className={style.eventNote}>{note}</span>
<div className={loaded ? style.progressBg : `${style.progressBg} ${style.hidden}`}> <div className={loaded ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
@@ -0,0 +1,21 @@
.chip {
background-color: $gray-1100;
white-space: nowrap;
font-size: calc(1rem - 3px);
color: $label-gray;
padding: 0.125rem 0.5rem;
border-radius: 2px;
&.over {
color: $ontime-delay-text;
}
&.under {
color: $playback-ahead;
}
&.due {
color: $warning-orange;
}
}
@@ -0,0 +1,123 @@
import { useMemo } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { usePlayback, useTimelineStatus } from '../../../../common/hooks/useSocket';
import useReport from '../../../../common/hooks-query/useReport';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import style from './EventBlockChip.module.scss';
interface EventBlockChipProps {
id: string;
trueTimeStart: number;
isPast: boolean;
isLoaded: boolean;
className: string;
totalGap: number;
isLinkedAndNext: boolean;
duration: number;
}
export default function EventBlockChip(props: EventBlockChipProps) {
const { trueTimeStart, isPast, isLoaded, className, totalGap, isLinkedAndNext, id, duration } = props;
const { playback } = usePlayback();
if (isLoaded) {
return null; //TODO: the is a small flash of 'DUE' on the loaded event as clock data arrives before isLoaded propagates
}
const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) {
return <EventReport className={className} id={id} duration={duration} />;
}
if (playbackActive) {
// we extracted the component to avoid unnecessary calculations and re-renders
return (
<Tooltip label='Expected time until start' openDelay={tooltipDelayFast}>
<div className={className}>
<EventUntil trueTimeStart={trueTimeStart} totalGap={totalGap} isLinkedAndNext={isLinkedAndNext} />
</div>
</Tooltip>
);
}
return null;
}
interface EventUntilProps {
trueTimeStart: number;
totalGap: number;
isLinkedAndNext: boolean;
}
function EventUntil(props: EventUntilProps) {
const { trueTimeStart, totalGap, isLinkedAndNext } = props;
const { clock, offset } = useTimelineStatus();
const consumedOffset = isLinkedAndNext ? offset : Math.min(offset + totalGap, 0);
const offsetTimestart = trueTimeStart - consumedOffset;
const timeUntil = offsetTimestart - clock;
const isDue = timeUntil < MILLIS_PER_SECOND;
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
return <div className={cx([style.chip, isDue && style.due])}>{timeUntilString}</div>;
}
interface EventReportProps {
className: string;
id: string;
duration: number;
}
function EventReport(props: EventReportProps) {
const { className, id, duration } = props;
const { data } = useReport();
const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => {
if (!currentReport) {
return [null, 'none', ''];
}
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
}
const actualDuration = endedAt - startedAt;
const difference = actualDuration - duration;
const absDifference = Math.abs(difference);
if (absDifference < MILLIS_PER_SECOND) {
return ['ontime', 'ontime', 'Event finished ontime'];
}
const isOver = difference > 0;
const fullTimeValue = formatTime(absDifference);
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, isOver ? 'over' : 'under', tooltip];
}, [currentReport, duration]);
if (!value) {
return null;
}
return (
<Tooltip label={tooltip} openDelay={tooltipDelayFast}>
<div className={cx([style.chip, style[overUnderStyle], className])}>
{value === 'ontime' ? <IoCheckmarkCircle size='1.1rem' /> : value}
</div>
</Tooltip>
);
}
@@ -1,15 +1,12 @@
.eventEditor { .eventEditor {
max-height: 100%; max-height: 80vh;
overflow-y: auto;
padding-inline: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-x: auto; overflow-x: auto;
} }
.content { .content {
padding-right: 4px; padding-inline: 0.5rem 1.5rem;
padding-bottom: 4rem; padding-bottom: 4rem;
flex: 1; flex: 1;
@@ -81,3 +78,9 @@
font-size: 1.25em; font-size: 1.25em;
margin-left: 0.25em; margin-left: 0.25em;
} }
.customImage {
display: grid;
grid-template-columns: 1fr 72px;
gap: 1rem;
}
@@ -1,16 +1,17 @@
import { CSSProperties, useCallback } from 'react'; import { CSSProperties, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button } from '@chakra-ui/react';
import { CustomFieldLabel, OntimeEvent } from 'ontime-types'; import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getAccessibleColour } from '../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../common/utils/styleUtils';
import * as Editor from '../../editors/editor-utils/EditorUtils'; import * as Editor from '../../editors/editor-utils/EditorUtils';
import EventEditorImage from './composite/EventEditorImage';
import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles'; import EventEditorTitles from './composite/EventEditorTitles';
import EventTextArea from './composite/EventTextArea'; import EventTextArea from './composite/EventTextArea';
import EventTextInput from './composite/EventTextInput';
import EventEditorEmpty from './EventEditorEmpty'; import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss'; import style from './EventEditor.module.scss';
@@ -27,7 +28,6 @@ export default function EventEditor(props: EventEditorProps) {
const { event } = props; const { event } = props;
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEvent } = useEventAction(); const { updateEvent } = useEventAction();
const [_searchParams, setSearchParams] = useSearchParams();
const isEditor = window.location.pathname.includes('editor'); const isEditor = window.location.pathname.includes('editor');
@@ -43,10 +43,6 @@ export default function EventEditor(props: EventEditorProps) {
[event?.id, updateEvent], [event?.id, updateEvent],
); );
const handleOpenCustomManager = () => {
setSearchParams({ settings: 'feature_settings__custom' });
};
if (!event) { if (!event) {
return <EventEditorEmpty />; return <EventEditorEmpty />;
} }
@@ -81,12 +77,9 @@ export default function EventEditor(props: EventEditorProps) {
<div className={style.column}> <div className={style.column}>
<Editor.Title> <Editor.Title>
Custom Fields Custom Fields
{isEditor && ( {isEditor && <AppLink search='settings=feature_settings__custom'>Manage</AppLink>}
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
Manage
</Button>
)}
</Editor.Title> </Editor.Title>
{Object.keys(customFields).map((fieldKey) => { {Object.keys(customFields).map((fieldKey) => {
const key = `${event.id}-${fieldKey}`; const key = `${event.id}-${fieldKey}`;
const fieldName = `custom-${fieldKey}`; const fieldName = `custom-${fieldKey}`;
@@ -94,17 +87,41 @@ export default function EventEditor(props: EventEditorProps) {
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour); const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label; const labelText = customFields[fieldKey].label;
return ( if (customFields[fieldKey].type === 'string') {
<EventTextArea return (
key={key} <EventTextArea
field={fieldName} key={key}
label={labelText} field={fieldName}
initialValue={initialValue} label={labelText}
submitHandler={handleSubmit} initialValue={initialValue}
className={style.decorated} submitHandler={handleSubmit}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties} className={style.decorated}
/> style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
); />
);
}
if (customFields[fieldKey].type === 'image') {
return (
<div key={key} className={style.customImage}>
<EventTextInput
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder='Paste image URL'
submitHandler={handleSubmit}
className={style.decorated}
maxLength={255}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
<EventEditorImage src={initialValue} />
</div>
);
}
// we should have exhausted all types by now
return null;
})} })}
</div> </div>
</div> </div>
@@ -0,0 +1,12 @@
.imageContainer {
width: 100%;
height: 100%;
background-color: $gray-1250;
display: grid;
place-content: center;
}
.imageOverlay {
width: 100%;
height: 100%;
}

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