mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 15:09:10 +00:00
feat: timer preview
This commit is contained in:
committed by
Carlos Valente
parent
fa709dc9be
commit
4d8bea2940
@@ -1,4 +1,4 @@
|
|||||||
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
|
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||||
|
|
||||||
import { useRuntimeStore } from '../stores/runtime';
|
import { useRuntimeStore } from '../stores/runtime';
|
||||||
import { socketSendJson } from '../utils/socket';
|
import { socketSendJson } from '../utils/socket';
|
||||||
@@ -28,11 +28,43 @@ export const useOperator = () => {
|
|||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMessageControl = () => {
|
export const useTimerViewControl = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
timer: state.message.timer,
|
blackout: state.message.timer.blackout,
|
||||||
external: state.message.external,
|
blink: state.message.timer.blink,
|
||||||
onAir: state.onAir,
|
secondarySource: state.message.timer.secondarySource,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
@@ -41,8 +73,11 @@ export const useMessageControl = () => {
|
|||||||
export const setMessage = {
|
export const setMessage = {
|
||||||
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
|
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
|
||||||
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
|
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
|
||||||
|
externalText: (payload: string) => socketSendJson('message', { external: payload }),
|
||||||
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
|
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
|
||||||
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
|
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
|
||||||
|
timerSecondary: (payload: TimerMessage['secondarySource']) =>
|
||||||
|
socketSendJson('message', { timer: { secondarySource: payload } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePlaybackControl = () => {
|
export const usePlaybackControl = () => {
|
||||||
|
|||||||
@@ -23,11 +23,9 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
|||||||
visible: false,
|
visible: false,
|
||||||
blink: false,
|
blink: false,
|
||||||
blackout: false,
|
blackout: false,
|
||||||
|
secondarySource: null,
|
||||||
},
|
},
|
||||||
external: {
|
external: '',
|
||||||
text: '',
|
|
||||||
visible: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
selectedEventIndex: null,
|
selectedEventIndex: null,
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default function ViewSettingsForm() {
|
|||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
maxLength={150}
|
maxLength={150}
|
||||||
width='275px'
|
width='275px'
|
||||||
placeholder='Message shown when timer reaches end'
|
placeholder='Shown when timer reaches end'
|
||||||
{...register('endMessage')}
|
{...register('endMessage')}
|
||||||
/>
|
/>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { IconButton, Input } from '@chakra-ui/react';
|
import { Input } from '@chakra-ui/react';
|
||||||
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
||||||
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
||||||
|
|
||||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||||
|
|
||||||
import style from './InputRow.module.scss';
|
import style from './InputRow.module.scss';
|
||||||
@@ -13,15 +12,13 @@ interface InputRowProps {
|
|||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
text: string;
|
text: string;
|
||||||
visible?: boolean;
|
visible: boolean;
|
||||||
readonly?: boolean;
|
actionHandler: () => void;
|
||||||
actionHandler: (action: string, payload: object) => void;
|
|
||||||
changeHandler: (newValue: string) => void;
|
changeHandler: (newValue: string) => void;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InputRow(props: InputRowProps) {
|
export default function InputRow(props: InputRowProps) {
|
||||||
const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
|
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const cursorPositionRef = useRef(0);
|
const cursorPositionRef = useRef(0);
|
||||||
@@ -39,41 +36,27 @@ export default function InputRow(props: InputRowProps) {
|
|||||||
changeHandler(event.target.value);
|
changeHandler(event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const classes = cx([style.inputRow, className]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes}>
|
<div className={style.inputRow}>
|
||||||
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
|
||||||
<div className={style.inputItems}>
|
<div className={style.inputItems}>
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
readOnly={readonly}
|
|
||||||
disabled={readonly}
|
|
||||||
value={text}
|
value={text}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
{readonly ? (
|
<TooltipActionBtn
|
||||||
<IconButton
|
clickHandler={actionHandler}
|
||||||
size='sm'
|
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||||
isDisabled
|
aria-label={`Toggle ${label}`}
|
||||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
openDelay={tooltipDelayMid}
|
||||||
aria-label={`Toggle ${label}`}
|
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
/>
|
size='sm'
|
||||||
) : (
|
/>
|
||||||
<TooltipActionBtn
|
|
||||||
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
|
|
||||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
|
||||||
aria-label={`Toggle ${label}`}
|
|
||||||
openDelay={tooltipDelayMid}
|
|
||||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
|
||||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
|
||||||
size='sm'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,3 @@
|
|||||||
.messageContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $section-spacing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonSection {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: $element-spacing;
|
|
||||||
margin-top: -0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.singleAction {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $element-spacing;
|
|
||||||
margin-top: $element-inner-spacing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
@@ -26,3 +6,73 @@
|
|||||||
color: $action-text-color;
|
color: $action-text-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.previewContainer {
|
||||||
|
display: grid;
|
||||||
|
gap: $element-spacing;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
background-color: $ui-black;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $element-spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventStatus {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
margin: 0.5rem 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainContent {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--override-colour, $ui-white);
|
||||||
|
|
||||||
|
&[data-phase='pending'] {
|
||||||
|
color: $ontime-roll;
|
||||||
|
}
|
||||||
|
&[data-phase='overtime'] {
|
||||||
|
color: $playback-negative;
|
||||||
|
}
|
||||||
|
&[data-phase='none'] {
|
||||||
|
opacity: $opacity-disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryContent {
|
||||||
|
border-top: 1px solid $white-7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blackout {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timerIndicators {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusIcon {
|
||||||
|
color: $gray-1000;
|
||||||
|
|
||||||
|
&[data-active='true'] {
|
||||||
|
color: $active-indicator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border-top: 1px solid $gray-1000;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,64 +1,52 @@
|
|||||||
import { Button } from '@chakra-ui/react';
|
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
|
||||||
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
|
||||||
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
|
||||||
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
|
|
||||||
import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline';
|
|
||||||
|
|
||||||
import { setMessage, useMessageControl } from '../../../common/hooks/useSocket';
|
|
||||||
import { enDash } from '../../../common/utils/styleUtils';
|
|
||||||
|
|
||||||
import InputRow from './InputRow';
|
import InputRow from './InputRow';
|
||||||
|
import TimerControlsPreview from './TimerViewControl';
|
||||||
import style from './MessageControl.module.scss';
|
|
||||||
|
|
||||||
const noop = () => undefined;
|
|
||||||
|
|
||||||
export default function MessageControl() {
|
export default function MessageControl() {
|
||||||
const message = useMessageControl();
|
|
||||||
const blink = message.timer.blink;
|
|
||||||
const blackout = message.timer.blackout;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.messageContainer}>
|
<>
|
||||||
<InputRow
|
<TimerControlsPreview />
|
||||||
label='Timer'
|
<TimerMessageInput />
|
||||||
placeholder='Message shown in stage timer'
|
<ExternalInput />
|
||||||
text={message.timer.text}
|
</>
|
||||||
visible={message.timer.visible}
|
);
|
||||||
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
}
|
||||||
actionHandler={() => setMessage.timerVisible(!message.timer.visible)}
|
|
||||||
/>
|
function TimerMessageInput() {
|
||||||
<div className={style.buttonSection}>
|
const { text, visible } = useTimerMessageInput();
|
||||||
<Button
|
|
||||||
size='sm'
|
return (
|
||||||
className={`${blink ? style.blink : ''}`}
|
<InputRow
|
||||||
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
|
label='Timer Message'
|
||||||
leftIcon={blink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
|
placeholder='Message shown fullscreen in stage timer'
|
||||||
onClick={() => setMessage.timerBlink(!blink)}
|
text={text}
|
||||||
data-testid='toggle timer blink'
|
visible={visible}
|
||||||
>
|
changeHandler={(newValue) => setMessage.timerText(newValue)}
|
||||||
Blink
|
actionHandler={() => setMessage.timerVisible(!visible)}
|
||||||
</Button>
|
/>
|
||||||
<Button
|
);
|
||||||
size='sm'
|
}
|
||||||
className={style.blackoutButton}
|
|
||||||
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
|
function ExternalInput() {
|
||||||
leftIcon={blackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
|
const { text, visible } = useExternalMessageInput();
|
||||||
onClick={() => setMessage.timerBlackout(!blackout)}
|
|
||||||
data-testid='toggle timer blackout'
|
const toggleExternal = () => {
|
||||||
>
|
if (visible) {
|
||||||
Blackout screen
|
setMessage.timerSecondary(null);
|
||||||
</Button>
|
} else {
|
||||||
</div>
|
setMessage.timerSecondary('external');
|
||||||
<InputRow
|
}
|
||||||
label='External Message (read only)'
|
};
|
||||||
placeholder={enDash}
|
|
||||||
readonly
|
return (
|
||||||
text={message.external.text}
|
<InputRow
|
||||||
visible={message.external.visible}
|
label='External Message'
|
||||||
changeHandler={noop}
|
placeholder='Message shown as secondary text in stage timer'
|
||||||
actionHandler={noop}
|
text={text}
|
||||||
/>
|
visible={visible}
|
||||||
</div>
|
changeHandler={(newValue) => setMessage.externalText(newValue)}
|
||||||
|
actionHandler={toggleExternal}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
|||||||
|
|
||||||
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
import MessageControl from './MessageControl';
|
import MessageControl from './MessageControl';
|
||||||
|
|
||||||
import style from '../../editors/Editor.module.scss';
|
import style from '../../editors/Editor.module.scss';
|
||||||
|
|
||||||
const MessageControlExport = () => {
|
const MessageControlExport = () => {
|
||||||
|
const classes = cx([style.content, style.contentColumnLayout]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.messages} data-testid='panel-messages-control'>
|
<div className={style.messages} data-testid='panel-messages-control'>
|
||||||
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} />
|
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'messagecontrol')} />
|
||||||
<div className={style.content}>
|
<div className={classes}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<MessageControl />
|
<MessageControl />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Tooltip } from '@chakra-ui/react';
|
||||||
|
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
|
||||||
|
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||||
|
import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
|
||||||
|
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
||||||
|
import { TimerPhase, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
|
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
||||||
|
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||||
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||||
|
|
||||||
|
import style from './MessageControl.module.scss';
|
||||||
|
|
||||||
|
export default function TimerPreview() {
|
||||||
|
const { blink, blackout, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
|
||||||
|
useMessagePreview();
|
||||||
|
const { data } = useViewSettings();
|
||||||
|
|
||||||
|
const contentClasses = cx([style.previewContent, blink && style.blink, blackout && style.blackout]);
|
||||||
|
|
||||||
|
const main = (() => {
|
||||||
|
if (showTimerMessage) return 'Message';
|
||||||
|
if (phase === TimerPhase.Pending) return 'Standby to start';
|
||||||
|
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
|
||||||
|
return 'Timer';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const secondary = (() => {
|
||||||
|
// message is a fullscreen overlay
|
||||||
|
if (showTimerMessage) return null;
|
||||||
|
|
||||||
|
// we need to check aux first since it takes priority
|
||||||
|
if (showAuxTimer) return 'Aux Timer';
|
||||||
|
if (showExternalMessage) return 'External message';
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const overrideColour = (() => {
|
||||||
|
// override fallback colours from starter project
|
||||||
|
if (phase === TimerPhase.Warning) return data.warningColor ?? '#FFAB33';
|
||||||
|
if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ED3333';
|
||||||
|
return data.normalColor ?? '#FFFC';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const showColourOverride = main == 'Timer';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.preview}>
|
||||||
|
<div className={contentClasses}>
|
||||||
|
<div
|
||||||
|
className={style.mainContent}
|
||||||
|
data-phase={phase}
|
||||||
|
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
|
||||||
|
>
|
||||||
|
{main}
|
||||||
|
</div>
|
||||||
|
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
|
||||||
|
</div>
|
||||||
|
<div className={style.eventStatus}>
|
||||||
|
<Tooltip label='Time type: Count down' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||||
|
<IoArrowDown className={style.statusIcon} data-active={timerType === TimerType.CountDown} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label='Time type: Count up' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||||
|
<IoArrowUp className={style.statusIcon} data-active={timerType === TimerType.CountUp} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||||
|
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||||
|
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Button } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
|
||||||
|
|
||||||
|
import TimerPreview from './TimerPreview';
|
||||||
|
|
||||||
|
import style from './MessageControl.module.scss';
|
||||||
|
|
||||||
|
export default function TimerControlsPreview() {
|
||||||
|
const { blackout, blink, secondarySource } = useTimerViewControl();
|
||||||
|
|
||||||
|
const toggleSecondary = (newValue: 'aux' | 'external' | null) => {
|
||||||
|
if (secondarySource === newValue) {
|
||||||
|
setMessage.timerSecondary(null);
|
||||||
|
} else {
|
||||||
|
setMessage.timerSecondary(newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.previewContainer}>
|
||||||
|
<TimerPreview />
|
||||||
|
|
||||||
|
<div className={style.options}>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant={secondarySource === 'aux' ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
onClick={() => toggleSecondary('aux')}
|
||||||
|
>
|
||||||
|
Show Aux timer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant={secondarySource === 'external' ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
onClick={() => toggleSecondary('external')}
|
||||||
|
>
|
||||||
|
Show external
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<hr className={style.divider} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant={blink ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
onClick={() => setMessage.timerBlink(!blink)}
|
||||||
|
data-testid='toggle timer blink'
|
||||||
|
>
|
||||||
|
Blink
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
className={style.blackoutButton}
|
||||||
|
variant={blackout ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
onClick={() => setMessage.timerBlackout(!blackout)}
|
||||||
|
data-testid='toggle timer blackout'
|
||||||
|
>
|
||||||
|
Blackout screen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ function AuxTimerInput() {
|
|||||||
|
|
||||||
const handleTimeUpdate = (_field: string, value: string) => {
|
const handleTimeUpdate = (_field: string, value: string) => {
|
||||||
const newTime = parseUserTime(value);
|
const newTime = parseUserTime(value);
|
||||||
setDuration(newTime / 1000); //frontend api is seconds based;
|
setDuration(newTime / 1000); // frontend api is seconds based
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ $panel-gap: 0.5rem;
|
|||||||
.playback,
|
.playback,
|
||||||
.messages {
|
.messages {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 8px;
|
border-radius: var(--editor--panel__br);
|
||||||
background-color: $bg-container-l2;
|
background-color: $bg-container-l2;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
@@ -62,3 +62,10 @@ $panel-gap: 0.5rem;
|
|||||||
.content {
|
.content {
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contentColumnLayout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $section-spacing;
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
@use '../../theme/ontimeColours' as *;
|
@use '../../theme/ontimeColours' as *;
|
||||||
@use '../../theme/ontimeStyles' as *;
|
@use '../../theme/ontimeStyles' as *;
|
||||||
|
|
||||||
@mixin absolute-top-right($distance) {
|
// declare editor specific styling constants
|
||||||
|
:root {
|
||||||
|
--editor--panel__br: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin corner() {
|
||||||
|
display: none;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: $distance;
|
top: 0.5rem;
|
||||||
right: $distance;
|
right: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
transition-property: color;
|
transition-property: color;
|
||||||
@@ -15,17 +23,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin corner() {
|
|
||||||
display: none;
|
|
||||||
@include absolute-top-right(0.5rem);
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin panel() {
|
@mixin panel() {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 8px;
|
border-radius: var(--editor--panel__br);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: $bg-container-l2;
|
background-color: $bg-container-l2;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ import EventBlockProgressBar from './composite/EventBlockProgressBar';
|
|||||||
|
|
||||||
import style from './EventBlock.module.scss';
|
import style from './EventBlock.module.scss';
|
||||||
|
|
||||||
const tooltipProps = {
|
|
||||||
openDelay: tooltipDelayMid,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EventBlockInnerProps {
|
interface EventBlockInnerProps {
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
timeEnd: number;
|
timeEnd: number;
|
||||||
@@ -98,11 +94,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className={style.titleSection}>
|
<div className={style.titleSection}>
|
||||||
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
|
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||||
{isNext && (
|
{isNext && <span className={style.nextTag}>UP NEXT</span>}
|
||||||
<Tooltip label='Next event' {...tooltipProps}>
|
|
||||||
<span className={style.nextTag}>UP NEXT</span>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<EventBlockPlayback
|
<EventBlockPlayback
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
@@ -118,17 +110,17 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
|||||||
{loaded && <EventBlockProgressBar />}
|
{loaded && <EventBlockProgressBar />}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.eventStatus} tabIndex={-1}>
|
<div className={style.eventStatus} tabIndex={-1}>
|
||||||
<Tooltip label={`Time type: ${timerType}`} {...tooltipProps}>
|
<Tooltip label={`Time type: ${timerType}`} openDelay={tooltipDelayMid}>
|
||||||
<span>
|
<span>
|
||||||
<TimerIcon type={timerType} className={style.statusIcon} />
|
<TimerIcon type={timerType} className={style.statusIcon} />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={`End action: ${endAction}`} {...tooltipProps}>
|
<Tooltip label={`End action: ${endAction}`} openDelay={tooltipDelayMid}>
|
||||||
<span>
|
<span>
|
||||||
<EndActionIcon action={endAction} className={style.statusIcon} />
|
<EndActionIcon action={endAction} className={style.statusIcon} />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}>
|
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}>
|
||||||
<span>
|
<span>
|
||||||
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
|
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { ComponentType, useMemo } from 'react';
|
|||||||
import { ViewExtendedTimer } from 'common/models/TimeManager.type';
|
import { ViewExtendedTimer } from 'common/models/TimeManager.type';
|
||||||
import {
|
import {
|
||||||
CustomFields,
|
CustomFields,
|
||||||
Message,
|
MessageState,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
ProjectData,
|
ProjectData,
|
||||||
Runtime,
|
Runtime,
|
||||||
Settings,
|
Settings,
|
||||||
|
SimpleTimerState,
|
||||||
SupportedEvent,
|
SupportedEvent,
|
||||||
TimerMessage,
|
|
||||||
ViewSettings,
|
ViewSettings,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { useStore } from 'zustand';
|
import { useStore } from 'zustand';
|
||||||
@@ -23,17 +23,17 @@ import { runtimeStore } from '../../common/stores/runtime';
|
|||||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||||
|
|
||||||
type WithDataProps = {
|
type WithDataProps = {
|
||||||
|
auxTimer: SimpleTimerState;
|
||||||
backstageEvents: OntimeEvent[];
|
backstageEvents: OntimeEvent[];
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
eventNext: OntimeEvent | null;
|
eventNext: OntimeEvent | null;
|
||||||
eventNow: OntimeEvent | null;
|
eventNow: OntimeEvent | null;
|
||||||
events: OntimeEvent[];
|
events: OntimeEvent[];
|
||||||
external: Message;
|
|
||||||
general: ProjectData;
|
general: ProjectData;
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
|
message: MessageState;
|
||||||
nextId: string | null;
|
nextId: string | null;
|
||||||
onAir: boolean;
|
onAir: boolean;
|
||||||
pres: TimerMessage;
|
|
||||||
publicEventNext: OntimeEvent | null;
|
publicEventNext: OntimeEvent | null;
|
||||||
publicEventNow: OntimeEvent | null;
|
publicEventNow: OntimeEvent | null;
|
||||||
publicSelectedId: string | null;
|
publicSelectedId: string | null;
|
||||||
@@ -68,7 +68,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
}, [rundownData]);
|
}, [rundownData]);
|
||||||
|
|
||||||
// websocket data
|
// websocket data
|
||||||
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } =
|
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime, auxtimer1 } =
|
||||||
useStore(runtimeStore);
|
useStore(runtimeStore);
|
||||||
const publicSelectedId = publicEventNow?.id ?? null;
|
const publicSelectedId = publicEventNow?.id ?? null;
|
||||||
const selectedId = eventNow?.id ?? null;
|
const selectedId = eventNow?.id ?? null;
|
||||||
@@ -96,17 +96,17 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
<ViewNavigationMenu />
|
<ViewNavigationMenu />
|
||||||
<Component
|
<Component
|
||||||
{...props}
|
{...props}
|
||||||
|
auxTimer={auxtimer1}
|
||||||
backstageEvents={rundownData}
|
backstageEvents={rundownData}
|
||||||
customFields={customFields}
|
customFields={customFields}
|
||||||
eventNext={eventNext}
|
eventNext={eventNext}
|
||||||
eventNow={eventNow}
|
eventNow={eventNow}
|
||||||
events={publicEvents}
|
events={publicEvents}
|
||||||
external={message.external}
|
|
||||||
general={project}
|
general={project}
|
||||||
isMirrored={isMirrored}
|
isMirrored={isMirrored}
|
||||||
|
message={message}
|
||||||
nextId={nextId}
|
nextId={nextId}
|
||||||
onAir={onAir}
|
onAir={onAir}
|
||||||
pres={message.timer}
|
|
||||||
publicEventNext={publicEventNext}
|
publicEventNext={publicEventNext}
|
||||||
publicEventNow={publicEventNow}
|
publicEventNow={publicEventNow}
|
||||||
publicSelectedId={publicSelectedId}
|
publicSelectedId={publicSelectedId}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
CustomFields,
|
CustomFields,
|
||||||
Message,
|
MessageState,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
Playback,
|
Playback,
|
||||||
Settings,
|
Settings,
|
||||||
TimerMessage,
|
SimpleTimerState,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
TimerType,
|
TimerType,
|
||||||
ViewSettings,
|
ViewSettings,
|
||||||
@@ -48,19 +48,19 @@ const titleVariants = {
|
|||||||
export const MotionTitleCard = motion(TitleCard);
|
export const MotionTitleCard = motion(TitleCard);
|
||||||
|
|
||||||
interface TimerProps {
|
interface TimerProps {
|
||||||
|
auxTimer: SimpleTimerState;
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
eventNext: OntimeEvent | null;
|
eventNext: OntimeEvent | null;
|
||||||
eventNow: OntimeEvent | null;
|
eventNow: OntimeEvent | null;
|
||||||
external: Message;
|
|
||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
pres: TimerMessage;
|
message: MessageState;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
time: ViewExtendedTimer;
|
time: ViewExtendedTimer;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Timer(props: TimerProps) {
|
export default function Timer(props: TimerProps) {
|
||||||
const { customFields, isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props;
|
const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props;
|
||||||
|
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
@@ -114,7 +114,7 @@ export default function Timer(props: TimerProps) {
|
|||||||
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
|
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
|
||||||
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
|
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
|
||||||
|
|
||||||
const showOverlay = pres.text !== '' && pres.visible;
|
const showOverlay = message.timer.text !== '' && message.timer.visible;
|
||||||
const isPlaying = time.playback !== Playback.Pause;
|
const isPlaying = time.playback !== Playback.Pause;
|
||||||
|
|
||||||
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
|
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
|
||||||
@@ -128,10 +128,20 @@ export default function Timer(props: TimerProps) {
|
|||||||
const showFinished = finished && (shouldShowModifiers || showEndMessage);
|
const showFinished = finished && (shouldShowModifiers || showEndMessage);
|
||||||
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
|
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
|
||||||
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
|
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
|
||||||
const showBlinking = pres.blink;
|
|
||||||
const showBlackout = pres.blackout;
|
|
||||||
const showClock = time.timerType !== TimerType.Clock;
|
const showClock = time.timerType !== TimerType.Clock;
|
||||||
const showExternal = external.visible && external.text;
|
|
||||||
|
const secondaryContent = ((): string | undefined => {
|
||||||
|
if (message.timer.secondarySource === 'aux') {
|
||||||
|
return getFormattedTimer(auxTimer.current, TimerType.CountDown, getLocalizedString('common.minutes'), {
|
||||||
|
removeSeconds: userOptions.hideTimerSeconds,
|
||||||
|
removeLeadingZero: userOptions.removeLeadingZeros,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (message.timer.secondarySource === 'external' && message.external) {
|
||||||
|
return message.external;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
})();
|
||||||
|
|
||||||
let timerColor = viewSettings.normalColor;
|
let timerColor = viewSettings.normalColor;
|
||||||
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
|
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
|
||||||
@@ -146,13 +156,14 @@ export default function Timer(props: TimerProps) {
|
|||||||
const stageTimerCharacters = display.replace('/:/g', '').length;
|
const stageTimerCharacters = display.replace('/:/g', '').length;
|
||||||
|
|
||||||
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
|
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
|
||||||
|
|
||||||
let timerFontSize = 89 / (stageTimerCharacters - 1);
|
let timerFontSize = 89 / (stageTimerCharacters - 1);
|
||||||
// we need to shrink the timer if the external is going to be there
|
// we need to shrink the timer if the external is going to be there
|
||||||
if (showExternal) {
|
if (secondaryContent) {
|
||||||
timerFontSize *= 0.8;
|
timerFontSize *= 0.8;
|
||||||
}
|
}
|
||||||
const externalFontSize = timerFontSize * 0.4;
|
const externalFontSize = timerFontSize * 0.4;
|
||||||
const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`;
|
const timerContainerClasses = `timer-container ${message.timer.blink ? (showOverlay ? '' : 'blink') : ''}`;
|
||||||
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
|
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
|
||||||
|
|
||||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
@@ -161,11 +172,11 @@ export default function Timer(props: TimerProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
||||||
<ViewParamsEditor viewOptions={timerOptions} />
|
<ViewParamsEditor viewOptions={timerOptions} />
|
||||||
<div className={showBlackout ? 'blackout blackout--active' : 'blackout'} />
|
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} />
|
||||||
{!userOptions.hideMessage && (
|
{!userOptions.hideMessage && (
|
||||||
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
||||||
<FitText mode='multi' min={32} max={256} className={`message ${showBlinking ? 'blink' : ''}`}>
|
<FitText mode='multi' min={32} max={256} className={`message ${message.timer.blink ? 'blink' : ''}`}>
|
||||||
{pres.text}
|
{message.timer.text}
|
||||||
</FitText>
|
</FitText>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -192,10 +203,10 @@ export default function Timer(props: TimerProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`external${showExternal ? '' : ' external--hidden'}`}
|
className={`external${secondaryContent ? '' : ' external--hidden'}`}
|
||||||
style={{ fontSize: `${externalFontSize}vw` }}
|
style={{ fontSize: `${externalFontSize}vw` }}
|
||||||
>
|
>
|
||||||
{external.text}
|
{secondaryContent}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { handleLegacyMessageConversion } from '../integration.legacy.js';
|
||||||
|
|
||||||
|
describe('handleLegacyConversion', () => {
|
||||||
|
it('should return the payload as is if it is not a legacy message', () => {
|
||||||
|
expect(handleLegacyMessageConversion({})).toEqual({});
|
||||||
|
const newPayload = {
|
||||||
|
timer: {
|
||||||
|
text: 'text',
|
||||||
|
visible: true,
|
||||||
|
blink: true,
|
||||||
|
blackout: true,
|
||||||
|
},
|
||||||
|
external: 'text',
|
||||||
|
};
|
||||||
|
expect(handleLegacyMessageConversion(newPayload)).toEqual(newPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert a legacy payload with external message', () => {
|
||||||
|
expect(handleLegacyMessageConversion({ external: { text: 'text', visible: true } })).toEqual({
|
||||||
|
external: 'text',
|
||||||
|
timer: {
|
||||||
|
secondarySource: 'external',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleLegacyMessageConversion({ external: { visible: true } })).toEqual({
|
||||||
|
timer: {
|
||||||
|
secondarySource: 'external',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleLegacyMessageConversion({ external: { text: 'text' } })).toEqual({
|
||||||
|
external: 'text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { DeepPartial, MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
import { MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { DeepPartial } from 'ts-essentials';
|
||||||
|
|
||||||
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
|
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
|
||||||
import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.js';
|
import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.js';
|
||||||
import { messageService } from '../services/message-service/MessageService.js';
|
import * as messageService from '../services/message-service/MessageService.js';
|
||||||
import { validateMessage, validateTimerMessage } from '../services/message-service/messageUtils.js';
|
import { validateMessage, validateTimerMessage } from '../services/message-service/messageUtils.js';
|
||||||
import { runtimeService } from '../services/runtime-service/RuntimeService.js';
|
import { runtimeService } from '../services/runtime-service/RuntimeService.js';
|
||||||
import { eventStore } from '../stores/EventStore.js';
|
import { eventStore } from '../stores/EventStore.js';
|
||||||
@@ -14,6 +16,8 @@ import { socket } from '../adapters/WebsocketAdapter.js';
|
|||||||
import { throttle } from '../utils/throttle.js';
|
import { throttle } from '../utils/throttle.js';
|
||||||
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
||||||
|
|
||||||
|
import { handleLegacyMessageConversion } from './integration.legacy.js';
|
||||||
|
|
||||||
const throttledUpdateEvent = throttle(updateEvent, 20);
|
const throttledUpdateEvent = throttle(updateEvent, 20);
|
||||||
|
|
||||||
export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
|
export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
|
||||||
@@ -78,9 +82,12 @@ const actionHandlers: Record<string, ActionHandler> = {
|
|||||||
message: (payload) => {
|
message: (payload) => {
|
||||||
assert.isObject(payload);
|
assert.isObject(payload);
|
||||||
|
|
||||||
|
// TODO: remove this once we feel its been enough time, ontime 3.6.0, 20/09/2024
|
||||||
|
const migratedPayload = handleLegacyMessageConversion(payload);
|
||||||
|
|
||||||
const patch: DeepPartial<MessageState> = {
|
const patch: DeepPartial<MessageState> = {
|
||||||
timer: 'timer' in payload ? validateTimerMessage(payload.timer) : undefined,
|
timer: 'timer' in migratedPayload ? validateTimerMessage(migratedPayload.timer) : undefined,
|
||||||
external: 'external' in payload ? validateMessage(payload.external) : undefined,
|
external: 'external' in migratedPayload ? validateMessage(migratedPayload.external) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const newMessage = messageService.patch(patch);
|
const newMessage = messageService.patch(patch);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { MessageState } from 'ontime-types';
|
||||||
|
import { DeepPartial } from 'ts-essentials';
|
||||||
|
|
||||||
|
export type LegacyMessageState = DeepPartial<{
|
||||||
|
timer: {
|
||||||
|
text: string;
|
||||||
|
visible: boolean;
|
||||||
|
blink: boolean;
|
||||||
|
blackout: boolean;
|
||||||
|
};
|
||||||
|
external: {
|
||||||
|
text: string;
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function isLegacyMessageState(value: object): value is LegacyMessageState {
|
||||||
|
// @ts-expect-error -- good enough here
|
||||||
|
return value?.external?.text !== undefined || value?.external?.visible !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is used to maintain support for legacy data in the /message endpoint
|
||||||
|
* The previous message endpoint expected a patch of the message state
|
||||||
|
* @example {
|
||||||
|
* timer: { blink: boolean, blackout: boolean, text: string, visible: boolean },
|
||||||
|
* external: { visible: boolean, text: string }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* This change is introduced in version 3.6.0
|
||||||
|
*/
|
||||||
|
export function handleLegacyMessageConversion(payload: object): object | Partial<MessageState> {
|
||||||
|
// if it is not a legacy message, we pass it as is
|
||||||
|
if (!isLegacyMessageState(payload)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current migration only needs to handle the cases
|
||||||
|
* for the deprecated external message controls
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Migrate external message
|
||||||
|
// 2.1 the user gives us the text and a visible flag
|
||||||
|
if (payload?.external?.text !== undefined && payload.external.visible !== undefined) {
|
||||||
|
return {
|
||||||
|
timer: { secondarySource: payload.external.visible ? 'external' : null },
|
||||||
|
external: payload.external.text,
|
||||||
|
} as Partial<MessageState>;
|
||||||
|
}
|
||||||
|
// 2.2 the user gives us the text
|
||||||
|
else if (payload?.external?.text !== undefined) {
|
||||||
|
return {
|
||||||
|
external: payload.external.text,
|
||||||
|
} as Partial<MessageState>;
|
||||||
|
}
|
||||||
|
// 2.3 the user gives us the visible flag
|
||||||
|
else if (payload?.external?.visible !== undefined) {
|
||||||
|
return {
|
||||||
|
timer: { secondarySource: payload.external.visible ? 'external' : null },
|
||||||
|
} as Partial<MessageState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// there should be no case for us to reach this since
|
||||||
|
// the type guard would have ensured one of the above states
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ import { populateStyles } from './setup/loadStyles.js';
|
|||||||
import { eventStore } from './stores/EventStore.js';
|
import { eventStore } from './stores/EventStore.js';
|
||||||
import { runtimeService } from './services/runtime-service/RuntimeService.js';
|
import { runtimeService } from './services/runtime-service/RuntimeService.js';
|
||||||
import { restoreService } from './services/RestoreService.js';
|
import { restoreService } from './services/RestoreService.js';
|
||||||
import { messageService } from './services/message-service/MessageService.js';
|
import * as messageService from './services/message-service/MessageService.js';
|
||||||
import { populateDemo } from './setup/loadDemo.js';
|
import { populateDemo } from './setup/loadDemo.js';
|
||||||
import { getState } from './stores/runtimeState.js';
|
import { getState } from './stores/runtimeState.js';
|
||||||
import { initRundown } from './services/rundown-service/RundownService.js';
|
import { initRundown } from './services/rundown-service/RundownService.js';
|
||||||
@@ -198,7 +198,7 @@ export const startServer = async (
|
|||||||
// initialise logging service, escalateErrorFn is only exists in electron
|
// initialise logging service, escalateErrorFn is only exists in electron
|
||||||
logger.init(escalateErrorFn);
|
logger.init(escalateErrorFn);
|
||||||
|
|
||||||
// initialise rundown service
|
// initialise rundown service
|
||||||
const persistedRundown = getDataProvider().getRundown();
|
const persistedRundown = getDataProvider().getRundown();
|
||||||
const persistedCustomFields = getDataProvider().getCustomFields();
|
const persistedCustomFields = getDataProvider().getCustomFields();
|
||||||
initRundown(persistedRundown, persistedCustomFields);
|
initRundown(persistedRundown, persistedCustomFields);
|
||||||
@@ -210,7 +210,7 @@ export const startServer = async (
|
|||||||
runtimeService.init(maybeRestorePoint);
|
runtimeService.init(maybeRestorePoint);
|
||||||
|
|
||||||
// eventStore set is a dependency of the services that publish to it
|
// eventStore set is a dependency of the services that publish to it
|
||||||
messageService.init(eventStore.set.bind(eventStore));
|
messageService.init(eventStore.set);
|
||||||
|
|
||||||
expressServer.listen(serverPort, '0.0.0.0', () => {
|
expressServer.listen(serverPort, '0.0.0.0', () => {
|
||||||
const nif = getNetworkInterfaces();
|
const nif = getNetworkInterfaces();
|
||||||
|
|||||||
@@ -1,68 +1,63 @@
|
|||||||
import { DeepPartial, Message, TimerMessage, MessageState } from 'ontime-types';
|
import { TimerMessage, MessageState } from 'ontime-types';
|
||||||
|
import { DeepPartial } from 'ts-essentials';
|
||||||
|
|
||||||
import { throttle } from '../../utils/throttle.js';
|
import { throttle } from '../../utils/throttle.js';
|
||||||
|
|
||||||
import type { PublishFn } from '../../stores/EventStore.js';
|
import type { PublishFn } from '../../stores/EventStore.js';
|
||||||
|
|
||||||
let instance: MessageService | null = null;
|
const defaultTimer: TimerMessage = {
|
||||||
|
text: '',
|
||||||
|
visible: false,
|
||||||
|
blink: false,
|
||||||
|
blackout: false,
|
||||||
|
secondarySource: null,
|
||||||
|
};
|
||||||
|
|
||||||
class MessageService {
|
let timer = { ...defaultTimer };
|
||||||
timer: TimerMessage;
|
let external = '';
|
||||||
external: Message;
|
|
||||||
|
|
||||||
private throttledSet: PublishFn;
|
let throttledSet: PublishFn | null = null;
|
||||||
private publish: PublishFn | null;
|
|
||||||
|
|
||||||
constructor() {
|
/**
|
||||||
if (instance) {
|
* Initialises the message service with a publish function
|
||||||
throw new Error('There can be only one');
|
* @param publishFn
|
||||||
}
|
*/
|
||||||
|
export function init(publishFn: PublishFn) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
|
throttledSet = throttle(publishFn, 100);
|
||||||
instance = this;
|
|
||||||
|
|
||||||
this.throttledSet = () => {
|
|
||||||
throw new Error('Published called before initialisation');
|
|
||||||
};
|
|
||||||
|
|
||||||
this.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.timer = {
|
|
||||||
text: '',
|
|
||||||
visible: false,
|
|
||||||
blink: false,
|
|
||||||
blackout: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.external = {
|
|
||||||
text: '',
|
|
||||||
visible: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
init(publish: PublishFn) {
|
|
||||||
this.publish = publish;
|
|
||||||
this.throttledSet = throttle((key, value) => this.publish?.(key, value), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
getState(): MessageState {
|
|
||||||
return {
|
|
||||||
timer: this.timer,
|
|
||||||
external: this.external,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
patch(message: DeepPartial<MessageState>) {
|
|
||||||
if (message.timer) this.timer = { ...this.timer, ...message.timer };
|
|
||||||
if (message.external) this.external = { ...this.external, ...message.external };
|
|
||||||
|
|
||||||
const newState = this.getState();
|
|
||||||
|
|
||||||
this.throttledSet('message', newState);
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const messageService = new MessageService();
|
/**
|
||||||
|
* Exposes function to reset the internal state
|
||||||
|
*/
|
||||||
|
export function clear() {
|
||||||
|
timer = { ...defaultTimer };
|
||||||
|
external = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposes the internal state of the message service
|
||||||
|
*/
|
||||||
|
export function getState(): MessageState {
|
||||||
|
return {
|
||||||
|
external,
|
||||||
|
timer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function allows patching internal object
|
||||||
|
*/
|
||||||
|
export function patch(patch: DeepPartial<MessageState>): MessageState {
|
||||||
|
// we cannot call patch before init
|
||||||
|
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||||
|
DEV: {
|
||||||
|
if (throttledSet === null) {
|
||||||
|
throw new Error('MessageService.patch() called before init()');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('timer' in patch) timer = { ...timer, ...patch.timer };
|
||||||
|
if ('external' in patch && patch.external !== undefined) external = patch.external;
|
||||||
|
const newState = getState();
|
||||||
|
throttledSet?.('message', newState);
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { messageService } from '../MessageService.js';
|
import * as messageService from '../MessageService.js';
|
||||||
|
|
||||||
describe('MessageService', () => {
|
describe('MessageService', () => {
|
||||||
const publishFunction = () => {};
|
const publishFunction = () => {};
|
||||||
@@ -14,17 +14,14 @@ describe('MessageService', () => {
|
|||||||
it('should patch the message state', () => {
|
it('should patch the message state', () => {
|
||||||
const message = {
|
const message = {
|
||||||
timer: { text: 'new text', visible: true },
|
timer: { text: 'new text', visible: true },
|
||||||
external: { visible: true },
|
external: 'external',
|
||||||
};
|
};
|
||||||
|
|
||||||
const newState = messageService.patch(message);
|
const newState = messageService.patch(message);
|
||||||
|
|
||||||
expect(newState).toEqual({
|
expect(newState).toEqual({
|
||||||
timer: { text: 'new text', visible: true, blackout: false, blink: false },
|
timer: { text: 'new text', visible: true, blackout: false, blink: false, secondarySource: null },
|
||||||
external: {
|
external: 'external',
|
||||||
text: '',
|
|
||||||
visible: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,11 +33,8 @@ describe('MessageService', () => {
|
|||||||
const newState = messageService.patch(initialMessage);
|
const newState = messageService.patch(initialMessage);
|
||||||
|
|
||||||
expect(newState).toEqual({
|
expect(newState).toEqual({
|
||||||
timer: { text: 'initial text', visible: true, blackout: false, blink: false },
|
timer: { text: 'initial text', visible: true, blackout: false, blink: false, secondarySource: null },
|
||||||
external: {
|
external: '',
|
||||||
text: '',
|
|
||||||
visible: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,26 +2,7 @@ import { validateMessage, validateTimerMessage } from '../messageUtils.js';
|
|||||||
|
|
||||||
describe('validateMessage()', () => {
|
describe('validateMessage()', () => {
|
||||||
it('returns a valid Message object', () => {
|
it('returns a valid Message object', () => {
|
||||||
const payload = {
|
expect(validateMessage('test')).toEqual('test');
|
||||||
text: '12312',
|
|
||||||
visible: 'true',
|
|
||||||
};
|
|
||||||
const expected = {
|
|
||||||
text: '12312',
|
|
||||||
visible: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(validateMessage(payload)).toEqual(expected);
|
|
||||||
});
|
|
||||||
it('skips keys not given', () => {
|
|
||||||
const payload = {
|
|
||||||
visible: 'true',
|
|
||||||
};
|
|
||||||
const expected = {
|
|
||||||
visible: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(validateMessage(payload)).toStrictEqual(expected);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Message, TimerMessage } from 'ontime-types';
|
import { TimerMessage } from 'ontime-types';
|
||||||
|
|
||||||
import * as assert from '../../utils/assert.js';
|
import * as assert from '../../utils/assert.js';
|
||||||
import { coerceBoolean, coerceString } from '../../utils/coerceType.js';
|
import { coerceBoolean, coerceString } from '../../utils/coerceType.js';
|
||||||
@@ -7,14 +7,8 @@ import { coerceBoolean, coerceString } from '../../utils/coerceType.js';
|
|||||||
* Creates a valid Message object from a payload
|
* Creates a valid Message object from a payload
|
||||||
* @throws if the payload is not an object
|
* @throws if the payload is not an object
|
||||||
*/
|
*/
|
||||||
export function validateMessage(message: unknown): Partial<Message> {
|
export function validateMessage(message: unknown): string {
|
||||||
assert.isObject(message);
|
return decodeURI(coerceString(message));
|
||||||
|
|
||||||
const result: Partial<Message> = {};
|
|
||||||
if ('text' in message) result.text = coerceString(message.text);
|
|
||||||
if ('visible' in message) result.visible = coerceBoolean(message.visible);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,10 +20,28 @@ export function validateTimerMessage(message: unknown): Partial<TimerMessage> {
|
|||||||
|
|
||||||
const result: Partial<TimerMessage> = {};
|
const result: Partial<TimerMessage> = {};
|
||||||
|
|
||||||
if ('text' in message) result.text = coerceString(message.text);
|
if ('text' in message) result.text = decodeURI(coerceString(message.text));
|
||||||
if ('visible' in message) result.visible = coerceBoolean(message.visible);
|
if ('visible' in message) result.visible = coerceBoolean(message.visible);
|
||||||
if ('blink' in message) result.blink = coerceBoolean(message.blink);
|
if ('blink' in message) result.blink = coerceBoolean(message.blink);
|
||||||
if ('blackout' in message) result.blackout = coerceBoolean(message.blackout);
|
if ('blackout' in message) result.blackout = coerceBoolean(message.blackout);
|
||||||
|
if ('secondarySource' in message) result.secondarySource = coerceSecondary(message.secondarySource);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the secondary value is one of the permitted values
|
||||||
|
*/
|
||||||
|
function assertSecondary(source: unknown): source is TimerMessage['secondarySource'] {
|
||||||
|
return source === 'aux' || source === 'external' || source === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that the secondary value is one of the permitted values
|
||||||
|
*/
|
||||||
|
function coerceSecondary(source: unknown): TimerMessage['secondarySource'] {
|
||||||
|
if (!assertSecondary(source)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ test('message control sends messages to screens', async ({ context }) => {
|
|||||||
await editorPage.goto('http://localhost:4001/messagecontrol');
|
await editorPage.goto('http://localhost:4001/messagecontrol');
|
||||||
|
|
||||||
// stage timer message
|
// stage timer message
|
||||||
await editorPage.getByPlaceholder('Timer').click();
|
await editorPage.getByPlaceholder('Message shown fullscreen in stage timer').click();
|
||||||
await editorPage.getByPlaceholder('Timer').fill('testing stage');
|
await editorPage.getByPlaceholder('Message shown fullscreen in stage timer').fill('testing stage');
|
||||||
await editorPage.getByRole('button', { name: /toggle timer/i }).click({ timeout: 5000 });
|
await editorPage.getByRole('button', { name: /toggle timer message/i }).click({ timeout: 5000 });
|
||||||
|
|
||||||
await featurePage.goto('http://localhost:4001/timer');
|
await featurePage.goto('http://localhost:4001/timer');
|
||||||
await featurePage.waitForLoadState('load', { timeout: 5000 });
|
await featurePage.waitForLoadState('load', { timeout: 5000 });
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
export type Message = {
|
export type TimerMessage = {
|
||||||
text: string;
|
text: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
};
|
|
||||||
|
|
||||||
export type TimerMessage = Message & {
|
|
||||||
blink: boolean;
|
blink: boolean;
|
||||||
blackout: boolean;
|
blackout: boolean;
|
||||||
|
secondarySource: 'aux' | 'external' | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageState = {
|
export type MessageState = {
|
||||||
timer: TimerMessage;
|
timer: TimerMessage;
|
||||||
external: Message;
|
external: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export type { RundownCached, NormalisedRundown } from './api/rundown-controller/
|
|||||||
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
||||||
export { Playback } from './definitions/runtime/Playback.type.js';
|
export { Playback } from './definitions/runtime/Playback.type.js';
|
||||||
export { TimerLifeCycle } from './definitions/core/TimerLifecycle.type.js';
|
export { TimerLifeCycle } from './definitions/core/TimerLifecycle.type.js';
|
||||||
export type { Message, TimerMessage, MessageState } from './definitions/runtime/MessageControl.type.js';
|
export type { TimerMessage, MessageState } from './definitions/runtime/MessageControl.type.js';
|
||||||
|
|
||||||
export type { Runtime } from './definitions/runtime/Runtime.type.js';
|
export type { Runtime } from './definitions/runtime/Runtime.type.js';
|
||||||
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
||||||
@@ -80,4 +80,4 @@ export {
|
|||||||
isOntimeCycle,
|
isOntimeCycle,
|
||||||
isKeyOfType,
|
isKeyOfType,
|
||||||
} from './utils/guards.js';
|
} from './utils/guards.js';
|
||||||
export type { DeepPartial, MaybeNumber, MaybeString } from './utils/utils.type.js';
|
export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
|
||||||
|
|||||||
@@ -1,6 +1,2 @@
|
|||||||
export type MaybeNumber = number | null;
|
export type MaybeNumber = number | null;
|
||||||
export type MaybeString = string | null;
|
export type MaybeString = string | null;
|
||||||
|
|
||||||
export type DeepPartial<T> = {
|
|
||||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user