mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 06:28:01 +00:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a6542df76 | |||
| b2eb140ee2 | |||
| e28f692d3d | |||
| dc73e7e637 | |||
| b81e3a4ba5 | |||
| 6fc7b15d23 | |||
| 99dd4b4d5c | |||
| b60b1beeec | |||
| eab916f410 | |||
| 069a003787 | |||
| 9b08080b7a | |||
| 8e7ab54d39 | |||
| 4a16378786 | |||
| e5b30770ce | |||
| 8465b04c89 | |||
| 718a6ad2b7 | |||
| 6ca4bfc363 | |||
| 4859026989 | |||
| 9167a50eda | |||
| 8adca908b3 | |||
| bf99646ad5 | |||
| 308285ce3e | |||
| dd781a32f6 | |||
| c2a6de6195 | |||
| 0ac2ce8e84 | |||
| 3bdd8424eb | |||
| b3a099255f | |||
| 570ff59cfc | |||
| d0b4c6a279 | |||
| e90161aa33 | |||
| e37254107e | |||
| 9d6bc0a137 | |||
| 4cd2dcbab0 | |||
| 8c593b3487 | |||
| ba74622569 | |||
| f76c0d1a6d | |||
| 8e8d3a7824 | |||
| 6c3ae1f914 | |||
| b6507c27a6 | |||
| 21877e4bff | |||
| b8d7324be0 | |||
| 88b8e83494 | |||
| abce0c498c | |||
| 6b2e45e7de | |||
| 5fb30c75c4 | |||
| 636f78e21e | |||
| 1636745fed | |||
| e01ab0c88b | |||
| 0ede753652 | |||
| 1f6f3df0f2 | |||
| a5afb32b8a | |||
| 9c41f8ff6b | |||
| 1edde03bb4 | |||
| 946b6717d2 | |||
| f99ad83049 | |||
| 665496907e | |||
| 2867c8301d | |||
| 84a5b4fe5b | |||
| 86bb739d90 | |||
| 2c8b6180d7 | |||
| 4cc138e3c0 | |||
| d21e1f996f | |||
| 11c1b472a2 | |||
| 891bda6661 | |||
| c65761448d | |||
| 70558c5104 | |||
| c8bf74a719 | |||
| 10bf4b93ee | |||
| f6449a4326 | |||
| 8d38f52b69 | |||
| 3d40e31730 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.11.1-beta.1",
|
||||
"version": "3.14.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.11.1-beta.1",
|
||||
"version": "3.14.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -15,6 +15,7 @@
|
||||
"@mantine/hooks": "^7.13.3",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^8.43.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-query-devtools": "^5.62.7",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
@@ -31,7 +32,7 @@
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"web-vitals": "^3.1.1",
|
||||
"zustand": "^4.5.2"
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
|
||||
@@ -158,7 +158,14 @@ export default function AppRouter() {
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<PCuesheet />} />
|
||||
<Route path='/op' element={<POperator />} />
|
||||
<Route
|
||||
path='/op'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<POperator />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
|
||||
@@ -14,6 +14,7 @@ export const SHEET_STATE = ['sheetState'];
|
||||
export const URL_PRESETS = ['urlpresets'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const CLIENT_LIST = ['clientList'];
|
||||
export const REPORT = ['report'];
|
||||
|
||||
// API URLs
|
||||
export const apiEntryUrl = `${serverURL}/data`;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
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 { createBlob, downloadBlob } from './utils';
|
||||
@@ -42,7 +43,7 @@ export async function downloadCSV(fileName: string = 'rundown') {
|
||||
const { project, rundown, customFields } = data;
|
||||
|
||||
const sheetData = makeTable(project, rundown, customFields);
|
||||
const fileContent = makeCSV(sheetData);
|
||||
const fileContent = makeCSVFromArrayOfArrays(sheetData);
|
||||
|
||||
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
|
||||
downloadBlob(blob, `${name}.csv`);
|
||||
|
||||
@@ -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 {
|
||||
Button,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftAddon,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Select,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoArrowForward } from '@react-icons/all-files/io5/IoArrowForward';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
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 {
|
||||
id: string;
|
||||
name?: string;
|
||||
path?: string;
|
||||
name: string;
|
||||
currentPath: string;
|
||||
origin: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
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 [selected, setSelected] = useState('/');
|
||||
|
||||
const { setRedirect } = setClientRemote;
|
||||
|
||||
const handleRedirect = () => {
|
||||
if (path !== currentPath && path !== '') {
|
||||
setRedirect({ target: id, redirect: path });
|
||||
const handleRedirect = (newPath: string) => {
|
||||
if (newPath === '/' || newPath === currentPath) {
|
||||
return;
|
||||
}
|
||||
setRedirect({ target: id, redirect: newPath });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const host = window.location.origin;
|
||||
const canSubmit = path !== currentPath && path !== '';
|
||||
const enabledPresets = data.filter((preset) => preset.enabled);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalContent maxWidth='max(480px, 35vw)'>
|
||||
<ModalHeader>Redirect: {name}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<InputGroup variant='ontime-filled' size='md'>
|
||||
<InputLeftAddon>{host}</InputLeftAddon>
|
||||
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
|
||||
</InputGroup>
|
||||
<Info>
|
||||
Remotely redirect the client to a different URL. <br />
|
||||
Either by selecting a URL Preset or entering a custom path.
|
||||
<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>
|
||||
<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>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -26,17 +26,22 @@ class ErrorBoundary extends React.Component {
|
||||
error,
|
||||
errorInfo: info,
|
||||
});
|
||||
this.reportContent = `${error} ${info.componentStack}`;
|
||||
|
||||
const appState = runtimeStore.getState();
|
||||
if (appState.ping < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setExtras({
|
||||
error,
|
||||
store: runtimeStore.getState(),
|
||||
store: appState,
|
||||
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
|
||||
});
|
||||
const eventId = Sentry.captureException(error);
|
||||
this.setState({ eventId, info });
|
||||
});
|
||||
this.reportContent = `${error} ${info.componentStack}`;
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $element-spacing;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
background-color: $gray-1100;
|
||||
border-radius: 2px;
|
||||
font-size: $inner-section-text-size;
|
||||
background-color: $gray-1200;
|
||||
border-radius: 3px;
|
||||
font-size: $text-body-size;
|
||||
padding: 1rem;
|
||||
|
||||
.content {
|
||||
color: $gray-200;
|
||||
}
|
||||
|
||||
svg {
|
||||
min-width: 1.5rem;
|
||||
align-self: start;
|
||||
font-size: 1.5rem;
|
||||
color: $info-blue;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
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 (
|
||||
<div className={style.infoLabel}>
|
||||
<div className={cx([style.infoLabel, className])}>
|
||||
<IoAlertCircle />
|
||||
{children}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useController, UseControllerProps } from 'react-hook-form';
|
||||
import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import PopoverPicker from '../../../../common/components/input/popover-picker/PopoverPicker';
|
||||
import { debounce } from '../../../../common/utils/debounce';
|
||||
import { debounce } from '../../../utils/debounce';
|
||||
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
|
||||
import PopoverPicker from '../popover-picker/PopoverPicker';
|
||||
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function useReactiveTextInput(
|
||||
submitOnCtrlEnter?: boolean;
|
||||
onCancelUpdate?: () => void;
|
||||
allowSubmitSameValue?: boolean;
|
||||
allowKeyboardNavigation?: boolean;
|
||||
},
|
||||
): UseReactiveTextInputReturn {
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
@@ -58,7 +59,14 @@ export default function useReactiveTextInput(
|
||||
setText(cleanVal);
|
||||
}
|
||||
}
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
|
||||
setTimeout(() => {
|
||||
if (options?.allowKeyboardNavigation) {
|
||||
ref.current?.parentElement?.focus(); // Focus on parent element to continue keyboard navigation
|
||||
} else {
|
||||
ref.current?.blur();
|
||||
}
|
||||
}); // Immediate timeout to ensure text is set before blurring
|
||||
},
|
||||
[initialText, options, ref, submitCallback],
|
||||
);
|
||||
@@ -70,12 +78,25 @@ export default function useReactiveTextInput(
|
||||
const handleEscape = useCallback(() => {
|
||||
// No need to update if it hasn't changed
|
||||
setText(initialText);
|
||||
// force the text to be the initial value
|
||||
if (ref.current) {
|
||||
ref.current.value = initialText;
|
||||
}
|
||||
options?.onCancelUpdate?.();
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
|
||||
}, [initialText, options, ref]);
|
||||
|
||||
const keyHandler = useMemo(() => {
|
||||
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
||||
const hotKeys: HotkeyItem[] = [
|
||||
[
|
||||
'Escape',
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
handleEscape();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
],
|
||||
];
|
||||
|
||||
if (options?.submitOnEnter) {
|
||||
hotKeys.push(['Enter', () => handleSubmit(text)]);
|
||||
@@ -84,7 +105,22 @@ export default function useReactiveTextInput(
|
||||
if (options?.submitOnCtrlEnter) {
|
||||
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
|
||||
}
|
||||
return getHotkeyHandler(hotKeys);
|
||||
|
||||
const hotKeyHandler = getHotkeyHandler(hotKeys);
|
||||
|
||||
return (event: KeyboardEvent<HTMLElement>) => {
|
||||
// allow moving in input field with arrow keys
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
hotKeyHandler(event);
|
||||
};
|
||||
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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,25 @@
|
||||
import { type PropsWithChildren } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import style from './AppLink.module.scss';
|
||||
|
||||
interface AppLinkProps {
|
||||
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 { search, children } = props;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => navigate({ search });
|
||||
|
||||
return (
|
||||
<button onClick={handleClick} className={style.link}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+10
-5
@@ -1,8 +1,8 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||
|
||||
import { openLink } from '../../utils/linkUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import { openLink } from '../../../utils/linkUtils';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
import style from './ExternalLink.module.scss';
|
||||
|
||||
@@ -14,7 +14,6 @@ interface ExternalLinkProps {
|
||||
|
||||
export default function ExternalLink(props: ExternalLinkProps) {
|
||||
const { href, inline, children } = props;
|
||||
const classes = cx([style.link, inline ? style.inline : null]);
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -22,8 +21,14 @@ export default function ExternalLink(props: ExternalLinkProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
|
||||
{children} <IoOpenOutline />
|
||||
<a
|
||||
href='#!'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={cx([style.link, inline && style.inline])}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
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 style from './NavigationMenu.module.scss';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 style from './NavigationMenu.module.scss';
|
||||
|
||||
@@ -9,10 +9,6 @@ $progress-bar-br: 3px;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||
overflow: clip;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar__indicator {
|
||||
|
||||
@@ -7,16 +7,15 @@ import './ProgressBar.scss';
|
||||
interface ProgressBarProps {
|
||||
current: MaybeNumber;
|
||||
duration: MaybeNumber;
|
||||
hidden?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar(props: ProgressBarProps) {
|
||||
const { current, duration, hidden, className = '' } = props;
|
||||
const { current, duration, className } = props;
|
||||
const progress = getProgress(current, duration);
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
.empty {
|
||||
width: 100%;
|
||||
opacity: 0.6;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.text {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.viewLogo {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-height: 75px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -126,9 +126,9 @@ function MultiOption(props: EditFormMultiOptionProps) {
|
||||
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
|
||||
>
|
||||
{Object.values(paramField.values).map((option) => {
|
||||
const { value, label } = option;
|
||||
const { value, label, colour } = option;
|
||||
return (
|
||||
<MenuItemOption value={value} key={value}>
|
||||
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
|
||||
{label}
|
||||
</MenuItemOption>
|
||||
);
|
||||
|
||||
@@ -16,3 +16,7 @@
|
||||
overflow-y: scroll;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||
import Info from '../info/Info';
|
||||
|
||||
import { ViewOption } from './types';
|
||||
@@ -132,7 +132,9 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
</DrawerHeader>
|
||||
|
||||
<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}>
|
||||
{viewOptions.map((section) => (
|
||||
<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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,34 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import type { ParamField } from './types';
|
||||
import type { MultiselectOptions, ParamField } from './types';
|
||||
|
||||
export const makeOptionsFromCustomFields = (
|
||||
customFields: CustomFields,
|
||||
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}`;
|
||||
return options;
|
||||
}, additionalOptions);
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
|
||||
const options: MultiselectOptions = {};
|
||||
for (const [key, value] of Object.entries(customFields)) {
|
||||
if (filterImageType && value.type === 'image') {
|
||||
continue;
|
||||
}
|
||||
options[key] = { value: key, label: value.label, colour: value.colour };
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export const getTimeOption = (timeFormat: string): ParamField => {
|
||||
const placeholder = `${timeFormat} (default)`;
|
||||
return {
|
||||
|
||||
@@ -12,9 +12,11 @@ type OptionsField = {
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
export type MultiselectOption = { value: string; label: string; colour: string };
|
||||
export type MultiselectOptions = Record<string, MultiselectOption>;
|
||||
type MultiOptionsField = {
|
||||
type: 'multi-option';
|
||||
values: Record<string, { value: string; label: string; colour: string }>;
|
||||
values: MultiselectOptions;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
import { useClientStore } from '../stores/clientStore';
|
||||
import { socketSendJson } from '../utils/socket';
|
||||
@@ -9,10 +10,12 @@ import { useIsOnline } from './useSocket';
|
||||
export const useClientPath = () => {
|
||||
const navigate = useNavigate();
|
||||
const { pathname, search } = useLocation();
|
||||
const { redirect, setRedirect } = useClientStore((store) => ({
|
||||
redirect: store.redirect,
|
||||
setRedirect: store.setRedirect,
|
||||
}));
|
||||
const { redirect, setRedirect } = useClientStore(
|
||||
useShallow((store) => ({
|
||||
redirect: store.redirect,
|
||||
setRedirect: store.setRedirect,
|
||||
})),
|
||||
);
|
||||
const isOnline = useIsOnline();
|
||||
|
||||
// notify of client path changes
|
||||
|
||||
@@ -55,6 +55,17 @@ export const useEventAction = () => {
|
||||
defaultEndAction,
|
||||
} = 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
|
||||
* @private
|
||||
@@ -615,6 +626,7 @@ export const useEventAction = () => {
|
||||
batchUpdateEvents,
|
||||
deleteEvent,
|
||||
deleteAllEvents,
|
||||
getEventById,
|
||||
reorderEvent,
|
||||
swapEvents,
|
||||
updateEvent,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { throttle } from '../../common/utils/throttle';
|
||||
import { throttle } from '../utils/throttle';
|
||||
|
||||
|
||||
export const useFadeOutOnInactivity = () => {
|
||||
const [isMouseMoved, setIsMouseMoved] = useState(false);
|
||||
|
||||
@@ -3,73 +3,54 @@ import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ont
|
||||
import { useRuntimeStore } from '../stores/runtime';
|
||||
import { socketSendJson } from '../utils/socket';
|
||||
|
||||
const createSelector =
|
||||
<T>(selector: (state: RuntimeStore) => T) =>
|
||||
() =>
|
||||
useRuntimeStore(selector);
|
||||
|
||||
export const setClientRemote = {
|
||||
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
|
||||
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
|
||||
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
|
||||
};
|
||||
|
||||
export const useRundownEditor = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
nextEventId: state.eventNext?.id ?? null,
|
||||
});
|
||||
export const useRundownEditor = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
selectedEventId: state.eventNow?.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 = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
});
|
||||
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
|
||||
blackout: state.message.timer.blackout,
|
||||
blink: state.message.timer.blink,
|
||||
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 = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
blackout: state.message.timer.blackout,
|
||||
blink: state.message.timer.blink,
|
||||
secondarySource: state.message.timer.secondarySource,
|
||||
});
|
||||
export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({
|
||||
text: state.message.external,
|
||||
visible: state.message.timer.secondarySource === 'external',
|
||||
}));
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useTimerMessageInput = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
text: state.message.timer.text,
|
||||
visible: state.message.timer.visible,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useExternalMessageInput = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
text: state.message.external,
|
||||
visible: state.message.timer.secondarySource === 'external',
|
||||
});
|
||||
|
||||
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 useMessagePreview = createSelector((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,
|
||||
}));
|
||||
|
||||
export const setMessage = {
|
||||
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
|
||||
@@ -81,16 +62,12 @@ export const setMessage = {
|
||||
socketSendJson('message', { timer: { secondarySource: payload } }),
|
||||
};
|
||||
|
||||
export const usePlaybackControl = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
numEvents: state.runtime.numEvents,
|
||||
timerPhase: state.timer.phase,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
numEvents: state.runtime.numEvents,
|
||||
timerPhase: state.timer.phase,
|
||||
}));
|
||||
|
||||
export const setPlayback = {
|
||||
start: () => socketSendJson('start'),
|
||||
@@ -114,32 +91,20 @@ export const setPlayback = {
|
||||
},
|
||||
};
|
||||
|
||||
export const useInfoPanel = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
eventNow: state.eventNow,
|
||||
eventNext: state.eventNext,
|
||||
playback: state.timer.playback,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
numEvents: state.runtime.numEvents,
|
||||
});
|
||||
export const useInfoPanel = createSelector((state: RuntimeStore) => ({
|
||||
eventNow: state.eventNow,
|
||||
eventNext: state.eventNext,
|
||||
playback: state.timer.playback,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
numEvents: state.runtime.numEvents,
|
||||
}));
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current);
|
||||
|
||||
export const useAuxTimerTime = () => {
|
||||
const featureSelector = (state: RuntimeStore) => state.auxtimer1.current;
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useAuxTimerControl = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.auxtimer1.playback,
|
||||
direction: state.auxtimer1.direction,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.auxtimer1.playback,
|
||||
direction: state.auxtimer1.direction,
|
||||
}));
|
||||
|
||||
export const setAuxTimer = {
|
||||
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
|
||||
@@ -149,20 +114,13 @@ export const setAuxTimer = {
|
||||
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
||||
};
|
||||
|
||||
export const useSelectedEventId = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
});
|
||||
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
}));
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useCurrentBlockId = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
currentBlockId: state.currentBlock.block?.id ?? null,
|
||||
});
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
export const useCurrentBlockId = createSelector((state: RuntimeStore) => ({
|
||||
currentBlockId: state.currentBlock.block?.id ?? null,
|
||||
}));
|
||||
|
||||
export const setEventPlayback = {
|
||||
loadEvent: (id: string) => socketSendJson('load', { id }),
|
||||
@@ -171,89 +129,69 @@ export const setEventPlayback = {
|
||||
pause: () => socketSendJson('pause'),
|
||||
};
|
||||
|
||||
export const useTimer = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
...state.timer,
|
||||
});
|
||||
export const useTimer = createSelector((state: RuntimeStore) => ({
|
||||
...state.timer,
|
||||
}));
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useClock = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
});
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
export const useClock = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
}));
|
||||
|
||||
/** Used by the progress bar components */
|
||||
export const useProgressData = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
current: state.timer.current,
|
||||
duration: state.timer.duration,
|
||||
timeWarning: state.eventNow?.timeWarning ?? null,
|
||||
timeDanger: state.eventNow?.timeDanger ?? null,
|
||||
});
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
export const useProgressData = createSelector((state: RuntimeStore) => ({
|
||||
current: state.timer.current,
|
||||
duration: state.timer.duration,
|
||||
timeWarning: state.eventNow?.timeWarning ?? null,
|
||||
timeDanger: state.eventNow?.timeDanger ?? null,
|
||||
}));
|
||||
|
||||
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
|
||||
|
||||
export const useRuntimeOverview = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
plannedStart: state.runtime.plannedStart,
|
||||
actualStart: state.runtime.actualStart,
|
||||
plannedEnd: state.runtime.plannedEnd,
|
||||
expectedEnd: state.runtime.expectedEnd,
|
||||
});
|
||||
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
|
||||
plannedStart: state.runtime.plannedStart,
|
||||
actualStart: state.runtime.actualStart,
|
||||
plannedEnd: state.runtime.plannedEnd,
|
||||
expectedEnd: state.runtime.expectedEnd,
|
||||
}));
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
clock: state.clock,
|
||||
|
||||
export const useRuntimePlaybackOverview = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
clock: state.clock,
|
||||
numEvents: state.runtime.numEvents,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
offset: state.runtime.offset,
|
||||
|
||||
numEvents: state.runtime.numEvents,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
offset: state.runtime.offset,
|
||||
currentBlock: state.currentBlock,
|
||||
}));
|
||||
|
||||
currentBlock: state.currentBlock,
|
||||
});
|
||||
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offset,
|
||||
}));
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offset,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
|
||||
}));
|
||||
|
||||
export const useTimelineStatus = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offset,
|
||||
});
|
||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.runtime.offset,
|
||||
}));
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const useRuntimeOffset = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
offset: state.runtime.offset,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
|
||||
export const usePing = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
ping: state.ping,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
};
|
||||
export const usePing = createSelector((state: RuntimeStore) => ({
|
||||
ping: state.ping,
|
||||
}));
|
||||
|
||||
/** 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) => ({
|
||||
isOnline: state.ping > 0,
|
||||
playback: state.timer.playback,
|
||||
});
|
||||
|
||||
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
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatTime, nowInMillis } from '../time';
|
||||
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
|
||||
|
||||
describe('nowInMillis()', () => {
|
||||
it('should return the current time in milliseconds', () => {
|
||||
@@ -38,3 +38,84 @@ describe('formatTime()', () => {
|
||||
expect(time).toStrictEqual('-01:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTimeUntilStart()', () => {
|
||||
test('ontime', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 90,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||
});
|
||||
|
||||
test('running behind', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 90,
|
||||
offset: -20,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
|
||||
});
|
||||
|
||||
test('running ahead', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 80,
|
||||
offset: 10,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||
});
|
||||
|
||||
test('running behind with enough gaps', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 20,
|
||||
clock: 50,
|
||||
offset: -20,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||
});
|
||||
|
||||
test('running behind with to little gaps', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 10,
|
||||
clock: 50,
|
||||
offset: -20,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||
});
|
||||
|
||||
//TODO: more indepth testing,
|
||||
// including day offset handling
|
||||
// and more?
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Allows lazy evaluation and caching of a functions result
|
||||
*/
|
||||
export function lazyEvaluate<T>(fn: () => T): () => T {
|
||||
let result: T | undefined;
|
||||
return function () {
|
||||
if (result === undefined) {
|
||||
result = fn();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { baseURI, serverURL } from '../../externals';
|
||||
* Open an external URLs: specifically for a electron / browser case
|
||||
* If electron: ask main process to call a new browser window
|
||||
* If browser: open in new tab
|
||||
* @param url
|
||||
*/
|
||||
export function openLink(url: string) {
|
||||
if (window.process?.type === 'renderer') {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
|
||||
|
||||
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 { ontimeQueryClient } from '../queryClient';
|
||||
import {
|
||||
@@ -34,12 +34,16 @@ export const connectSocket = () => {
|
||||
hasConnected = true;
|
||||
reconnectAttempts = 0;
|
||||
|
||||
socketSendJson('set-client-patch', {
|
||||
type: 'ontime',
|
||||
origin: window.location.origin,
|
||||
path: window.location.pathname + window.location.search,
|
||||
});
|
||||
setOnlineStatus(true);
|
||||
|
||||
if (preferredClientName) {
|
||||
socketSendJson('set-client-name', preferredClientName);
|
||||
}
|
||||
|
||||
socketSendJson('set-client-type', 'ontime');
|
||||
setOnlineStatus(true);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
@@ -78,16 +82,14 @@ export const connectSocket = () => {
|
||||
updateDevTools({ ping: offset });
|
||||
break;
|
||||
}
|
||||
case 'client-id': {
|
||||
if (typeof payload === 'string') {
|
||||
setClientId(payload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'client-name': {
|
||||
if (typeof payload === 'string') {
|
||||
setClientName(payload);
|
||||
case 'client': {
|
||||
if (typeof payload === 'object' || payload !== null) {
|
||||
if (payload.clientId && payload.clientName) {
|
||||
setClientId(payload.clientId);
|
||||
if (!preferredClientName) {
|
||||
setClientName(payload.clientName);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -196,19 +198,23 @@ export const connectSocket = () => {
|
||||
}
|
||||
case 'ontime-refetch': {
|
||||
// the refetch message signals that the rundown has changed in the server side
|
||||
const { revision, reload } = payload;
|
||||
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
|
||||
|
||||
const { reload, target } = payload;
|
||||
if (reload) {
|
||||
invalidateAllCaches();
|
||||
} else if (revision > currentRevision) {
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
||||
} else if (target === 'RUNDOWN') {
|
||||
const { revision } = payload;
|
||||
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;
|
||||
}
|
||||
case 'ontime-flush': {
|
||||
flushBatchUpdates()
|
||||
flushBatchUpdates();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
|
||||
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
|
||||
import { APP_SETTINGS } from '../api/constants';
|
||||
import { useTimeUntilData } from '../hooks/useSocket';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
/**
|
||||
@@ -125,3 +126,71 @@ export function formatDuration(duration: number, hideSeconds = true): string {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param totalGap accumulated gap from the current event
|
||||
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
||||
* @returns
|
||||
*/
|
||||
export function useTimeUntilStart(
|
||||
// typed like this to make it very clear what the data is
|
||||
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
},
|
||||
): number {
|
||||
const { offset, clock, currentDay } = useTimeUntilData();
|
||||
return calculateTimeUntilStart({ ...data, currentDay, clock, offset });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param currentDay the day offset of the urrently running event
|
||||
* @param totalGap accumulated gap from the current event
|
||||
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
||||
* @param clock
|
||||
* @param offset
|
||||
* @returns
|
||||
*/
|
||||
export function calculateTimeUntilStart(
|
||||
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
|
||||
currentDay: number;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
clock: number;
|
||||
offset: number;
|
||||
},
|
||||
): number {
|
||||
const { timeStart, dayOffset, currentDay, totalGap, isLinkedToLoaded, clock, offset, delay } = data;
|
||||
|
||||
//How many days from the currently running event to this one
|
||||
const relativeDayOffset = dayOffset - currentDay;
|
||||
|
||||
const delayedStart = Math.max(0, timeStart + delay);
|
||||
|
||||
//The normalised start time of this event relative to the currently running event
|
||||
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
|
||||
|
||||
const offsetTimestart = normalisedTimeStart - offset;
|
||||
const offsetTimeUntil = offsetTimestart - clock;
|
||||
|
||||
if (isLinkedToLoaded) {
|
||||
//if we are directly linked back to the loaded event we just follow the offset
|
||||
return offsetTimeUntil;
|
||||
}
|
||||
|
||||
const scheduledTimeUntil = normalisedTimeStart - clock;
|
||||
|
||||
const isAheadOfSchedule = offset >= 0;
|
||||
const gapsCanCompensadeForOffset = totalGap + offset >= 0;
|
||||
|
||||
if (isAheadOfSchedule || gapsCanCompensadeForOffset) {
|
||||
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
|
||||
return scheduledTimeUntil;
|
||||
}
|
||||
|
||||
// otherwise consume as much of the offset as possible with the gap
|
||||
const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap;
|
||||
return offsetTimeUntilBufferedByGaps;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@ export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
|
||||
export const appVersion = version;
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
export const isDev = !isProduction;
|
||||
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
export const currentHostName = window.location.hostname;
|
||||
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
|
||||
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
|
||||
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
|
||||
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no');
|
||||
|
||||
// resolve entrypoint URLs
|
||||
|
||||
@@ -75,4 +76,4 @@ function resolveBaseURI(): string {
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ $inner-padding: 1rem;
|
||||
color: $muted-gray;
|
||||
|
||||
td {
|
||||
padding-block: 1rem;
|
||||
padding-block: 5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
|
||||
@@ -67,9 +67,17 @@ export function TableEmpty({ label, handleClick }: { label?: string; handleClick
|
||||
<tr className={style.empty}>
|
||||
<td colSpan={99}>
|
||||
<div>{label ?? 'No data yet'}</div>
|
||||
<Button onClick={handleClick} isDisabled={!handleClick} variant='ontime-filled' rightIcon={<IoAdd />} size='sm'>
|
||||
New
|
||||
</Button>
|
||||
{handleClick && (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
isDisabled={!handleClick}
|
||||
variant='ontime-filled'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import {
|
||||
buyMeACoffeeUrl,
|
||||
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 { appVersion } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
+14
-4
@@ -27,7 +27,8 @@
|
||||
.titleSection,
|
||||
.filterSection,
|
||||
.oscSection,
|
||||
.httpSection {
|
||||
.httpSection,
|
||||
.actionSection {
|
||||
display: grid;
|
||||
grid-gap: 0.5rem;
|
||||
|
||||
@@ -40,8 +41,10 @@
|
||||
.ruleSection,
|
||||
.filterSection,
|
||||
.oscSection,
|
||||
.httpSection {
|
||||
label, div {
|
||||
.httpSection,
|
||||
.actionSection {
|
||||
label,
|
||||
div {
|
||||
// we use the div as non-interactive placeholder for button cells
|
||||
// it needs to match the size of the label element
|
||||
font-size: calc(1rem - 3px);
|
||||
@@ -51,7 +54,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.titleSection {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -68,6 +70,14 @@
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.actionSection {
|
||||
grid-template-columns: auto 1fr 1fr auto;
|
||||
|
||||
.test {
|
||||
grid-column: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.outputCard {
|
||||
border-left: 0.25rem solid $gray-1200;
|
||||
padding-left: 0.5rem;
|
||||
|
||||
+151
-103
@@ -1,23 +1,23 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
Button,
|
||||
IconButton,
|
||||
Input,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
} from '@chakra-ui/react';
|
||||
import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
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 { 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 useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
@@ -27,10 +27,11 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import TemplateInput from './template-input/TemplateInput';
|
||||
import { isAutomation, makeFieldList } from './automationUtils';
|
||||
import OntimeActionForm from './OntimeActionForm';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/integrations/#using-variables-in-integrations';
|
||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
|
||||
|
||||
interface AutomationFormProps {
|
||||
automation: Automation | AutomationDTO;
|
||||
@@ -51,6 +52,7 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
register,
|
||||
setError,
|
||||
setFocus,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<AutomationDTO>({
|
||||
mode: 'onChange',
|
||||
@@ -101,6 +103,11 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
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) => {
|
||||
try {
|
||||
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) => {
|
||||
if (isAutomation(automation)) {
|
||||
await handleEdit(automation.id, { id: automation.id, ...values });
|
||||
@@ -190,7 +210,7 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
</div>
|
||||
|
||||
<div className={style.innerSection}>
|
||||
<h3>Filters</h3>
|
||||
<h3>Filters (optional)</h3>
|
||||
<div className={style.ruleSection}>
|
||||
<label>
|
||||
Trigger outputs if
|
||||
@@ -205,74 +225,80 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
{fieldFilters.map((field, index) => (
|
||||
<div key={field.id} className={style.filterSection}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select
|
||||
{...register(`filters.${index}.field`, { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
>
|
||||
<option selected hidden disabled value=''>
|
||||
Event field
|
||||
</option>
|
||||
{fieldList.map(({ value, label }) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
{fieldFilters.map((field, index) => {
|
||||
const key = `filters.${index}.field.${field.id}`;
|
||||
return (
|
||||
<div key={key} className={style.filterSection}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select
|
||||
{...register(`filters.${index}.field`, { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
>
|
||||
<option selected hidden disabled value=''>
|
||||
Event field
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
{...register(`filters.${index}.operator`, { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
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>
|
||||
{/*
|
||||
{fieldList.map(({ value, label }, localIndex) => {
|
||||
const key = `filters.${index}.field.${localIndex}`;
|
||||
return (
|
||||
<option key={key} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
{...register(`filters.${index}.operator`, { required: { value: true, message: 'Required field' } })}
|
||||
size='sm'
|
||||
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
|
||||
<option value='greater_than'>greater than</option>
|
||||
<option value='less_than'>less than</option>
|
||||
*/}
|
||||
</Select>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Value to match
|
||||
<Input
|
||||
{...register(`filters.${index}.value`)}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='<empty / no value>'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<div>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-ghosted'
|
||||
</Select>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Value to match
|
||||
<Input
|
||||
{...register(`filters.${index}.value`)}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
color='#FA5656' // $red-500
|
||||
onClick={() => removeFilter(index)}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
placeholder='<empty / no value>'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<span> </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>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
@@ -291,13 +317,10 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Automation outputs can be used to send data from Ontime to external software.
|
||||
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Info>
|
||||
Automation outputs can be used to send data from Ontime to external software.
|
||||
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
|
||||
</Info>
|
||||
|
||||
{fieldOutputs.map((output, index) => {
|
||||
if (isOSCOutput(output)) {
|
||||
@@ -357,13 +380,13 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Parameters
|
||||
Arguments
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.args`)}
|
||||
value={output.args}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='1'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
@@ -380,8 +403,6 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
size='sm'
|
||||
onClick={() => removeOutput(index)}
|
||||
color='#FA5656' // $red-500
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
@@ -429,8 +450,6 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
size='sm'
|
||||
onClick={() => removeOutput(index)}
|
||||
color='#FA5656' // $red-500
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
@@ -438,30 +457,59 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
</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> </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
|
||||
return null;
|
||||
})}
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
onClick={handleAddNewOSCOutput}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewOSCOutput}>
|
||||
OSC
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
onClick={handleAddNewHTTPOutput}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewHTTPOutput}>
|
||||
HTTP
|
||||
</Button>
|
||||
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddnewOntimeAction}>
|
||||
Ontime action
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
|
||||
|
||||
+13
-10
@@ -1,11 +1,13 @@
|
||||
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 { 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 { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/';
|
||||
@@ -75,14 +77,13 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
|
||||
<Panel.Divider />
|
||||
|
||||
<Panel.Section>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Control Ontime and share its data with external systems in your workflow. <br />
|
||||
- Automations allow Ontime to send its data on lifecycle triggers. <br />- OSC Input tells Ontime to listen
|
||||
to messages on the specific port. <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Info>
|
||||
<p>Control Ontime and share its data with external systems in your workflow.</p>
|
||||
<p>- Automations allow Ontime to send its data on lifecycle triggers.</p>
|
||||
<p>- OSC Input tells Ontime to listen to messages on the specific port.</p>
|
||||
<br />
|
||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
|
||||
<Panel.Section
|
||||
@@ -112,7 +113,9 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
|
||||
</Panel.ListGroup>
|
||||
|
||||
<Panel.Title>OSC Input</Panel.Title>
|
||||
|
||||
<Panel.ListGroup>
|
||||
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
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>
|
||||
{!showForm && triggers.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
label='Create an automation before to attach triggers to'
|
||||
label='Create an automation to attach triggers to'
|
||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { forwardRef, useMemo, useState } from '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';
|
||||
|
||||
@@ -10,10 +10,10 @@ import style from './TemplateInput.module.scss';
|
||||
|
||||
interface TemplateInputProps extends InputProps {}
|
||||
|
||||
export default function TemplateInput(props: TemplateInputProps) {
|
||||
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
|
||||
const { value, onChange, ...rest } = props;
|
||||
const { data } = useCustomFields();
|
||||
const ref = useClickOutside(() => setShowSuggestions(false));
|
||||
const localRef = useClickOutside(() => setShowSuggestions(false));
|
||||
|
||||
const autocompleteList = useMemo(() => {
|
||||
return makeAutoCompleteList(data);
|
||||
@@ -31,15 +31,13 @@ export default function TemplateInput(props: TemplateInputProps) {
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
|
||||
if (event.target.value.endsWith('{{')) {
|
||||
if (event.target.value.endsWith('{')) {
|
||||
setShowSuggestions(true);
|
||||
setSuggestions(updateSuggestions(event.target.value));
|
||||
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
|
||||
if (showSuggestions) {
|
||||
const suggestions = updateSuggestions(event.target.value);
|
||||
setSuggestions(suggestions);
|
||||
} else if (showSuggestions) {
|
||||
setSuggestions(updateSuggestions(event.target.value));
|
||||
}
|
||||
|
||||
onChange?.(event);
|
||||
@@ -54,8 +52,8 @@ export default function TemplateInput(props: TemplateInputProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.wrapper} ref={ref}>
|
||||
<Input {...rest} value={inputValue} onChange={handleInputChange} autoComplete='off' autoCorrect='off' />
|
||||
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
|
||||
<Input value={inputValue} {...rest} onChange={handleInputChange} autoComplete='off' autoCorrect='off' />
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<ul className={style.suggestions}>
|
||||
{suggestions.map((suggestion) => (
|
||||
@@ -67,4 +65,6 @@ export default function TemplateInput(props: TemplateInputProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default TemplateInput;
|
||||
|
||||
+2
-4
@@ -6,10 +6,8 @@ describe('matchRemaining()', () => {
|
||||
expect(matchRemaining('{{{hum', '{{human}}')).toBe('an}}');
|
||||
expect(matchRemaining('send {', '{{human}}')).toBe('{human}}');
|
||||
|
||||
// we should be able to match the following
|
||||
// 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}}');
|
||||
expect(matchRemaining('{{', '{{human}}')).toBe('human}}');
|
||||
});
|
||||
|
||||
it('should return an empty string if there are no matches or if it is complete', () => {
|
||||
|
||||
+10
@@ -83,6 +83,16 @@ export function matchRemaining(a: string, b: string) {
|
||||
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++) {
|
||||
const searchString = b.substring(0, i + 1);
|
||||
if (a.endsWith(searchString)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge, Button, useDisclosure } from '@chakra-ui/react';
|
||||
import { Client } from 'ontime-types';
|
||||
|
||||
import { RedirectClientModal } from '../../../../common/components/client-modal/RedirectClientModal';
|
||||
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 otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
|
||||
|
||||
const targetClient = clients[targetId];
|
||||
const targetClient: Client | undefined = clients[targetId];
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpenRedirect && (
|
||||
{isOpenRedirect && targetClient !== undefined && (
|
||||
<RedirectClientModal
|
||||
id={targetId}
|
||||
name={targetClient?.name}
|
||||
path={targetClient?.path}
|
||||
name={targetClient.name}
|
||||
origin={targetClient.origin}
|
||||
currentPath={targetClient.path}
|
||||
isOpen={isOpenRedirect}
|
||||
onClose={onCloseRedirect}
|
||||
/>
|
||||
@@ -52,7 +54,7 @@ export default function ClientList() {
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td className={style.halfWidth}>Client Name (Connection ID)</td>
|
||||
<td className={style.halfWidth}>Client Name</td>
|
||||
<td className={style.fullWidth}>Path</td>
|
||||
<td />
|
||||
</tr>
|
||||
@@ -64,9 +66,6 @@ export default function ClientList() {
|
||||
return (
|
||||
<tr key={key}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Badge variant='outline' size='xs'>
|
||||
{key}
|
||||
</Badge>
|
||||
{isCurrent && (
|
||||
<Badge variant='outline' colorScheme='yellow' size='xs'>
|
||||
self
|
||||
@@ -119,7 +118,7 @@ export default function ClientList() {
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td className={style.halfWidthNoWrap}>Client Name (Connection ID)</td>
|
||||
<td className={style.halfWidthNoWrap}>Client Name</td>
|
||||
<td className={style.halfWidthNoWrap}>Client type</td>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -129,12 +128,7 @@ export default function ClientList() {
|
||||
|
||||
return (
|
||||
<tr key={key}>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Badge variant='outline' size='sx'>
|
||||
{key}
|
||||
</Badge>
|
||||
{name}
|
||||
</Panel.InlineElements>
|
||||
<td>{name}</td>
|
||||
<td>{type}</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
+6
@@ -3,11 +3,13 @@ import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import CustomFields from './custom-fields/CustomFields';
|
||||
import ReportSettings from './ReportSettings';
|
||||
import UrlPresetsForm from './UrlPresetsForm';
|
||||
|
||||
export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
|
||||
const customFieldsRef = useScrollIntoView<HTMLDivElement>('custom', location);
|
||||
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
|
||||
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -19,6 +21,10 @@ export default function FeatureSettingsPanel({ location }: PanelBaseProps) {
|
||||
<div ref={urlPresetsRef}>
|
||||
<UrlPresetsForm />
|
||||
</div>
|
||||
|
||||
<div ref={reportRef}>
|
||||
<ReportSettings />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
th.over {
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
|
||||
th.under {
|
||||
color: $playback-ahead;
|
||||
}
|
||||
+113
@@ -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>
|
||||
);
|
||||
}
|
||||
+26
-28
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
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 { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||
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 { maybeAxiosError } from '../../../../common/api/utils';
|
||||
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 { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
@@ -108,32 +109,29 @@ export default function UrlPresetsForm() {
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
URL presets are user defined aliases to Ontime URLs
|
||||
<br />
|
||||
<br />
|
||||
<b>Preset Name</b> <br />
|
||||
The alias for the URL. This will be the URL you will be calling. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
|
||||
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
<b>URL Segment</b> <br />
|
||||
The corresponding alias path and configuration parameters. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to
|
||||
complete URL
|
||||
<Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
You will need to save the changes before the presets are functional.
|
||||
<br />
|
||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Info>
|
||||
URL presets are user defined aliases to Ontime URLs
|
||||
<br />
|
||||
<br />
|
||||
<b>Preset Name</b> <br />
|
||||
The alias for the URL. This will be the URL you will be calling. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
|
||||
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
<b>URL Segment</b> <br />
|
||||
The corresponding alias path and configuration parameters. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to
|
||||
complete URL
|
||||
<Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
You will need to save the changes before the presets are functional.
|
||||
<br />
|
||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.Title>
|
||||
|
||||
+12
-7
@@ -6,6 +6,7 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
|
||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
@@ -13,19 +14,20 @@ import CustomFieldForm from './CustomFieldForm';
|
||||
import style from '../FeatureSettings.module.scss';
|
||||
|
||||
interface CustomFieldEntryProps {
|
||||
field: string;
|
||||
colour: string;
|
||||
label: string;
|
||||
fieldKey: string;
|
||||
type: 'string' | 'image';
|
||||
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
|
||||
onDelete: (label: CustomFieldLabel) => Promise<void>;
|
||||
}
|
||||
|
||||
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 handleEdit = async (patch: CustomField) => {
|
||||
await onEdit(field, patch);
|
||||
await onEdit(fieldKey, patch);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
@@ -38,7 +40,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
onSubmit={handleEdit}
|
||||
initialColour={colour}
|
||||
initialLabel={label}
|
||||
initialKey={field}
|
||||
initialKey={fieldKey}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -50,10 +52,13 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
<td>
|
||||
<Swatch color={colour} />
|
||||
</td>
|
||||
<td>
|
||||
<Tag>{type}</Tag>
|
||||
</td>
|
||||
<td className={style.halfWidth}>{label}</td>
|
||||
<td className={style.fullWidth}>
|
||||
<CopyTag label='Copy key to use in integrations' copyValue={field}>
|
||||
{field}
|
||||
<CopyTag label='Copy key to use in integrations' copyValue={fieldKey}>
|
||||
{fieldKey}
|
||||
</CopyTag>
|
||||
</td>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
@@ -71,7 +76,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => onDelete(field)}
|
||||
onClick={() => onDelete(fieldKey)}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
|
||||
+31
-9
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Button, Input, Radio, RadioGroup } from '@chakra-ui/react';
|
||||
import { CustomField } from 'ontime-types';
|
||||
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import Info from '../../../../../common/components/info/Info';
|
||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||
@@ -20,6 +21,8 @@ interface CustomFieldsFormProps {
|
||||
initialKey?: string;
|
||||
}
|
||||
|
||||
type CustomFieldFormData = CustomField & { key: string };
|
||||
|
||||
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
|
||||
const { data } = useCustomFields();
|
||||
@@ -28,6 +31,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
const [_, setColour] = useState(initialColour || '');
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
setFocus,
|
||||
@@ -35,17 +39,17 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm({
|
||||
defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' },
|
||||
} = useForm<CustomFieldFormData>({
|
||||
defaultValues: { type: 'string', label: initialLabel || '', colour: initialColour || '' },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const setupSubmit = async (values: { label: string; colour: string }) => {
|
||||
const { label, colour } = values;
|
||||
const setupSubmit = async (values: CustomFieldFormData) => {
|
||||
const { type, label, colour } = values;
|
||||
const newField: CustomField = {
|
||||
type: 'string', // type is not user definable yet
|
||||
type,
|
||||
colour,
|
||||
label,
|
||||
};
|
||||
@@ -77,6 +81,26 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
className={style.fieldForm}
|
||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
||||
>
|
||||
<Info>
|
||||
Please note that images can quickly deteriorate your app'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>
|
||||
<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' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Panel.Description>Colour</Panel.Description>
|
||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||
</div>
|
||||
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.InlineElements relation='inner' align='end'>
|
||||
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
||||
|
||||
+18
-18
@@ -1,10 +1,11 @@
|
||||
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 { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
|
||||
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 { customFieldsDocsUrl } from '../../../../../externals';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
@@ -55,20 +56,17 @@ export default function CustomFields() {
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
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.
|
||||
light, sound, camera).
|
||||
<br />
|
||||
<br />
|
||||
Custom fields can be used width the Integrations feature using the generated key.
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Info>
|
||||
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.
|
||||
light, sound, camera).
|
||||
<br />
|
||||
<br />
|
||||
Custom fields can be used width the Integrations feature using the generated key.
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
|
||||
@@ -76,19 +74,21 @@ export default function CustomFields() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Colour</th>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Key (used in Integrations)</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(data).map(([key, { colour, label }]) => {
|
||||
{Object.entries(data).map(([key, { colour, label, type }]) => {
|
||||
return (
|
||||
<CustomFieldEntry
|
||||
key={key}
|
||||
field={key}
|
||||
fieldKey={key}
|
||||
colour={colour}
|
||||
label={label}
|
||||
type={type}
|
||||
onEdit={handleEditField}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
+64
@@ -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 { 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 { maybeAxiosError } from '../../../../common/api/utils';
|
||||
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 ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
|
||||
@@ -85,16 +87,19 @@ export default function ViewSettingsForm() {
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
You can the Ontime views or customise its styles by modifying the provided CSS file. <br />
|
||||
The CSS file is in the user directory at {`${info.publicDir}/user/styles/override.css`}
|
||||
<br />
|
||||
<br />
|
||||
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Info>
|
||||
You can the Ontime views or customise its styles by modifying the provided CSS file.
|
||||
<br />
|
||||
{!isOntimeCloud && (
|
||||
<>
|
||||
<br />
|
||||
The loaded CSS file is in the user directory at{' '}
|
||||
<Panel.BlockQuote>{`${info.publicDir}/user/styles/override.css`}</Panel.BlockQuote>
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,3 +3,12 @@
|
||||
background: $ui-white;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.column {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: $label-gray;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
@@ -5,14 +5,13 @@ import { Button, Select, Switch } from '@chakra-ui/react';
|
||||
|
||||
import { generateUrl } from '../../../../common/api/session';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import copyToClipboard from '../../../../common/utils/copyToClipboard';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { linkToOtherHost } from '../../../../common/utils/linkUtils';
|
||||
import { serverURL } from '../../../../externals';
|
||||
import { currentHostName, isOntimeCloud, serverURL } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './GenerateLinkForm.module.scss';
|
||||
@@ -39,6 +38,12 @@ export default function GenerateLinkForm() {
|
||||
formState: { errors },
|
||||
} = useForm<GenerateLinkFormOptions>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
baseUrl: currentHostName,
|
||||
path: '',
|
||||
lock: false,
|
||||
authenticate: false,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
@@ -72,8 +77,11 @@ export default function GenerateLinkForm() {
|
||||
</Info>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Host IP' description='Which IP address will be used' />
|
||||
<Select variant='ontime' size='sm' {...register('baseUrl')}>
|
||||
<Panel.Field
|
||||
title='Host IP'
|
||||
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
|
||||
/>
|
||||
<Select variant='ontime' isDisabled={isOntimeCloud} size='sm' {...register('baseUrl')}>
|
||||
{infoData.networkInterfaces.map((nif) => {
|
||||
return (
|
||||
<option key={nif.name} value={nif.address}>
|
||||
@@ -128,9 +136,9 @@ export default function GenerateLinkForm() {
|
||||
>
|
||||
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
|
||||
</Button>
|
||||
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<div className={style.column}>
|
||||
<QRCode size={172} value={url} className={style.qrCode} />
|
||||
<ExternalLink href={url}>{url}</ExternalLink>
|
||||
<div>{url}</div>
|
||||
</div>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { usePing } from '../../../../common/hooks/useSocket';
|
||||
import { socketSendJson } from '../../../../common/utils/socket';
|
||||
import { isDockerImage } from '../../../../externals';
|
||||
import { isDockerImage, isOntimeCloud } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||
@@ -30,8 +30,12 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
<InfoNif />
|
||||
{!isOntimeCloud && (
|
||||
<>
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
<InfoNif />
|
||||
</>
|
||||
)}
|
||||
<GenerateLinkForm />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function ProjectList() {
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.containCell}>Project Name</th>
|
||||
<th className={style.containCell}>File Name</th>
|
||||
<th>Last Used</th>
|
||||
<th />
|
||||
</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/';
|
||||
|
||||
export default function GSheetInfo() {
|
||||
return (
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
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 />
|
||||
Once set up, you will be able to synchronize data between Ontime and your Google Sheet. <br />
|
||||
<ExternalLink href={googleSheetDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Info>
|
||||
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 />
|
||||
Once set up, you will be able to synchronize data between Ontime and your Google Sheet. <br />
|
||||
<ExternalLink href={googleSheetDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,15 +55,14 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
setLoading('');
|
||||
};
|
||||
|
||||
const handleCancelFlow = async () => {
|
||||
const handleCancelFlow = () => {
|
||||
onCancel();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets file from input
|
||||
* @param event
|
||||
*/
|
||||
const handleClientSecret = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const handleClientSecret = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const namedImportMap = {
|
||||
'Timer type': 'timer type',
|
||||
'Time warning': 'warning time',
|
||||
'Time danger': 'danger time',
|
||||
ID: 'id',
|
||||
custom: [] as ImportCustom[],
|
||||
};
|
||||
|
||||
@@ -58,6 +59,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
||||
timeWarning: namedImportMap['Time warning'],
|
||||
timeDanger: namedImportMap['Time danger'],
|
||||
custom,
|
||||
entryId: namedImportMap.ID,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
{fieldLabels.map((label) => (
|
||||
<th key={label}>{label}</th>
|
||||
))}
|
||||
<th>ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -113,6 +114,9 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
}
|
||||
return <td key={field}>{value}</td>;
|
||||
})}
|
||||
<td className={style.center}>
|
||||
<Tag>{event.id}</Tag>
|
||||
</td>
|
||||
</tr>
|
||||
{event.note && (
|
||||
<tr>
|
||||
|
||||
@@ -35,6 +35,7 @@ const staticOptions = [
|
||||
secondary: [
|
||||
{ id: 'feature_settings__custom', label: 'Custom fields' },
|
||||
{ id: 'feature_settings__urlpresets', label: 'URL Presets' },
|
||||
{ id: 'feature_settings__report', label: 'Report' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
||||
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 noEvents = numEvents === 0;
|
||||
|
||||
const disableGo = isRolling || noEvents || (isLast && !isArmed);
|
||||
const disableGo = isRolling || noEvents;
|
||||
const disableNext = isRolling || noEvents || isLast;
|
||||
const disablePrev = isRolling || noEvents || isFirst;
|
||||
|
||||
@@ -45,14 +46,16 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
const disableStop = !playbackCan.stop;
|
||||
const disableReload = !playbackCan.reload;
|
||||
|
||||
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next';
|
||||
const goModeAction = () => {
|
||||
const [goModeAction, goModeText] = useMemo(() => {
|
||||
if (isArmed) {
|
||||
setPlayback.start();
|
||||
} else {
|
||||
setPlayback.startNext();
|
||||
return [setPlayback.start, 'Start'];
|
||||
} else if (isLast) {
|
||||
return [setPlayback.stop, 'Finish'];
|
||||
} else if (selectedEventIndex === null) {
|
||||
return [setPlayback.startNext, 'Start'];
|
||||
}
|
||||
};
|
||||
return [setPlayback.startNext, 'Next'];
|
||||
}, [isArmed, isLast, selectedEventIndex]);
|
||||
|
||||
return (
|
||||
<div className={style.buttonContainer}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { Playback, TimerPhase } from 'ontime-types';
|
||||
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
|
||||
import { dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import useReport from '../../../../common/hooks-query/useReport';
|
||||
import { formatDuration } from '../../../../common/utils/time';
|
||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||
|
||||
@@ -29,10 +31,6 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
||||
const { playback, children } = props;
|
||||
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 isWaiting = timer.phase === TimerPhase.Pending;
|
||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||
@@ -58,19 +56,51 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
||||
{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>
|
||||
</>
|
||||
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={playback} />
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RunningStatusProps {
|
||||
startedAt: MaybeNumber;
|
||||
expectedFinish: MaybeNumber;
|
||||
playback: Playback;
|
||||
}
|
||||
function RunningStatus(props: RunningStatusProps) {
|
||||
const { startedAt, expectedFinish, playback } = props;
|
||||
|
||||
if (playback === Playback.Stop) {
|
||||
return <StoppedStatus />;
|
||||
}
|
||||
|
||||
const started = millisToString(startedAt);
|
||||
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
|
||||
const finish = millisToString(finishedMs);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StoppedStatus() {
|
||||
const { data } = useReport();
|
||||
const hasReport = Object.keys(data).length > 0;
|
||||
|
||||
if (hasReport) {
|
||||
return <AppLink search='settings=feature_settings__report'>Go to report management</AppLink>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button, Checkbox, Modal, ModalBody, ModalCloseButton, ModalContent, Mod
|
||||
import { loadDemo, loadProject } from '../../../common/api/db';
|
||||
import { postShowWelcomeDialog } from '../../../common/api/settings';
|
||||
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 * as Editor from '../editor-utils/EditorUtils';
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function Welcome(props: WelcomeProps) {
|
||||
<table className={style.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project Name</th>
|
||||
<th>File Name</th>
|
||||
<th>Last Used</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function Operator() {
|
||||
|
||||
// subscriptions is a MultiSelect and may have multiple values
|
||||
const subscriptions = searchParams.getAll('subscribe').filter((value) => Object.hasOwn(customFields, value));
|
||||
const canEdit = shouldEdit && subscriptions;
|
||||
const canEdit = shouldEdit && subscriptions.length;
|
||||
|
||||
const main = searchParams.get('main') as keyof TitleFields | null;
|
||||
const secondary = searchParams.get('secondary');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CustomFields } from 'ontime-types';
|
||||
|
||||
import {
|
||||
getTimeOption,
|
||||
makeCustomFieldSelectOptions,
|
||||
makeOptionsFromCustomFields,
|
||||
OptionTitle,
|
||||
} from '../../common/components/view-params-editor/constants';
|
||||
@@ -9,13 +10,7 @@ import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||
|
||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
||||
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||
|
||||
const customFieldSelect = Object.entries(customFields).reduce<
|
||||
Record<string, { value: string; label: string; colour: string }>
|
||||
>((acc, [key, field]) => {
|
||||
acc[key] = { value: key, label: field.label, colour: field.colour };
|
||||
return acc;
|
||||
}, {});
|
||||
const customFieldSelect = makeCustomFieldSelectOptions(customFields);
|
||||
|
||||
return [
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
@@ -46,6 +41,13 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
type: 'multi-option',
|
||||
values: customFieldSelect,
|
||||
},
|
||||
{
|
||||
id: 'shouldEdit',
|
||||
title: 'Edit custom field',
|
||||
description: 'Allows editing an highlighted custom field by long pressing',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -59,13 +61,6 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'shouldEdit',
|
||||
title: 'Edit custom field',
|
||||
description: 'Allows editing an events selected custom field by long pressing.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
}
|
||||
|
||||
.ahead {
|
||||
color: $green-500;
|
||||
color: $playback-ahead;
|
||||
}
|
||||
|
||||
.behind {
|
||||
|
||||
@@ -269,8 +269,9 @@ export default function Rundown({ data }: RundownProps) {
|
||||
// all events before the current selected are in the past
|
||||
let isPast = Boolean(featureData?.selectedEventId);
|
||||
let isNextDay = false;
|
||||
let totalGap = 0;
|
||||
const isEditMode = appMode === AppMode.Edit;
|
||||
|
||||
let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
|
||||
return (
|
||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
||||
@@ -297,6 +298,10 @@ export default function Rundown({ data }: RundownProps) {
|
||||
|
||||
if (isPlayableEvent(entry)) {
|
||||
isNextDay = checkIsNextDay(entry, lastEvent);
|
||||
if (!isPast) {
|
||||
totalGap += entry.gap;
|
||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null;
|
||||
}
|
||||
if (isNewLatest(entry, lastEvent)) {
|
||||
// populate previous entry
|
||||
thisEvent = entry;
|
||||
@@ -331,6 +336,8 @@ export default function Rundown({ data }: RundownProps) {
|
||||
playback={isLoaded ? featureData.playback : undefined}
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
isNextDay={isNextDay}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,8 @@ export type EventItemActions =
|
||||
| 'delete'
|
||||
| 'clone'
|
||||
| 'update'
|
||||
| 'swap';
|
||||
| 'swap'
|
||||
| 'clear-report';
|
||||
|
||||
interface RundownEntryProps {
|
||||
type: SupportedEvent;
|
||||
@@ -37,6 +38,8 @@ interface RundownEntryProps {
|
||||
previousEventId?: string;
|
||||
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
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
export default function RundownEntry(props: RundownEntryProps) {
|
||||
@@ -52,6 +55,8 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
isRolling,
|
||||
eventIndex,
|
||||
isNextDay,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
} = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
||||
@@ -173,6 +178,9 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
isRolling={isRolling}
|
||||
gap={data.gap}
|
||||
isNextDay={isNextDay}
|
||||
dayOffset={data.dayOffset}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -51,6 +51,8 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
|
||||
fontWeight='600'
|
||||
letterSpacing='0.25px'
|
||||
paddingLeft='0'
|
||||
size='sm'
|
||||
fontSize='1rem'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,16 +9,17 @@ $skip-opacity: 0.2;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'binder ... ... ...'
|
||||
'binder pb-actions times ...'
|
||||
'binder pb-actions times chip'
|
||||
'binder pb-actions title title'
|
||||
'binder pb-actions estatus estatus'
|
||||
'binder ... ... ...';
|
||||
|
||||
grid-template-columns: $block-binder-width 3rem 1fr auto;
|
||||
grid-template-rows: 0.25rem 2.25rem 2.25rem 2.25rem 0.25rem;
|
||||
grid-template-rows: 0.125rem 2rem 2rem 2rem 0.125rem;
|
||||
align-items: center;
|
||||
padding-right: $block-clearance;
|
||||
gap: 2px;
|
||||
row-gap: 0.25rem;
|
||||
|
||||
transition-property: background-color;
|
||||
transition-duration: $transition-time-feedback;
|
||||
@@ -120,7 +121,7 @@ $skip-opacity: 0.2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 0.5rem;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.eventTimers {
|
||||
@@ -131,6 +132,10 @@ $skip-opacity: 0.2;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chipSection {
|
||||
grid-area: chip;
|
||||
}
|
||||
|
||||
.titleSection {
|
||||
grid-area: title;
|
||||
display: flex;
|
||||
|
||||
@@ -49,6 +49,9 @@ interface EventBlockProps {
|
||||
isRolling: boolean;
|
||||
gap: number;
|
||||
isNextDay: boolean;
|
||||
dayOffset: number;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
actionHandler: (
|
||||
action: EventItemActions,
|
||||
payload?:
|
||||
@@ -87,6 +90,9 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
isRolling,
|
||||
gap,
|
||||
isNextDay,
|
||||
dayOffset,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
actionHandler,
|
||||
} = props;
|
||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||
@@ -171,7 +177,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
},
|
||||
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') },
|
||||
],
|
||||
);
|
||||
@@ -303,6 +309,10 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
loaded={loaded}
|
||||
playback={playback}
|
||||
isRolling={isRolling}
|
||||
dayOffset={dayOffset}
|
||||
isPast={isPast}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
|
||||
|
||||
import EventBlockChip from './composite/EventBlockChip';
|
||||
import EventBlockPlayback from './composite/EventBlockPlayback';
|
||||
import EventBlockProgressBar from './composite/EventBlockProgressBar';
|
||||
|
||||
@@ -42,6 +43,10 @@ interface EventBlockInnerProps {
|
||||
loaded: boolean;
|
||||
playback?: Playback;
|
||||
isRolling: boolean;
|
||||
dayOffset: number;
|
||||
isPast: boolean;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
function EventBlockInner(props: EventBlockInnerProps) {
|
||||
@@ -64,6 +69,10 @@ function EventBlockInner(props: EventBlockInnerProps) {
|
||||
loaded,
|
||||
playback,
|
||||
isRolling,
|
||||
dayOffset,
|
||||
isPast,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
} = props;
|
||||
|
||||
const [renderInner, setRenderInner] = useState(false);
|
||||
@@ -108,6 +117,20 @@ function EventBlockInner(props: EventBlockInnerProps) {
|
||||
loaded={loaded}
|
||||
disablePlayback={skip || isRolling}
|
||||
/>
|
||||
{!skip && (
|
||||
<EventBlockChip
|
||||
className={style.chipSection}
|
||||
id={eventId}
|
||||
timeStart={timeStart}
|
||||
delay={delay}
|
||||
dayOffset={dayOffset}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isPast={isPast}
|
||||
isLoaded={loaded}
|
||||
totalGap={totalGap}
|
||||
duration={duration}
|
||||
/>
|
||||
)}
|
||||
<div className={style.statusElements} id='block-status' data-ispublic={isPublic}>
|
||||
<span className={style.eventNote}>{note}</span>
|
||||
<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,130 @@
|
||||
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 } from '../../../../common/hooks/useSocket';
|
||||
import useReport from '../../../../common/hooks-query/useReport';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime, useTimeUntilStart } from '../../../../common/utils/time';
|
||||
import { tooltipDelayFast } from '../../../../ontimeConfig';
|
||||
|
||||
import style from './EventBlockChip.module.scss';
|
||||
|
||||
interface EventBlockChipProps {
|
||||
id: string;
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
isPast: boolean;
|
||||
isLoaded: boolean;
|
||||
className: string;
|
||||
totalGap: number;
|
||||
duration: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
export default function EventBlockChip(props: EventBlockChipProps) {
|
||||
const { timeStart, delay, dayOffset, isPast, isLoaded, className, totalGap, id, duration, isLinkedToLoaded } = 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
|
||||
timeStart={timeStart}
|
||||
delay={delay}
|
||||
dayOffset={dayOffset}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface EventUntilProps {
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
function EventUntil(props: EventUntilProps) {
|
||||
const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props;
|
||||
|
||||
const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded });
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import EventEditor from './EventEditor';
|
||||
|
||||
@@ -37,7 +38,7 @@ export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.eventEditor} data-testid='editor-container'>
|
||||
<div className={cx([style.eventEditor, style.inModal])} data-testid='editor-container'>
|
||||
<EventEditor event={event} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
.eventEditor {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-inline: 0.5rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
|
||||
&.inModal {
|
||||
max-height: 80vh;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-right: 4px;
|
||||
padding-inline: 0.5rem 1.5rem;
|
||||
padding-bottom: 4rem;
|
||||
|
||||
flex: 1;
|
||||
@@ -81,3 +82,9 @@
|
||||
font-size: 1.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 { useSearchParams } from 'react-router-dom';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
||||
|
||||
import EventEditorImage from './composite/EventEditorImage';
|
||||
import EventEditorTimes from './composite/EventEditorTimes';
|
||||
import EventEditorTitles from './composite/EventEditorTitles';
|
||||
import EventTextArea from './composite/EventTextArea';
|
||||
import EventTextInput from './composite/EventTextInput';
|
||||
import EventEditorEmpty from './EventEditorEmpty';
|
||||
|
||||
import style from './EventEditor.module.scss';
|
||||
@@ -27,7 +28,6 @@ export default function EventEditor(props: EventEditorProps) {
|
||||
const { event } = props;
|
||||
const { data: customFields } = useCustomFields();
|
||||
const { updateEvent } = useEventAction();
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const isEditor = window.location.pathname.includes('editor');
|
||||
|
||||
@@ -43,10 +43,6 @@ export default function EventEditor(props: EventEditorProps) {
|
||||
[event?.id, updateEvent],
|
||||
);
|
||||
|
||||
const handleOpenCustomManager = () => {
|
||||
setSearchParams({ settings: 'feature_settings__custom' });
|
||||
};
|
||||
|
||||
if (!event) {
|
||||
return <EventEditorEmpty />;
|
||||
}
|
||||
@@ -81,12 +77,9 @@ export default function EventEditor(props: EventEditorProps) {
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Custom Fields
|
||||
{isEditor && (
|
||||
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage</AppLink>}
|
||||
</Editor.Title>
|
||||
|
||||
{Object.keys(customFields).map((fieldKey) => {
|
||||
const key = `${event.id}-${fieldKey}`;
|
||||
const fieldName = `custom-${fieldKey}`;
|
||||
@@ -94,17 +87,41 @@ export default function EventEditor(props: EventEditorProps) {
|
||||
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
||||
const labelText = customFields[fieldKey].label;
|
||||
|
||||
return (
|
||||
<EventTextArea
|
||||
key={key}
|
||||
field={fieldName}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
submitHandler={handleSubmit}
|
||||
className={style.decorated}
|
||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
if (customFields[fieldKey].type === 'string') {
|
||||
return (
|
||||
<EventTextArea
|
||||
key={key}
|
||||
field={fieldName}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
submitHandler={handleSubmit}
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.imageContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: $gray-1250;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
}
|
||||
|
||||
.imageOverlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import style from './EventEditorImage.module.scss';
|
||||
|
||||
interface EventEditorImageProps {
|
||||
src: string;
|
||||
}
|
||||
|
||||
export default function EventEditorImage(props: EventEditorImageProps) {
|
||||
const { src } = props;
|
||||
|
||||
return (
|
||||
<div className={style.imageContainer}>
|
||||
<img loading='lazy' src={src} />
|
||||
<div className={style.imageOverlay} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,11 +9,12 @@ interface EventTextInputProps extends InputProps {
|
||||
field: EditorUpdateFields;
|
||||
label: string;
|
||||
initialValue: string;
|
||||
placeholder?: string;
|
||||
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
||||
}
|
||||
|
||||
export default function EventTextInput(props: EventTextInputProps) {
|
||||
const { field, label, initialValue, submitHandler, maxLength } = props;
|
||||
const { className, field, label, initialValue, style: givenStyles, submitHandler, maxLength, placeholder } = props;
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||
|
||||
@@ -23,7 +24,9 @@ export default function EventTextInput(props: EventTextInputProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Editor.Label htmlFor={field}>{label}</Editor.Label>
|
||||
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
||||
{label}
|
||||
</Editor.Label>
|
||||
<Input
|
||||
id={field}
|
||||
ref={ref}
|
||||
@@ -32,6 +35,7 @@ export default function EventTextInput(props: EventTextInputProps) {
|
||||
data-testid='input-textfield'
|
||||
value={value}
|
||||
maxLength={maxLength || 100}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
top: 2vw;
|
||||
left: 2vw;
|
||||
max-width: min(200px, 20vw);
|
||||
max-height: min(100px, 20vh);
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== MOBILE ===================*/
|
||||
@media screen and (max-width: 768px) {
|
||||
.clock-view {
|
||||
.logo img {
|
||||
height: min(50px, 10vh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
top: 2vw;
|
||||
left: 2vw;
|
||||
max-width: min(200px, 20vw);
|
||||
max-height: min(100px, 20vh);
|
||||
}
|
||||
|
||||
/* =================== MAIN - EVENT CONTAINER ===================*/
|
||||
@@ -175,3 +174,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =================== MOBILE ===================*/
|
||||
@media screen and (max-width: 768px) {
|
||||
.countdown {
|
||||
.logo img {
|
||||
height: min(50px, 10vh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user