Compare commits

..

6 Commits

Author SHA1 Message Date
Carlos Valente e9f0bdef4d bump version to 3.11.1-beta.1 2025-02-16 22:28:51 +01:00
Carlos Valente 8386105b75 refactor: show projected time 2025-02-16 22:28:51 +01:00
Carlos Valente 459d584b35 refactor: public design review 2025-02-16 22:28:51 +01:00
Carlos Valente f98af929b3 chore: directory restructure 2025-02-15 12:20:19 +01:00
Carlos Valente cbabec2b1d refactor: backstage design review
refactor: improve empty state

refactor: review schedule design

- fit as many elements as possible
- expose cycle interval as an option
- stabilise rundown reference
- style tweaks
2025-02-15 12:20:19 +01:00
Carlos Valente 959a2b6d4a chore: folder structure 2025-02-13 21:40:15 +01:00
71 changed files with 838 additions and 1450 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.12.0",
"version": "3.11.1-beta.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.12.0",
"version": "3.11.1-beta.1",
"private": true,
"type": "module",
"dependencies": {
@@ -31,7 +31,7 @@
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1",
"zustand": "^5.0.3"
"zustand": "^4.5.2"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -14,6 +14,7 @@ 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();
@@ -21,14 +22,8 @@ export default function ExternalLink(props: ExternalLinkProps) {
};
return (
<a
href='#!'
target='_blank'
rel='noreferrer'
className={cx([style.link, inline && style.inline])}
onClick={handleClick}
>
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
{children} <IoOpenOutline />
</a>
);
}
@@ -2,18 +2,14 @@
display: flex;
align-items: center;
gap: $element-spacing;
background-color: $gray-1200;
border-radius: 3px;
font-size: $text-body-size;
padding: 1rem;
margin-bottom: 1rem;
.content {
color: $gray-200;
}
background-color: $gray-1100;
border-radius: 2px;
font-size: $inner-section-text-size;
svg {
align-self: start;
font-size: 1.5rem;
color: $info-blue;
}
@@ -1,21 +1,13 @@
import { PropsWithChildren } from 'react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import { cx } from '../../utils/styleUtils';
import style from './Info.module.scss';
interface InfoProps {
className?: string;
}
export default function Info(props: PropsWithChildren<InfoProps>) {
const { className, children } = props;
export default function Info({ children }: PropsWithChildren) {
return (
<div className={cx([style.infoLabel, className])}>
<div className={style.infoLabel}>
<IoAlertCircle />
<div>{children}</div>
{children}
</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 { debounce } from '../../../utils/debounce';
import PopoverPicker from '../../../../common/components/input/popover-picker/PopoverPicker';
import { debounce } from '../../../../common/utils/debounce';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import PopoverPicker from '../popover-picker/PopoverPicker';
import style from './SwatchSelect.module.scss';
@@ -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 '../../hooks/useFadeOutOnInactivity';
import { useFadeOutOnInactivity } from '../../../common/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 '../../hooks/useFadeOutOnInactivity';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss';
@@ -9,6 +9,10 @@ $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,15 +7,16 @@ import './ProgressBar.scss';
interface ProgressBarProps {
current: MaybeNumber;
duration: MaybeNumber;
hidden?: boolean;
className?: string;
}
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, className } = props;
const { current, duration, hidden, className = '' } = props;
const progress = getProgress(current, duration);
return (
<div className={`progress-bar__bg ${className}`}>
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
</div>
);
@@ -5,7 +5,7 @@
.empty {
width: 100%;
opacity: 0.8;
opacity: 0.6;
}
.text {
@@ -16,7 +16,3 @@
overflow-y: scroll;
padding-right: 0.5rem;
}
.info {
margin-bottom: 1rem;
}
@@ -12,7 +12,7 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import useViewSettings from '../../hooks-query/useViewSettings';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import Info from '../info/Info';
import { ViewOption } from './types';
@@ -132,9 +132,7 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
</DrawerHeader>
<DrawerBody>
{viewSettings.overrideStyles && (
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
)}
{viewSettings.overrideStyles && <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
@@ -1,6 +1,5 @@
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';
@@ -10,12 +9,10 @@ import { useIsOnline } from './useSocket';
export const useClientPath = () => {
const navigate = useNavigate();
const { pathname, search } = useLocation();
const { redirect, setRedirect } = useClientStore(
useShallow((store) => ({
redirect: store.redirect,
setRedirect: store.setRedirect,
})),
);
const { redirect, setRedirect } = useClientStore((store) => ({
redirect: store.redirect,
setRedirect: store.setRedirect,
}));
const isOnline = useIsOnline();
// notify of client path changes
@@ -55,17 +55,6 @@ 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
@@ -626,7 +615,6 @@ export const useEventAction = () => {
batchUpdateEvents,
deleteEvent,
deleteAllEvents,
getEventById,
reorderEvent,
swapEvents,
updateEvent,
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { throttle } from '../utils/throttle';
import { throttle } from '../../common/utils/throttle';
export const useFadeOutOnInactivity = () => {
const [isMouseMoved, setIsMouseMoved] = useState(false);
+176 -100
View File
@@ -3,54 +3,73 @@ 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 = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
nextEventId: state.eventNext?.id ?? null,
}));
export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
nextEventId: state.eventNext?.id ?? null,
});
export const useOperator = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
}));
return useRuntimeStore(featureSelector);
};
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
blackout: state.message.timer.blackout,
blink: state.message.timer.blink,
secondarySource: state.message.timer.secondarySource,
}));
export const useOperator = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
});
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
text: state.message.timer.text,
visible: state.message.timer.visible,
}));
return useRuntimeStore(featureSelector);
};
export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({
text: state.message.external,
visible: state.message.timer.secondarySource === 'external',
}));
export const useTimerViewControl = () => {
const featureSelector = (state: RuntimeStore) => ({
blackout: state.message.timer.blackout,
blink: state.message.timer.blink,
secondarySource: state.message.timer.secondarySource,
});
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,
}));
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 setMessage = {
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
@@ -62,12 +81,16 @@ export const setMessage = {
socketSendJson('message', { timer: { secondarySource: payload } }),
};
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
timerPhase: state.timer.phase,
}));
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 setPlayback = {
start: () => socketSendJson('start'),
@@ -91,20 +114,32 @@ export const setPlayback = {
},
};
export const useInfoPanel = createSelector((state: RuntimeStore) => ({
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
}));
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 useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current);
return useRuntimeStore(featureSelector);
};
export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
}));
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 setAuxTimer = {
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
@@ -114,13 +149,20 @@ export const setAuxTimer = {
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
};
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null,
}));
export const useSelectedEventId = () => {
const featureSelector = (state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null,
});
export const useCurrentBlockId = createSelector((state: RuntimeStore) => ({
currentBlockId: state.currentBlock.block?.id ?? null,
}));
return useRuntimeStore(featureSelector);
};
export const useCurrentBlockId = () => {
const featureSelector = (state: RuntimeStore) => ({
currentBlockId: state.currentBlock.block?.id ?? null,
});
return useRuntimeStore(featureSelector);
};
export const setEventPlayback = {
loadEvent: (id: string) => socketSendJson('load', { id }),
@@ -129,56 +171,90 @@ export const setEventPlayback = {
pause: () => socketSendJson('pause'),
};
export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
export const useTimer = () => {
const featureSelector = (state: RuntimeStore) => ({
...state.timer,
});
export const useClock = createSelector((state: RuntimeStore) => ({
clock: state.clock,
}));
return useRuntimeStore(featureSelector);
};
export const useClock = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
});
return useRuntimeStore(featureSelector);
};
/** Used by the progress bar components */
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 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 setClientName = (newName: string) => socketSendJson('set-client-name', newName);
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart,
actualStart: state.runtime.actualStart,
plannedEnd: state.runtime.plannedEnd,
expectedEnd: state.runtime.expectedEnd,
}));
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 useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
return useRuntimeStore(featureSelector);
};
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset,
export const useRuntimePlaybackOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
currentBlock: state.currentBlock,
}));
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset,
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
}));
currentBlock: state.currentBlock,
});
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset,
}));
return useRuntimeStore(featureSelector);
};
export const usePing = createSelector((state: RuntimeStore) => ({
ping: state.ping,
}));
export const useTimelineStatus = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
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);
};
/** convert ping into a derived value which changes less often */
export const useIsOnline = createSelector((state: RuntimeStore) => ({
isOnline: state.ping > 0,
}));
export const useIsOnline = () => {
const featureSelector = (state: RuntimeStore) => ({
isOnline: state.ping > 0,
});
return useRuntimeStore(featureSelector);
};
@@ -27,8 +27,7 @@
.titleSection,
.filterSection,
.oscSection,
.httpSection,
.actionSection {
.httpSection {
display: grid;
grid-gap: 0.5rem;
@@ -41,10 +40,8 @@
.ruleSection,
.filterSection,
.oscSection,
.httpSection,
.actionSection {
label,
div {
.httpSection {
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);
@@ -54,6 +51,7 @@
}
}
.titleSection {
grid-template-columns: 1fr;
}
@@ -70,14 +68,6 @@
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;
@@ -1,23 +1,23 @@
import { useEffect, useMemo } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react';
import {
Alert,
AlertDescription,
AlertIcon,
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,
isOntimeAction,
isOSCOutput,
OntimeAction,
OSCOutput,
} from 'ontime-types';
import { Automation, AutomationDTO, HTTPOutput, isHTTPOutput, isOSCOutput, 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 Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
@@ -27,7 +27,6 @@ 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';
@@ -52,7 +51,6 @@ export default function AutomationForm(props: AutomationFormProps) {
register,
setError,
setFocus,
setValue,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<AutomationDTO>({
mode: 'onChange',
@@ -103,11 +101,6 @@ 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;
@@ -141,19 +134,6 @@ 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 });
@@ -210,7 +190,7 @@ export default function AutomationForm(props: AutomationFormProps) {
</div>
<div className={style.innerSection}>
<h3>Filters (optional)</h3>
<h3>Filters</h3>
<div className={style.ruleSection}>
<label>
Trigger outputs if
@@ -225,80 +205,74 @@ export default function AutomationForm(props: AutomationFormProps) {
)}
/>
</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
{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}
</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>
{/*
))}
</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>
</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>&nbsp;</span>
<div>
<span>&nbsp;</span>
<div>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
color='#FA5656' // $red-500
onClick={() => removeFilter(index)}
isDisabled={false}
isLoading={false}
/>
</div>
<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>
<Button
variant='ontime-subtle'
@@ -317,10 +291,13 @@ export default function AutomationForm(props: AutomationFormProps) {
<div className={style.innerColumn}>
<h3>Outputs</h3>
<Info>
Automation outputs can be used to send data from Ontime to external software.
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
</Info>
<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>
{fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) {
@@ -380,13 +357,13 @@ export default function AutomationForm(props: AutomationFormProps) {
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
Parameters
<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>
@@ -403,6 +380,8 @@ export default function AutomationForm(props: AutomationFormProps) {
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
isDisabled={false}
isLoading={false}
/>
</Panel.InlineElements>
</div>
@@ -450,6 +429,8 @@ export default function AutomationForm(props: AutomationFormProps) {
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
isDisabled={false}
isLoading={false}
/>
</Panel.InlineElements>
</div>
@@ -457,59 +438,30 @@ 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>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
/>
</Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
// there should be no other output types
return null;
})}
<Panel.InlineElements relation='inner'>
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewOSCOutput}>
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
onClick={handleAddNewOSCOutput}
isDisabled={false}
isLoading={false}
>
OSC
</Button>
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewHTTPOutput}>
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
onClick={handleAddNewHTTPOutput}
isDisabled={false}
isLoading={false}
>
HTTP
</Button>
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddnewOntimeAction}>
Ontime action
</Button>
</Panel.InlineElements>
</div>
@@ -1,13 +1,11 @@
import { Controller, useForm } from 'react-hook-form';
import { Button, Input, Switch } from '@chakra-ui/react';
import { Alert, AlertDescription, AlertIcon, 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 { 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/';
@@ -77,13 +75,14 @@ export default function AutomationSettingsForm(props: AutomationSettingsProps) {
<Panel.Divider />
<Panel.Section>
<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>
<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>
</Panel.Section>
<Panel.Section
@@ -113,9 +112,7 @@ 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'
@@ -1,110 +0,0 @@
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>
);
}
@@ -1,6 +1,6 @@
import { forwardRef, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { type InputProps, Input } from '@chakra-ui/react';
import { mergeRefs, useClickOutside } from '@mantine/hooks';
import { 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 {}
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
export default function TemplateInput(props: TemplateInputProps) {
const { value, onChange, ...rest } = props;
const { data } = useCustomFields();
const localRef = useClickOutside(() => setShowSuggestions(false));
const ref = useClickOutside(() => setShowSuggestions(false));
const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data);
@@ -31,13 +31,15 @@ const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProp
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);
} else if (showSuggestions) {
setSuggestions(updateSuggestions(event.target.value));
}
if (showSuggestions) {
const suggestions = updateSuggestions(event.target.value);
setSuggestions(suggestions);
}
onChange?.(event);
@@ -52,8 +54,8 @@ const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProp
};
return (
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
<Input value={inputValue} {...rest} onChange={handleInputChange} autoComplete='off' autoCorrect='off' />
<div className={style.wrapper} ref={ref}>
<Input {...rest} value={inputValue} onChange={handleInputChange} autoComplete='off' autoCorrect='off' />
{showSuggestions && suggestions.length > 0 && (
<ul className={style.suggestions}>
{suggestions.map((suggestion) => (
@@ -65,6 +67,4 @@ const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProp
)}
</div>
);
});
export default TemplateInput;
}
@@ -6,8 +6,10 @@ describe('matchRemaining()', () => {
expect(matchRemaining('{{{hum', '{{human}}')).toBe('an}}');
expect(matchRemaining('send {', '{{human}}')).toBe('{human}}');
expect(matchRemaining('{', '{{human}}')).toBe('{human}}');
expect(matchRemaining('{{', '{{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}}');
});
it('should return an empty string if there are no matches or if it is complete', () => {
@@ -83,16 +83,6 @@ 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,6 +1,6 @@
import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { Alert, AlertDescription, AlertIcon, 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';
@@ -10,7 +10,6 @@ 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 useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { handleLinks } from '../../../../common/utils/linkUtils';
@@ -109,29 +108,32 @@ export default function UrlPresetsForm() {
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<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>
<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>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.Title>
@@ -1,11 +1,10 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { Alert, AlertDescription, AlertIcon, 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 useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../../externals';
import * as Panel from '../../../panel-utils/PanelUtils';
@@ -56,17 +55,20 @@ export default function CustomFields() {
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<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>
<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>
</Panel.Section>
<Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
@@ -1,17 +1,15 @@
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Button, Input, Switch } from '@chakra-ui/react';
import { Alert, AlertDescription, AlertIcon, 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 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/';
@@ -87,19 +85,16 @@ export default function ViewSettingsForm() {
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<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>
<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>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.ListGroup>
@@ -0,0 +1,27 @@
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 />
</>
);
}
@@ -1,17 +1,21 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import Info from '../../../../common/components/info/Info';
const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/';
export default function GSheetInfo() {
return (
<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>
<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>
);
}
@@ -55,14 +55,15 @@ export default function GSheetSetup(props: GSheetSetupProps) {
setLoading('');
};
const handleCancelFlow = () => {
const handleCancelFlow = async () => {
onCancel();
};
/**
* Gets file from input
* @param event
*/
const handleClientSecret = (event: ChangeEvent<HTMLInputElement>) => {
const handleClientSecret = async (event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files?.length) {
return;
}
@@ -1,12 +1,15 @@
.eventEditor {
max-height: 80vh;
max-height: 100%;
overflow-y: auto;
padding-inline: 0.5rem;
display: flex;
flex-direction: column;
overflow-x: auto;
}
.content {
padding-inline: 0.5rem 1.5rem;
padding-right: 4px;
padding-bottom: 4rem;
flex: 1;
+11 -3
View File
@@ -12,7 +12,6 @@
color: var(--color-override, $viewer-color);
gap: $view-element-gap;
padding: $view-outer-padding;
font-size: $base-font-size;
display: grid;
grid-template-columns: 1fr 40vw;
@@ -29,7 +28,6 @@
left: 0;
right: 0;
margin-top: 25vh;
color: var(--label-color-override, $viewer-label-color);
}
/* =================== HEADER + EXTRAS ===================*/
@@ -84,6 +82,16 @@
gap: $view-element-gap;
}
.empty {
font-size: clamp(24px, 2vw, 32px);
width: 100%;
height: 100%;
display: grid;
place-content: center;
text-align: center;
color: var(--label-color-override, $viewer-label-color);
}
.event {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: $view-card-padding;
@@ -110,7 +118,7 @@
max-width: 7.5em;
}
.time-entry {
.aux-timers {
&__label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
+37 -35
View File
@@ -15,7 +15,7 @@ import { cx, timerPlaceholderMin } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { useTranslation } from '../../translation/TranslationProvider';
import ScheduleExport from '../common/schedule/ScheduleExport';
import BackstageSchedule from '../common/schedule/BackstageSchedule';
import { getBackstageOptions, useBackstageOptions } from './backstage.options';
import { getCardData, getIsPendingStart, getShowProgressBar, isOvertime } from './backstage.utils';
@@ -68,7 +68,6 @@ export default function Backstage(props: BackstageProps) {
}, [selectedId]);
// gather card data
const hasEvents = backstageEvents.length > 0;
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
eventNow,
eventNext,
@@ -81,18 +80,16 @@ export default function Backstage(props: BackstageProps) {
const clock = formatTime(time.clock);
const isPendingStart = getIsPendingStart(time.playback, time.phase);
const startedAt = isPendingStart ? formatTime(time.secondaryTimer) : formatTime(time.startedAt);
const scheduledStart =
hasEvents && showNow ? '' : formatTime(runtime.plannedStart, { format12: 'hh:mm a', format24: 'HH:mm' });
const scheduledEnd =
hasEvents && showNow ? '' : formatTime(runtime.plannedEnd, { format12: 'hh:mm a', format24: 'HH:mm' });
const scheduledStart = showNow ? '' : formatTime(runtime.plannedStart, { format12: 'hh:mm a', format24: 'HH:mm' });
const scheduledEnd = showNow ? '' : formatTime(runtime.plannedEnd, { format12: 'hh:mm a', format24: 'HH:mm' });
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
displayTimer = removeLeadingZero(displayTimer);
let stageTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
stageTimer = removeLeadingZero(stageTimer);
// gather presentation styles
const qrSize = Math.max(window.innerWidth / 15, 72);
const showProgress = getShowProgressBar(time.playback);
const showSchedule = hasEvents && screenHeight > 700; // in vertical screens we may not have space
const showSchedule = screenHeight > 700; // in vertical screens we may not have space
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
@@ -110,64 +107,69 @@ export default function Backstage(props: BackstageProps) {
</div>
</div>
{showProgress && <ProgressBar className='progress-container' current={time.current} duration={time.duration} />}
<ProgressBar
className='progress-container'
current={time.current}
duration={time.duration}
hidden={!showProgress}
/>
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
{backstageEvents.length === 0 && (
<div className='empty-container'>
<Empty text={getLocalizedString('common.no_data')} />
</div>
)}
<div className='card-container'>
{showNow && (
{showNow ? (
<div className={cx(['event', 'now', blinkClass && 'blink'])}>
<TitleCard title={nowMain} secondary={nowSecondary} />
<div className='timer-group'>
<div className='time-entry'>
<div className={cx(['time-entry__label', isPendingStart && 'time-entry--pending'])}>
<div className='aux-timers'>
<div className={cx(['aux-timers__label', isPendingStart && 'aux-timers--pending'])}>
{isPendingStart ? getLocalizedString('countdown.waiting') : getLocalizedString('common.started_at')}
</div>
<SuperscriptTime time={startedAt} className='time-entry__value' />
<SuperscriptTime time={startedAt} className='aux-timers__value' />
</div>
<div className='timer-gap' />
<div className='time-entry'>
<div className='time-entry__label'>{getLocalizedString('common.expected_finish')}</div>
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
{isOvertime(time.current) ? (
<div className='time-entry__value'>{getLocalizedString('countdown.overtime')}</div>
<div className='aux-timers__value'>{getLocalizedString('countdown.overtime')}</div>
) : (
<SuperscriptTime time={formatTime(time.expectedFinish)} className='time-entry__value' />
<SuperscriptTime time={formatTime(time.expectedFinish)} className='aux-timers__value' />
)}
</div>
<div className='timer-gap' />
<div className='time-entry'>
<div className='time-entry__label'>{getLocalizedString('common.stage_timer')}</div>
<div className='time-entry__value'>{displayTimer}</div>
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.stage_timer')}</div>
<div className='aux-timers__value'>{stageTimer}</div>
</div>
</div>
</div>
)}
{!showNow && hasEvents && (
) : (
<div className='event'>
<div className='title-card__placeholder'>{getLocalizedString('countdown.waiting')}</div>
<div className='timer-group'>
<div className='time-entry'>
<div className={cx(['time-entry__label', isPendingStart && 'time-entry--pending'])}>
<div className='aux-timers'>
<div className={cx(['aux-timers__label', isPendingStart && 'aux-timers--pending'])}>
{getLocalizedString('common.scheduled_start')}
</div>
<SuperscriptTime time={scheduledStart} className='time-entry__value' />
<SuperscriptTime time={scheduledStart} className='aux-timers__value' />
</div>
<div className='timer-gap' />
<div className='time-entry'>
<div className='time-entry__label'>{getLocalizedString('common.scheduled_end')}</div>
<SuperscriptTime time={scheduledEnd} className='time-entry__value' />
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.scheduled_end')}</div>
<SuperscriptTime time={scheduledEnd} className='aux-timers__value' />
</div>
</div>
</div>
)}
{showNext && hasEvents && (
<TitleCard className='event' label='next' title={nextMain} secondary={nextSecondary} />
)}
{showNext && <TitleCard className='event' label='next' title={nextMain} secondary={nextSecondary} />}
</div>
{showSchedule && <ScheduleExport selectedId={selectedId} isBackstage />}
{showSchedule && <BackstageSchedule selectedId={selectedId} />}
<div className={cx(['info', !showSchedule && 'info--stretch'])}>
{general.backstageUrl && <QRCode value={general.backstageUrl} size={qrSize} level='L' className='qr' />}
@@ -5,18 +5,17 @@ import Schedule from './Schedule';
import { ScheduleProvider } from './ScheduleContext';
import ScheduleNav from './ScheduleNav';
interface ScheduleExportProps {
interface BackstageScheduleProps {
selectedId: MaybeString;
isBackstage?: boolean;
}
export default memo(ScheduleExport);
function ScheduleExport(props: ScheduleExportProps) {
const { selectedId, isBackstage } = props;
export default memo(BackstageSchedule);
function BackstageSchedule(props: BackstageScheduleProps) {
const { selectedId } = props;
return (
<ScheduleProvider selectedEventId={selectedId} isBackstage>
<ScheduleNav className='schedule-nav-container' />
<Schedule isProduction={isBackstage} className='schedule-container' />
<Schedule isProduction className='schedule-container' />
</ScheduleProvider>
);
}
@@ -0,0 +1,21 @@
import { memo } from 'react';
import { MaybeString } from 'ontime-types';
import Schedule from './Schedule';
import { ScheduleProvider } from './ScheduleContext';
import ScheduleNav from './ScheduleNav';
interface PublicScheduleProps {
selectedId: MaybeString;
}
export default memo(PublicSchedule);
function PublicSchedule(props: PublicScheduleProps) {
const { selectedId } = props;
return (
<ScheduleProvider selectedEventId={selectedId}>
<ScheduleNav className='schedule-nav-container' />
<Schedule className='schedule-container' />
</ScheduleProvider>
);
}
@@ -1,8 +1,5 @@
@use '../../../theme/viewerDefs' as *;
$circle-size: clamp(8px, 0.75vw, 12px);
$indeterminate-width: clamp(32px, 3vw, 48px);
.schedule {
width: 100%;
position: relative;
@@ -20,9 +17,9 @@ $indeterminate-width: clamp(32px, 3vw, 48px);
.entry-colour {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
height: $circle-size;
width: $circle-size;
border-radius: $component-border-radius-full;
height: clamp(8px, 0.75vw, 12px);
width: clamp(8px, 0.75vw, 12px);
border-radius: 6px;
margin-right: 0.25rem;
}
@@ -60,6 +57,7 @@ $indeterminate-width: clamp(32px, 3vw, 48px);
}
.entry-title {
font-size: clamp(16px, 1.5vw, 24px);
font-size: $base-font-size;
line-height: 1.2em;
}
@@ -72,10 +70,10 @@ $indeterminate-width: clamp(32px, 3vw, 48px);
background-color: var(--color-override, $viewer-color);
opacity: 0.2;
height: $circle-size;
width: $circle-size;
border-radius: $component-border-radius-full;
margin-right: 0.5em;
height: clamp(8px, 0.75vw, 12px);
width: clamp(8px, 0.75vw, 12px);
border-radius: 6px;
margin-right: 8px;
transition-property: opacity;
transition-duration: 1s;
@@ -87,7 +85,7 @@ $indeterminate-width: clamp(32px, 3vw, 48px);
}
&--indeterminate {
width: $indeterminate-width;
width: clamp(32px, 3vw, 48px);
}
}
}
@@ -52,7 +52,6 @@ export const ScheduleProvider = ({
const containerRef = useRef<HTMLUListElement>(null);
// After the view is rendered, we paginate by hiding elements that dont fit
useLayoutEffect(() => {
if (!containerRef.current) return;
@@ -1,4 +1,5 @@
import { useCallback, useRef } from 'react';
import { useRef } from 'react';
import { Menu } from '@chakra-ui/react';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import Color from 'color';
import {
@@ -22,8 +23,8 @@ import BlockRow from './cuesheet-table-elements/BlockRow';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import CuesheetTableMenu from './CuesheetTableMenu';
import useColumnManager from './useColumnManager';
import style from './CuesheetTable.module.scss';
@@ -92,13 +93,13 @@ export default function CuesheetTable(props: CuesheetTableProps) {
},
});
const setAllVisible = useCallback(() => {
const setAllVisible = () => {
table.toggleAllColumnsVisible(true);
}, []);
};
const resetColumnResizing = useCallback(() => {
const resetColumnResizing = () => {
setColumnSizing({});
}, []);
};
const headerGroups = table.getHeaderGroups();
const rowModel = table.getRowModel();
@@ -164,24 +165,24 @@ export default function CuesheetTable(props: CuesheetTableProps) {
}
return (
<EventRow
key={key}
eventId={entry.id}
eventIndex={eventIndex}
rowIndex={index}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
skip={entry.skip}
colour={entry.colour}
>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</EventRow>
<Menu key={key} variant='ontime-on-dark' size='sm' isLazy>
<EventRow
eventIndex={eventIndex}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
skip={entry.skip}
colour={entry.colour}
>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</EventRow>
<CuesheetTableMenu event={entry} entryIndex={index} showModal={showModal} />
</Menu>
);
}
@@ -191,7 +192,6 @@ export default function CuesheetTable(props: CuesheetTableProps) {
</tbody>
</table>
</div>
<CuesheetTableMenu showModal={showModal} />
</>
);
}
@@ -5,30 +5,25 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { isOntimeEvent, SupportedEvent } from 'ontime-types';
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { cloneEvent } from '../../../../common/utils/eventsManager';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { cloneEvent } from '../../../common/utils/eventsManager';
interface CuesheetTableMenuActionsProps {
eventId: string;
interface CuesheetTableMenuProps {
event: OntimeEvent;
entryIndex: number;
showModal: (entryId: string) => void;
}
export default function CuesheetTableMenuActions(props: CuesheetTableMenuActionsProps) {
const { eventId, entryIndex, showModal } = props;
const { addEvent, getEventById, reorderEvent, deleteEvent } = useEventAction();
export default function CuesheetTableMenu(props: CuesheetTableMenuProps) {
const { event, entryIndex, showModal } = props;
const { addEvent, reorderEvent, deleteEvent } = useEventAction();
const handleCloneEvent = () => {
const currentEvent = getEventById(eventId);
if (!currentEvent || !isOntimeEvent(currentEvent)) {
return;
}
const newEvent = cloneEvent(currentEvent);
const newEvent = cloneEvent(event);
try {
addEvent(newEvent, { after: eventId });
addEvent(newEvent, { after: event.id });
} catch (_error) {
// we do not handle errors here
}
@@ -36,14 +31,14 @@ export default function CuesheetTableMenuActions(props: CuesheetTableMenuActions
return (
<MenuList>
<MenuItem icon={<IoOptions />} onClick={() => showModal(eventId)}>
<MenuItem icon={<IoOptions />} onClick={() => showModal(event.id)}>
Edit ...
</MenuItem>
<MenuDivider />
<MenuItem icon={<IoAdd />} onClick={() => addEvent({ type: SupportedEvent.Event }, { before: eventId })}>
<MenuItem icon={<IoAdd />} onClick={() => addEvent({ type: SupportedEvent.Event }, { before: event.id })}>
Add event above
</MenuItem>
<MenuItem icon={<IoAdd />} onClick={() => addEvent({ type: SupportedEvent.Event }, { after: eventId })}>
<MenuItem icon={<IoAdd />} onClick={() => addEvent({ type: SupportedEvent.Event }, { after: event.id })}>
Add event below
</MenuItem>
<MenuItem icon={<IoDuplicateOutline />} onClick={handleCloneEvent}>
@@ -53,14 +48,14 @@ export default function CuesheetTableMenuActions(props: CuesheetTableMenuActions
<MenuItem
isDisabled={entryIndex < 1}
icon={<IoArrowUp />}
onClick={() => reorderEvent(eventId, entryIndex, entryIndex - 1)}
onClick={() => reorderEvent(event.id, entryIndex, entryIndex - 1)}
>
Move up
</MenuItem>
<MenuItem icon={<IoArrowDown />} onClick={() => reorderEvent(eventId, entryIndex, entryIndex + 1)}>
<MenuItem icon={<IoArrowDown />} onClick={() => reorderEvent(event.id, entryIndex, entryIndex + 1)}>
Move down
</MenuItem>
<MenuItem icon={<IoTrash />} onClick={() => deleteEvent([eventId])}>
<MenuItem icon={<IoTrash />} onClick={() => deleteEvent([event.id])}>
Delete
</MenuItem>
</MenuList>
@@ -1,18 +1,15 @@
import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react';
import { IconButton } from '@chakra-ui/react';
import { IconButton, MenuButton } from '@chakra-ui/react';
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
import Color from 'color';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { useCuesheetOptions } from '../../cuesheet.options';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import style from '../CuesheetTable.module.scss';
interface EventRowProps {
eventId: string;
eventIndex: number;
rowIndex: number;
isPast?: boolean;
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
skip?: boolean;
@@ -20,13 +17,11 @@ interface EventRowProps {
}
function EventRow(props: PropsWithChildren<EventRowProps>) {
const { children, eventId, eventIndex, rowIndex, isPast, selectedRef, skip, colour } = props;
const { children, eventIndex, isPast, selectedRef, skip, colour } = props;
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
const { openMenu } = useCuesheetTableMenu();
useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
@@ -65,16 +60,12 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
>
{showActionMenu && (
<td className={style.actionColumn}>
<IconButton
<MenuButton
as={IconButton}
size='sm'
aria-label='Options'
icon={<IoEllipsisHorizontal />}
variant='ontime-subtle'
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
const yPos = 8 + rect.y + rect.height / 2;
openMenu({ x: rect.x, y: yPos }, eventId, rowIndex);
}}
/>
</td>
)}
@@ -1,35 +0,0 @@
import { memo } from 'react';
import { Menu, MenuButton, Portal } from '@chakra-ui/react';
import CuesheetTableMenuActionsProps from './CuesheetTableMenuActions';
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
interface CuesheetTableMenuProps {
showModal: (eventId: string) => void;
}
export default memo(CuesheetTableMenu);
function CuesheetTableMenu(props: CuesheetTableMenuProps) {
const { showModal } = props;
const { isOpen, eventId, entryIndex, position, closeMenu } = useCuesheetTableMenu();
return (
<Portal>
{isOpen && (
<Menu isOpen size='sm' onClose={closeMenu} isLazy variant='ontime-on-dark'>
<MenuButton
position='absolute'
left={position.x}
top={position.y}
pointerEvents='none'
aria-hidden
w={1}
h={1}
/>
<CuesheetTableMenuActionsProps eventId={eventId} entryIndex={entryIndex} showModal={showModal} />
</Menu>
)}
</Portal>
);
}
@@ -1,31 +0,0 @@
import { create } from 'zustand';
type Anchor = { x: number; y: number };
type OpenMenu = {
isOpen: true;
eventId: string;
entryIndex: number;
};
type ClosedMenu = {
isOpen: false;
eventId: null;
entryIndex: null;
};
type CuesheetTableMenuStore = (OpenMenu | ClosedMenu) & {
position: Anchor;
openMenu: (position: Anchor, eventId: string, entryIndex: number) => void;
closeMenu: () => void;
};
export const useCuesheetTableMenu = create<CuesheetTableMenuStore>((set) => ({
isOpen: false,
eventId: null,
entryIndex: null,
position: { x: 0, y: 0 },
openMenu: (position: Anchor, eventId: string, entryIndex: number) =>
set({ isOpen: true, position, eventId, entryIndex }),
closeMenu: () => set({ isOpen: false }),
}));
+1 -3
View File
@@ -12,7 +12,6 @@
color: var(--color-override, $viewer-color);
gap: $view-element-gap;
padding: $view-outer-padding;
font-size: $base-font-size;
display: grid;
grid-template-columns: 1fr 40vw;
@@ -28,7 +27,6 @@
left: 0;
right: 0;
margin-top: 25vh;
color: var(--label-color-override, $viewer-label-color);
}
/* =================== HEADER + EXTRAS ===================*/
@@ -91,7 +89,7 @@
row-gap: 0.5em;
}
.time-entry {
.aux-timers {
&__label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
+14 -15
View File
@@ -13,7 +13,7 @@ import { formatTime, getDefaultFormat } from '../../common/utils/time';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { useTranslation } from '../../translation/TranslationProvider';
import { getIsPendingStart } from '../backstage/backstage.utils';
import ScheduleExport from '../common/schedule/ScheduleExport';
import PublicSchedule from '../common/schedule/PublicSchedule';
import { getPublicOptions, usePublicOptions } from './public.options';
import { getCardData, getFirstStartTime } from './public.utils';
@@ -52,7 +52,6 @@ export default function Public(props: BackstageProps) {
useWindowTitle('Public Schedule');
// gather card data
const hasEvents = events.length > 0;
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
publicEventNow,
publicEventNext,
@@ -64,11 +63,11 @@ export default function Public(props: BackstageProps) {
// gather timer data
const clock = formatTime(time.clock);
const isPendingStart = getIsPendingStart(time.playback, time.phase);
const scheduledStart = hasEvents && showNow ? '' : getFirstStartTime(events[0]);
const scheduledStart = showNow ? '' : getFirstStartTime(events[0]);
// gather presentation styles
const qrSize = Math.max(window.innerWidth / 15, 72);
const showSchedule = hasEvents && screenHeight > 700; // in vertical screens we may not have space
const showSchedule = screenHeight > 700; // in vertical screens we may not have space
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
@@ -86,31 +85,31 @@ export default function Public(props: BackstageProps) {
</div>
</div>
{!hasEvents && <Empty text={getLocalizedString('countdown.waiting')} className='empty-container' />}
{events.length === 0 && (
<div className='empty-container'>
<Empty text={getLocalizedString('countdown.waiting')} />
</div>
)}
<div className='card-container'>
{showNow && hasEvents && (
<TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />
)}
{showNow && <TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />}
{!showNow && scheduledStart && (
<div className='event'>
<div className='title-card__placeholder'>{getLocalizedString('countdown.waiting')}</div>
<div className='timer-group'>
<div className='time-entry'>
<div className={cx(['time-entry__label', isPendingStart && 'time-entry--pending'])}>
<div className='aux-timers'>
<div className={cx(['aux-timers__label', isPendingStart && 'aux-timers--pending'])}>
{getLocalizedString('common.scheduled_start')}
</div>
<SuperscriptTime time={formatTime(scheduledStart)} className='time-entry__value' />
<SuperscriptTime time={formatTime(scheduledStart)} className='aux-timers__value' />
</div>
</div>
</div>
)}
{showNext && hasEvents && (
<TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />
)}
{showNext && <TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />}
</div>
{showSchedule && <ScheduleExport selectedId={publicSelectedId} />}
{showSchedule && <PublicSchedule selectedId={publicSelectedId} />}
<div className={cx(['info', !showSchedule && 'info--stretch'])}>
{general.publicUrl && <QRCode value={general.publicUrl} size={qrSize} level='L' className='qr' />}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "3.12.0",
"version": "3.11.1-beta.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+5 -13
View File
@@ -250,29 +250,21 @@ function makeSettingsMenu(redirectWindow) {
],
},
{
label: 'Automation',
label: 'Integrations',
submenu: [
{
label: 'Automation settings',
click: () => redirectWindow('/editor?settings=automation__settings'),
label: 'OSC settings',
click: () => redirectWindow('/editor?settings=integrations__osc'),
},
{
label: 'Manage automations',
click: () => redirectWindow('/editor?settings=automation__automations'),
},
{
label: 'Manage triggers',
click: () => redirectWindow('/editor?settings=automation__triggers'),
label: 'HTTP settings',
click: () => redirectWindow('/editor?settings=integrations__http'),
},
],
},
{
label: 'Network',
submenu: [
{
label: 'Share link',
click: () => redirectWindow('/editor?settings=network__link'),
},
{
label: 'Event log',
click: () => redirectWindow('/editor?settings=network__log'),
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.12.0",
"version": "3.11.1-beta.1",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
+8 -15
View File
@@ -1,15 +1,14 @@
import { LogOrigin } from 'ontime-types';
import { fromBuffer, type OscPacketOutput } from 'osc-min';
import { fromBuffer } from 'osc-min';
import * as dgram from 'node:dgram';
import type { IAdapter } from './IAdapter.js';
import { logger } from '../classes/Logger.js';
import { integrationPayloadFromPath } from './utils/parse.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { isOntimeCloud } from '../externals.js';
import { integrationPayloadFromPath } from './utils/parse.js';
import type { IAdapter } from './IAdapter.js';
class OscServer implements IAdapter {
private udpSocket: dgram.Socket | null = null;
@@ -28,16 +27,10 @@ class OscServer implements IAdapter {
// params: used to create a nested object to patch with
// args: extra data, only used on some API entries
let msg: OscPacketOutput;
try {
msg = fromBuffer(buf);
} catch (_e) {
logger.error(LogOrigin.Rx, 'OSC IN: Received invalid OSC message');
return;
}
const msg = fromBuffer(buf);
if (msg.oscType === 'bundle') {
logger.error(LogOrigin.Rx, 'OSC IN: Ontime is unable to handle OSC bundles');
//TODO: manage bundles
logger.error(LogOrigin.Rx, `OSC IN: We don't take bundles`);
return;
}
@@ -45,7 +38,7 @@ class OscServer implements IAdapter {
// split message
const [, ontimeKey, command, ...params] = address.split('/');
const args = oscArgs[0]?.value ?? undefined;
const args = oscArgs[0]?.value ?? undefined; //TODO: manage multiple args or mayeb we have no usecase
// get first part (ontime)
if (ontimeKey !== 'ontime') {
@@ -74,7 +67,7 @@ class OscServer implements IAdapter {
this.udpSocket.bind(port);
}
shutdown() {
logger.info(LogOrigin.Rx, 'OSC: Closing server');
logger.info(LogOrigin.Rx, `OSC: Closing server`);
this.udpSocket?.close();
this.udpSocket = null;
}
@@ -40,43 +40,43 @@ afterAll(() => {
});
describe('addTrigger()', () => {
beforeEach(async () => {
await deleteAllTriggers();
beforeEach(() => {
deleteAllTriggers();
});
it('should accept a valid trigger', async () => {
it('should accept a valid automation', () => {
const testData: TriggerDTO = {
title: 'test',
trigger: TimerLifeCycle.onLoad,
automationId: 'test-automation-id',
};
const trigger = await addTrigger(testData);
const trigger = addTrigger(testData);
expect(trigger).toMatchObject(testData);
});
});
describe('editTrigger()', () => {
beforeEach(async () => {
await deleteAllTriggers();
await addTrigger({
beforeEach(() => {
deleteAllTriggers();
addTrigger({
title: 'test-osc',
trigger: TimerLifeCycle.onLoad,
automationId: 'test-osc-automation',
});
await addTrigger({
addTrigger({
title: 'test-http',
trigger: TimerLifeCycle.onFinish,
automationId: 'test-http-automation',
});
});
it('should edit the contents of a trigger', async () => {
it('should edit the contents of an automation', () => {
const triggers = getAutomationTriggers();
const fistTrigger = triggers[0];
expect(fistTrigger).toMatchObject({ id: expect.any(String), title: 'test-osc' });
const editedOSC = await editTrigger(fistTrigger.id, {
const editedOSC = editTrigger(fistTrigger.id, {
title: 'edited-title',
trigger: TimerLifeCycle.onDanger,
automationId: 'test-osc-automation',
@@ -92,9 +92,9 @@ describe('editTrigger()', () => {
});
describe('deleteTrigger()', () => {
beforeEach(async () => {
await deleteAllTriggers();
await addTrigger({
beforeEach(() => {
deleteAllTriggers();
addTrigger({
title: 'test-osc',
trigger: TimerLifeCycle.onLoad,
automationId: 'test-osc-automation',
@@ -106,13 +106,13 @@ describe('deleteTrigger()', () => {
});
});
it('should remove an automation from the list', async () => {
it('should remove an automation from the list', () => {
const triggers = getAutomationTriggers();
expect(triggers.length).toEqual(2);
const fistTrigger = triggers[0];
expect(fistTrigger).toMatchObject({ id: expect.any(String), title: 'test-osc' });
await deleteTrigger(fistTrigger.id);
deleteTrigger(fistTrigger.id);
const removed = getAutomationTriggers();
expect(removed.length).toEqual(1);
expect(removed[0].title).not.toEqual('test-osc');
@@ -120,11 +120,11 @@ describe('deleteTrigger()', () => {
});
describe('addAutomation()', () => {
beforeEach(async () => {
await deleteAll();
beforeEach(() => {
deleteAll();
});
it('should accept a valid automation', async () => {
it('should accept a valid automation', () => {
const testData: AutomationDTO = {
title: 'test',
filterRule: 'all',
@@ -132,24 +132,24 @@ describe('addAutomation()', () => {
outputs: [makeOSCAction(), makeHTTPAction()],
};
const automation = await addAutomation(testData);
const automation = addAutomation(testData);
const automations = getAutomations();
expect(automations[automation.id]).toMatchObject(testData);
});
});
describe('editAutomation()', async () => {
describe('editAutomation()', () => {
// saving the ID of the added automation
let firstAutomation: Automation;
beforeEach(async () => {
await deleteAll();
firstAutomation = await addAutomation({
beforeEach(() => {
deleteAll();
firstAutomation = addAutomation({
title: 'test-osc',
filterRule: 'all',
filters: [],
outputs: [],
});
await addAutomation({
addAutomation({
title: 'test-http',
filterRule: 'all',
filters: [],
@@ -157,7 +157,7 @@ describe('editAutomation()', async () => {
});
});
it('should edit the contents of an automation', async () => {
it('should edit the contents of an automation', () => {
const automations = getAutomations();
expect(Object.keys(automations).length).toEqual(2);
expect(automations[firstAutomation.id]).toMatchObject({
@@ -168,7 +168,7 @@ describe('editAutomation()', async () => {
outputs: expect.any(Array),
});
const editedOSC = await editAutomation(firstAutomation.id, {
const editedOSC = editAutomation(firstAutomation.id, {
title: 'edited-title',
filterRule: 'any',
filters: [],
@@ -188,9 +188,9 @@ describe('editAutomation()', async () => {
describe('deleteAutomation()', () => {
// saving the ID of the added automation
let firstAutomation: Automation;
beforeEach(async () => {
await deleteAll();
firstAutomation = await addAutomation({
beforeEach(() => {
deleteAll();
firstAutomation = addAutomation({
title: 'test-osc',
filterRule: 'all',
filters: [],
@@ -198,18 +198,18 @@ describe('deleteAutomation()', () => {
});
});
it('should remove m automation from the list', async () => {
it('should remove m automation from the list', () => {
const automations = getAutomations();
expect(Object.keys(automations).length).toEqual(1);
await deleteAutomation(Object.keys(automations)[0]);
deleteAutomation(Object.keys(automations)[0]);
const removed = getAutomations();
expect(Object.keys(removed).length).toEqual(0);
});
it('should not remove an automation which is in use', async () => {
it('should not remove an automation which is in use', () => {
const automations = getAutomations();
await addTrigger({
addTrigger({
title: 'test-automation',
trigger: TimerLifeCycle.onLoad,
automationId: firstAutomation.id,
@@ -227,6 +227,6 @@ describe('deleteAutomation()', () => {
outputs: expect.any(Array),
});
await expect(deleteAutomation(automationId)).rejects.toThrowError();
expect(() => deleteAutomation(automationId)).toThrowError();
});
});
@@ -39,29 +39,29 @@ describe('triggerAction()', () => {
let oscSpy = vi.spyOn(oscClient, 'emitOSC');
let httpSpy = vi.spyOn(httpClient, 'emitHTTP');
beforeEach(async () => {
beforeEach(() => {
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => {});
await deleteAllTriggers();
const oscAutomation = await addAutomation({
deleteAllTriggers();
const oscAutomation = addAutomation({
title: 'test-osc',
filterRule: 'all',
filters: [],
outputs: [makeOSCAction()],
});
const httpAutomation = await addAutomation({
const httpAutomation = addAutomation({
title: 'test-http',
filterRule: 'any',
filters: [],
outputs: [makeHTTPAction()],
});
await addTrigger({
addTrigger({
title: 'test-osc',
trigger: TimerLifeCycle.onLoad,
automationId: oscAutomation.id,
});
await addTrigger({
addTrigger({
title: 'test-http',
trigger: TimerLifeCycle.onFinish,
automationId: httpAutomation.id,
@@ -1,167 +0,0 @@
import { parseOutput } from '../automation.validation.js';
describe('parseOutput', () => {
describe('handles OSC outputs', () => {
it('parses a valid payload', () => {
const payload = {
type: 'osc',
targetIP: 'localhost',
targetPort: 1234,
address: '/test',
args: 'test',
};
const result = parseOutput(payload);
expect(result).toStrictEqual(payload);
});
it('throws on a invalid payload', () => {
const payload = {
type: 'osc',
targetIP: 1234,
targetPort: 1234,
address: '/test',
args: 'test',
};
expect(() => parseOutput(payload)).toThrow();
});
});
describe('handles HTTP outputs', () => {
it('parses a valid payload', () => {
const payload = {
type: 'http',
url: 'http://asdasdas',
};
const result = parseOutput(payload);
expect(result).toStrictEqual(payload);
});
it('throws on a invalid payload', () => {
const payload = {
type: 'http',
};
expect(() => parseOutput(payload)).toThrow();
});
});
describe('handles Ontime outputs', () => {
it('parses a valid payload', () => {
const auxStart = {
type: 'ontime',
action: 'aux-start',
};
expect(parseOutput(auxStart)).toStrictEqual(auxStart);
const auxStop = {
type: 'ontime',
action: 'aux-stop',
};
expect(parseOutput(auxStop)).toStrictEqual(auxStop);
const auxPause = {
type: 'ontime',
action: 'aux-pause',
};
expect(parseOutput(auxPause)).toStrictEqual(auxPause);
});
it('removes extra properties', () => {
expect(
parseOutput({
type: 'ontime',
action: 'aux-start',
time: 10,
}),
).toStrictEqual({
type: 'ontime',
action: 'aux-start',
});
});
it('throws on a invalid payload', () => {
const payload = {
type: 'ontime',
action: 'not-exist',
};
expect(() => parseOutput(payload)).toThrow();
});
it('parses message-set', () => {
expect(
parseOutput({
type: 'ontime',
action: 'message-set',
text: 'test',
visible: 'true',
}),
).toMatchObject({
text: 'test',
visible: true,
});
expect(
parseOutput({
type: 'ontime',
action: 'message-set',
text: '',
visible: 'false',
}),
).toMatchObject({
text: undefined,
visible: false,
});
expect(
parseOutput({
type: 'ontime',
action: 'message-set',
text: '',
visible: '',
}),
).toMatchObject({
text: undefined,
visible: undefined,
});
expect(() =>
parseOutput({
type: 'ontime',
action: 'message-set',
text: 123,
visible: '',
}),
).toThrow();
});
it('parses message-secondary', () => {});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'test',
}),
).toMatchObject({
secondarySource: null,
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: '',
}),
).toMatchObject({
secondarySource: null,
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'aux',
}),
).toMatchObject({
secondarySource: 'aux',
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'external',
}),
).toMatchObject({
secondarySource: 'external',
});
});
});
@@ -1,22 +1,20 @@
import { getErrorMessage } from 'ontime-utils';
import { Automation, AutomationSettings, ErrorResponse, Trigger } from 'ontime-types';
import { Automation, AutomationOutput, AutomationSettings, ErrorResponse, Trigger } from 'ontime-types';
import type { Request, Response } from 'express';
import { oscServer } from '../../adapters/OscAdapter.js';
import * as automationDao from './automation.dao.js';
import * as automationService from './automation.service.js';
import { parseOutput } from './automation.validation.js';
import { oscServer } from '../../adapters/OscAdapter.js';
export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) {
res.json(automationDao.getAutomationSettings());
}
export async function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) {
export function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) {
try {
// body payload is a patch object that must contain root properties
const automationSettings = await automationDao.editAutomationSettings({
const automationSettings = automationDao.editAutomationSettings({
enabledAutomations: req.body.enabledAutomations,
enabledOscIn: req.body.enabledOscIn,
oscPortIn: req.body.oscPortIn,
@@ -35,9 +33,9 @@ export async function postAutomationSettings(req: Request, res: Response<Automat
}
}
export async function postTrigger(req: Request, res: Response<Trigger | ErrorResponse>) {
export function postTrigger(req: Request, res: Response<Trigger | ErrorResponse>) {
try {
const automation = await automationDao.addTrigger({
const automation = automationDao.addTrigger({
title: req.body.title,
trigger: req.body.trigger,
automationId: req.body.automationId,
@@ -49,10 +47,10 @@ export async function postTrigger(req: Request, res: Response<Trigger | ErrorRes
}
}
export async function putTrigger(req: Request, res: Response<Trigger | ErrorResponse>) {
export function putTrigger(req: Request, res: Response<Trigger | ErrorResponse>) {
try {
// body payload is a patch object
const automation = await automationDao.editTrigger(req.params.id, {
const automation = automationDao.editTrigger(req.params.id, {
title: req.body.title ?? undefined,
trigger: req.body.trigger ?? undefined,
automationId: req.body.automationId ?? undefined,
@@ -64,9 +62,9 @@ export async function putTrigger(req: Request, res: Response<Trigger | ErrorResp
}
}
export async function deleteTrigger(req: Request, res: Response<void | ErrorResponse>) {
export function deleteTrigger(req: Request, res: Response<void | ErrorResponse>) {
try {
await automationDao.deleteTrigger(req.params.id);
automationDao.deleteTrigger(req.params.id);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
@@ -74,9 +72,9 @@ export async function deleteTrigger(req: Request, res: Response<void | ErrorResp
}
}
export async function postAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
export function postAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
try {
const newAutomation = await automationDao.addAutomation({
const newAutomation = automationDao.addAutomation({
title: req.body.title,
filterRule: req.body.filterRule,
filters: req.body.filters,
@@ -89,9 +87,9 @@ export async function postAutomation(req: Request, res: Response<Automation | Er
}
}
export async function editAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
export function editAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
try {
const newAutomation = await automationDao.editAutomation(req.params.id, {
const newAutomation = automationDao.editAutomation(req.params.id, {
title: req.body.title,
filterRule: req.body.filterRule,
filters: req.body.filters,
@@ -104,9 +102,9 @@ export async function editAutomation(req: Request, res: Response<Automation | Er
}
}
export async function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) {
export function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) {
try {
await automationDao.deleteAutomation(req.params.id);
automationDao.deleteAutomation(req.params.id);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
@@ -116,9 +114,8 @@ export async function deleteAutomation(req: Request, res: Response<void | ErrorR
export function testOutput(req: Request, res: Response<void | ErrorResponse>) {
try {
const payload = req.body;
const parsed = parseOutput(payload);
automationService.testOutput(parsed);
const payload = req.body as AutomationOutput;
automationService.testOutput(payload);
res.status(200).send();
} catch (error) {
const message = getErrorMessage(error);
@@ -1,11 +1,4 @@
import type {
Automation,
AutomationDTO,
AutomationSettings,
NormalisedAutomation,
Trigger,
TriggerDTO,
} from 'ontime-types';
import type { Automation, AutomationDTO, AutomationSettings, NormalisedAutomation, Trigger, TriggerDTO } from 'ontime-types';
import { deleteAtIndex, generateId } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
@@ -41,27 +34,27 @@ export function getAutomations(): NormalisedAutomation {
/**
* Patches the automation settings object
*/
export async function editAutomationSettings(settings: Partial<AutomationSettings>): Promise<AutomationSettings> {
await saveChanges(settings);
export function editAutomationSettings(settings: Partial<AutomationSettings>): AutomationSettings {
saveChanges(settings);
return getAutomationSettings();
}
/**
* Adds a validated automation to the store
*/
export async function addTrigger(newTrigger: TriggerDTO): Promise<Trigger> {
export function addTrigger(newTrigger: TriggerDTO): Trigger {
const triggers = getAutomationTriggers();
const id = getUniqueTriggerId(triggers);
const trigger = { ...newTrigger, id };
triggers.push(trigger);
await saveChanges({ triggers });
saveChanges({ triggers });
return trigger;
}
/**
* Patches an existing automation trigger
*/
export async function editTrigger(id: string, newTrigger: TriggerDTO): Promise<Trigger> {
export function editTrigger(id: string, newTrigger: TriggerDTO): Trigger {
const triggers = getAutomationTriggers();
const index = triggers.findIndex((trigger) => trigger.id === id);
@@ -70,14 +63,14 @@ export async function editTrigger(id: string, newTrigger: TriggerDTO): Promise<T
}
triggers[index] = { ...triggers[index], ...newTrigger };
await saveChanges({ triggers });
saveChanges({ triggers });
return triggers[index];
}
/**
* Deletes an automation trigger given its ID
*/
export async function deleteTrigger(id: string): Promise<void> {
export function deleteTrigger(id: string): void {
let triggers = getAutomationTriggers();
const index = triggers.findIndex((trigger) => trigger.id === id);
@@ -86,53 +79,53 @@ export async function deleteTrigger(id: string): Promise<void> {
}
triggers = deleteAtIndex(index, triggers);
await saveChanges({ triggers });
saveChanges({ triggers });
}
/**
* Deletes all project automation triggers
*/
export async function deleteAllTriggers(): Promise<void> {
await saveChanges({ triggers: [] });
export function deleteAllTriggers(): void {
saveChanges({ triggers: [] });
}
/**
* Deletes all project automation triggers and automations
* We do this together to avoid issues with missing references
*/
export async function deleteAll() {
await saveChanges({ triggers: [], automations: {} });
export function deleteAll(): void {
saveChanges({ triggers: [], automations: {} });
}
/**
* Adds a validated automation to the store
*/
export async function addAutomation(newAutomation: AutomationDTO): Promise<Automation> {
export function addAutomation(newAutomation: AutomationDTO): Automation {
const automations = getAutomations();
const id = getUniqueAutomationId(automations);
automations[id] = { ...newAutomation, id };
await saveChanges({ automations });
saveChanges({ automations });
return automations[id];
}
/**
* Updates an existing automation with a new entry
*/
export async function editAutomation(id: string, newAutomation: AutomationDTO): Promise<Automation> {
export function editAutomation(id: string, newAutomation: AutomationDTO): Automation {
const automations = getAutomations();
if (!Object.hasOwn(automations, id)) {
throw new Error(`Automation with id ${id} not found`);
}
automations[id] = { ...newAutomation, id };
await saveChanges({ automations });
saveChanges({ automations });
return automations[id];
}
/**
* Deletes a automation given its ID
*/
export async function deleteAutomation(id: string): Promise<void> {
export function deleteAutomation(id: string): void {
const automations = getAutomations();
// ignore request if automation does not exist
if (!Object.hasOwn(automations, id)) {
@@ -147,7 +140,7 @@ export async function deleteAutomation(id: string): Promise<void> {
}
}
delete automations[id];
await saveChanges({ automations });
saveChanges({ automations });
}
/**
@@ -1,8 +1,6 @@
import {
isHTTPOutput,
isOntimeAction,
isOSCOutput,
LogOrigin,
type AutomationFilter,
type AutomationOutput,
type FilterRule,
@@ -10,7 +8,6 @@ import {
} from 'ontime-types';
import { getPropertyFromPath } from 'ontime-utils';
import { logger } from '../../classes/Logger.js';
import { getState, type RuntimeState } from '../../stores/runtimeState.js';
import { isOntimeCloud } from '../../externals.js';
@@ -18,7 +15,6 @@ import { emitOSC } from './clients/osc.client.js';
import { emitHTTP } from './clients/http.client.js';
import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
import { isBooleanEquals, isGreaterThan, isLessThan } from './automation.utils.js';
import { toOntimeAction } from './clients/ontime.client.js';
/**
* Exposes a method for triggering actions based on a TimerLifeCycle event
@@ -41,7 +37,7 @@ export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) {
triggerAutomations.forEach((trigger) => {
const automation = automations[trigger.automationId];
if (!automation || automation.outputs.length === 0) {
if (!automation) {
return;
}
const shouldSend = testConditions(automation.filters, automation.filterRule, state);
@@ -121,14 +117,12 @@ export function testConditions(
function send(output: AutomationOutput[], state?: RuntimeState) {
const stateSnapshot = state ?? getState();
output.forEach((payload) => {
if (isOSCOutput(payload) && !isOntimeCloud) {
emitOSC(payload, stateSnapshot);
if (isOSCOutput(payload)) {
if (!isOntimeCloud) {
emitOSC(payload, stateSnapshot);
}
} else if (isHTTPOutput(payload)) {
emitHTTP(payload, stateSnapshot);
} else if (isOntimeAction(payload)) {
toOntimeAction(payload);
} else {
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
}
});
}
@@ -1,4 +1,4 @@
import { FilterRule, MaybeNumber, OntimeAction } from 'ontime-types';
import { FilterRule, MaybeNumber } from 'ontime-types';
import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils';
import type { OscArgOrArrayInput, OscArgInput } from 'osc-min';
@@ -12,10 +12,6 @@ export function isFilterRule(value: string): value is FilterRule {
return value === 'all' || value === 'any';
}
export function isOntimeActionAction(value: string): value is OntimeAction['action'] {
return ['aux-start', 'aux-stop', 'aux-pause', 'aux-set', 'message-set', 'message-secondary'].includes(value);
}
function toOscValue(argString: string): OscArgInput {
const argAsNum = Number(argString);
// NOTE: number like: 1 2.0 33333
@@ -3,19 +3,16 @@ import {
AutomationFilter,
AutomationOutput,
HTTPOutput,
OntimeAction,
OSCOutput,
SecondarySource,
timerLifecycleValues,
} from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import type { Request, Response, NextFunction } from 'express';
import { body, oneOf, param, validationResult } from 'express-validator';
import * as assert from '../../utils/assert.js';
import { isFilterOperator, isFilterRule, isOntimeActionAction } from './automation.utils.js';
import { isFilterOperator, isFilterRule } from './automation.utils.js';
export const paramContainsId = [
param('id').exists(),
@@ -134,13 +131,43 @@ function validateFilters(filters: Array<unknown>): filters is AutomationFilter[]
function validateOutput(output: Array<unknown>): output is AutomationOutput[] {
output.forEach((payload) => {
parseOutput(payload);
assert.isObject(payload);
assert.hasKeys(payload, ['type']);
const { type } = payload;
assert.isString(type);
if (type === 'osc') {
validateOSCOutput(payload);
} else if (type === 'http') {
validateHttpOutput(payload);
} else {
throw new Error('Invalid automation');
}
});
return true;
}
function validateOSCOutput(payload: object): payload is OSCOutput {
assert.hasKeys(payload, ['targetIP', 'targetPort', 'address', 'args']);
const { targetIP, targetPort, address, args } = payload;
assert.isString(targetIP);
assert.isNumber(targetPort);
assert.isString(address);
if (typeof args !== 'string' && typeof args !== 'number') {
throw new Error('Invalid automation');
}
return true;
}
function validateHttpOutput(payload: object): payload is HTTPOutput {
assert.hasKeys(payload, ['url']);
const { url } = payload;
assert.isString(url);
return true;
}
export const validateTestPayload = [
body('type').exists().isIn(['osc', 'http', 'ontime']),
body('type').exists().isIn(['osc', 'http']),
// validation for OSC message
oneOf([
@@ -155,143 +182,9 @@ export const validateTestPayload = [
// validation for HTTP message
body('url').if(body('type').equals('http')).isURL({ require_tld: false }).trim(),
// validation for Ontime actions
body('action').if(body('type').equals('ontime')).isString().trim(),
body('text').if(body('type').equals('ontime')).optional().isString().trim(),
body('time').if(body('type').equals('ontime')).optional().isString().trim(),
body('visible').if(body('type').equals('ontime')).optional().isString().trim(),
body('secondarySource').if(body('type').equals('ontime')).optional().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* Sanitises an output object
* @Throws if the output is invalid
*/
export function parseOutput(maybeOutput: unknown): AutomationOutput {
assert.isObject(maybeOutput);
assert.hasKeys(maybeOutput, ['type']);
const { type } = maybeOutput;
assert.isString(type);
if (type === 'osc') {
return parseOSCOutput(maybeOutput);
} else if (type === 'http') {
return parseHTTPOutput(maybeOutput);
} else if (type === 'ontime') {
return parseOntimeAction(maybeOutput);
} else {
throw new Error('Invalid automation output');
}
}
function parseOSCOutput(maybeOSCOutput: object): OSCOutput {
assert.hasKeys(maybeOSCOutput, ['targetIP', 'targetPort', 'address', 'args']);
assert.isString(maybeOSCOutput.targetIP);
assert.isNumber(maybeOSCOutput.targetPort);
assert.isString(maybeOSCOutput.address);
assert.isString(maybeOSCOutput.args);
return {
type: 'osc',
targetIP: maybeOSCOutput.targetIP,
targetPort: maybeOSCOutput.targetPort,
address: maybeOSCOutput.address,
args: maybeOSCOutput.args,
};
}
function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput {
assert.hasKeys(maybeHTTPOutput, ['url']);
assert.isString(maybeHTTPOutput.url);
return {
type: 'http',
url: maybeHTTPOutput.url,
};
}
function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
assert.hasKeys(maybeOntimeAction, ['action']);
assert.isString(maybeOntimeAction.action);
if (!isOntimeActionAction(maybeOntimeAction.action)) {
throw new Error('Invalid Ontime action');
}
// we know we have a valid action, deal with special cases
if (maybeOntimeAction.action === 'aux-set') {
assert.hasKeys(maybeOntimeAction, ['time']);
assert.isString(maybeOntimeAction.time);
return {
type: 'ontime',
action: 'aux-set',
time: parseUserTime(maybeOntimeAction.time),
};
}
if (maybeOntimeAction.action === 'message-set') {
assert.hasKeys(maybeOntimeAction, ['text', 'visible']);
assert.isString(maybeOntimeAction.text);
assert.isString(maybeOntimeAction.visible);
return {
type: 'ontime',
action: 'message-set',
text: indeterminateText(maybeOntimeAction.text),
visible: indeterminateBooleanString(maybeOntimeAction.visible),
};
}
if (maybeOntimeAction.action === 'message-secondary') {
assert.hasKeys(maybeOntimeAction, ['secondarySource']);
assert.isString(maybeOntimeAction.secondarySource);
return {
type: 'ontime',
action: 'message-secondary',
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
};
}
return {
type: 'ontime',
action: maybeOntimeAction.action,
};
}
/**
* Helper function to parse a text which may be indeterminate
* "some text" -> string
* "" -> undefined
*/
function indeterminateText(value: string): string | undefined {
return value === '' ? undefined : value;
}
/**
* Helper function to parse boolean values in transit
* "true" -> true
* "false" -> false
* "" | "null" -> undefined
*/
function indeterminateBooleanString(value: string): boolean | undefined {
return value === '' ? undefined : value === 'true';
}
/**
* Helper function to validate the secondary source
*/
function chooseSecondarySource(value: string): SecondarySource {
if (value === 'aux') return 'aux';
if (value === 'external') return 'external';
return null;
}
@@ -1,48 +0,0 @@
import { LogOrigin, OntimeAction } from 'ontime-types';
import { logger } from '../../../classes/Logger.js';
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
import * as messageService from '../../../services/message-service/MessageService.js';
export function toOntimeAction(action: OntimeAction) {
switch (action.action) {
// Aux timer actions
case 'aux-start':
auxTimerService.start();
break;
case 'aux-stop':
auxTimerService.stop();
break;
case 'aux-pause':
auxTimerService.pause();
break;
case 'aux-set': {
auxTimerService.setTime(action.time);
break;
}
// Message actions
case 'message-set': {
messageService.patch({
timer: {
text: action.text,
visible: action.visible,
},
});
break;
}
case 'message-secondary': {
messageService.patch({
timer: {
secondarySource: action.secondarySource,
},
});
break;
}
default:
// @ts-expect-error -- this guard checks that we handled all the cases, but we still want to log just in case
logger.warning(LogOrigin.Tx, `Unknown action type: ${action.type}`);
break;
}
}
+1 -2
View File
@@ -133,8 +133,7 @@ export const validateFilenameParam = [
.customSanitizer((input: string) => sanitize(input))
.withMessage('Failed to sanitize the filename')
.notEmpty()
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
.withMessage('Filename was empty or contained only invalid characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
+2 -4
View File
@@ -144,7 +144,7 @@ export const initAssets = async () => {
checkStart(OntimeStartOrder.InitAssets);
await clearUploadfolder();
populateStyles();
await populateDemo();
populateDemo();
const project = await initialiseProject();
logger.info(LogOrigin.Server, `Initialised Ontime with ${project}`);
};
@@ -200,7 +200,7 @@ export const startServer = async (
// initialise rundown service
const persistedRundown = getDataProvider().getRundown();
const persistedCustomFields = getDataProvider().getCustomFields();
await initRundown(persistedRundown, persistedCustomFields);
initRundown(persistedRundown, persistedCustomFields);
// initialise message service
messageService.init(eventStore.set, eventStore.get);
@@ -235,8 +235,6 @@ export const startIntegrations = async () => {
const { enabledOscIn, oscPortIn } = getDataProvider().getAutomation();
if (enabledOscIn) {
oscServer.init(oscPortIn);
} else {
logger.info(LogOrigin.Server, 'Skipping OSC integration');
}
};
@@ -114,13 +114,18 @@ async function getDeviceCodes(clientSecret: ClientSecret): Promise<CodesResponse
/**
* Gets credentials from Google Auth server
* @param clientSecret
* @param device_code
* @param interval
* @param expires_in
* @param postAction
*/
function verifyConnection(
clientSecret: ClientSecret,
device_code: string,
interval: number,
expires_in: number,
postAction: () => Promise<any>,
postAction: () => void,
) {
// create poller to check for auth
pollInterval = setInterval(pollForAuth, interval * 1000);
+3 -3
View File
@@ -6,14 +6,14 @@ import { publicDir, publicFiles, srcDir, srcFiles } from './index.js';
/**
* @description ensures directories exist and populates demo folder
*/
export async function populateDemo() {
export const populateDemo = () => {
ensureDirectory(publicDir.demoDir);
try {
copyFileSync(srcFiles.externalReadme, publicFiles.externalReadme);
// even if demo exist we want to use startup demo
await copyDirectory(srcDir.demoDir, publicDir.demoDir);
copyDirectory(srcDir.demoDir, publicDir.demoDir);
} catch (_) {
/* we do not handle this */
}
}
};
+1 -1
View File
@@ -35,7 +35,7 @@ export const eventStore = {
for (const dataKey of changedKeys) {
socket.sendAsJson({ type: `ontime-${dataKey}`, payload: store[dataKey] });
}
socket.sendAsJson({ type: 'ontime-flush' });
socket.sendAsJson({ type: `ontime-flush` });
isUpdatePending = null;
changedKeys.clear();
});
+1 -2
View File
@@ -1,6 +1,5 @@
import { isProduction } from '../externals.js';
import { consoleError } from './console.js';
import { consoleError } from '../utils/console.js';
/**
* Milestone checker for dev environment
+2 -2
View File
@@ -67,11 +67,11 @@ export function mergeObject<T extends object>(a: T, b: Partial<T>): T {
* @param {object} obj
*/
export const removeUndefined = <T extends Record<string, unknown>>(obj: T): Partial<T> => {
return Object.keys(obj).reduce<Partial<T>>((patched, key) => {
return Object.keys(obj).reduce((patched, key) => {
if (typeof obj[key] !== 'undefined') {
// @ts-expect-error -- not sure how to type this
patched[key] = obj[key];
}
return patched;
}, {});
}, {} as Partial<T>);
};
+2 -2
View File
@@ -84,9 +84,9 @@ test('delays are show correctly', async ({ page }) => {
// delay is NOT shown in the public view
await page.goto('http://localhost:4001/public');
await page.getByText('00:1000:20').click();
await page.getByText('00:1000:20').click();
// delay is shown in the backstage view
await page.goto('http://localhost:4001/backstage');
await page.getByText('00:1100:21').click();
await page.getByText('00:1100:21').click();
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.12.0",
"version": "3.11.1-beta.1",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -1,4 +1,3 @@
import type { SecondarySource } from '../runtime/MessageControl.type.js';
import type { TimerLifeCycle } from './TimerLifecycle.type.js';
export type AutomationSettings = {
@@ -39,7 +38,7 @@ export type AutomationFilter = {
value: string; // we use string but would coerce to the field value
};
export type AutomationOutput = OSCOutput | HTTPOutput | OntimeAction;
export type AutomationOutput = OSCOutput | HTTPOutput;
export type OSCOutput = {
type: 'osc';
@@ -53,25 +52,3 @@ export type HTTPOutput = {
type: 'http';
url: string;
};
export type OntimeAction =
| {
type: 'ontime';
action: 'aux-start' | 'aux-stop' | 'aux-pause';
}
| {
type: 'ontime';
action: 'aux-set';
time: number;
}
| {
type: 'ontime';
action: 'message-set';
text?: string;
visible?: boolean;
}
| {
type: 'ontime';
action: 'message-secondary';
secondarySource: SecondarySource;
};
@@ -1,11 +1,9 @@
export type SecondarySource = 'aux' | 'external' | null;
export type TimerMessage = {
text: string;
visible: boolean;
blink: boolean;
blackout: boolean;
secondarySource: SecondarySource;
secondarySource: 'aux' | 'external' | null;
};
export type MessageState = {
+1 -3
View File
@@ -26,7 +26,6 @@ export type {
FilterRule,
HTTPOutput,
NormalisedAutomation,
OntimeAction,
OSCOutput,
Trigger,
TriggerDTO,
@@ -81,7 +80,7 @@ export type {
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
export { Playback } from './definitions/runtime/Playback.type.js';
export { TimerLifeCycle, timerLifecycleValues } from './definitions/core/TimerLifecycle.type.js';
export type { TimerMessage, MessageState, SecondarySource } 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 { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
@@ -105,6 +104,5 @@ export {
isKeyOfType,
isOSCOutput,
isHTTPOutput,
isOntimeAction,
} from './utils/guards.js';
export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
+1 -5
View File
@@ -1,4 +1,4 @@
import type { AutomationOutput, HTTPOutput, OntimeAction, OSCOutput } from '../definitions/core/Automation.type.js';
import type { AutomationOutput, HTTPOutput, OSCOutput } from '../definitions/core/Automation.type.js';
import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js';
import { SupportedEvent } from '../definitions/core/OntimeEvent.type.js';
import type { OntimeRundownEntry } from '../definitions/core/Rundown.type.js';
@@ -41,7 +41,3 @@ export function isOSCOutput(output: AutomationOutput): output is OSCOutput {
export function isHTTPOutput(output: AutomationOutput): output is HTTPOutput {
return output.type === 'http';
}
export function isOntimeAction(output: AutomationOutput): output is OntimeAction {
return output.type === 'ontime';
}
+99 -165
View File
@@ -168,8 +168,8 @@ importers:
specifier: ^3.1.1
version: 3.1.1
zustand:
specifier: ^5.0.3
version: 5.0.3(@types/react@18.0.26)(react@18.3.1)(use-sync-external-store@1.2.0(react@18.3.1))
specifier: ^4.5.2
version: 4.5.2(@types/react@18.0.26)(react@18.3.1)
devDependencies:
'@sentry/vite-plugin':
specifier: ^2.16.1
@@ -257,7 +257,7 @@ importers:
version: 31.2.0
electron-builder:
specifier: ^24.13.3
version: 24.13.3(electron-builder-squirrel-windows@24.13.3)
version: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
eslint:
specifier: 'catalog:'
version: 8.56.0
@@ -2261,8 +2261,8 @@ packages:
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn-walk@8.3.4:
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
acorn-walk@8.3.2:
resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==}
engines: {node: '>=0.4.0'}
acorn@8.11.2:
@@ -2270,11 +2270,6 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.14.0:
resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==}
engines: {node: '>=0.4.0'}
hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
@@ -2380,9 +2375,6 @@ packages:
async@3.2.5:
resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -2504,10 +2496,6 @@ packages:
resolution: {integrity: sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==}
engines: {node: '>= 0.4'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bind@1.0.2:
resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
@@ -2748,8 +2736,8 @@ packages:
supports-color:
optional: true
decimal.js@10.5.0:
resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==}
decimal.js@10.4.3:
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
@@ -2920,18 +2908,10 @@ packages:
resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.0.1:
resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-shim-unscopables@1.0.0:
resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==}
@@ -2972,8 +2952,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
escodegen@2.1.0:
resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
escodegen@2.0.0:
resolution: {integrity: sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==}
engines: {node: '>=6.0'}
hasBin: true
@@ -3200,10 +3180,6 @@ packages:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
form-data@4.0.2:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@@ -3295,18 +3271,10 @@ packages:
resolution: {integrity: sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==}
engines: {node: '>= 0.4'}
get-intrinsic@1.2.7:
resolution: {integrity: sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==}
engines: {node: '>= 0.4'}
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-stream@5.2.0:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
@@ -3428,10 +3396,6 @@ packages:
resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
has@1.0.3:
resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
engines: {node: '>= 0.4.0'}
@@ -3723,6 +3687,10 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
levn@0.3.0:
resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==}
engines: {node: '>= 0.8.0'}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -3959,8 +3927,8 @@ packages:
resolution: {integrity: sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==}
engines: {node: '>=14.16'}
nwsapi@2.2.16:
resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==}
nwsapi@2.2.2:
resolution: {integrity: sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
@@ -4007,6 +3975,10 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
optionator@0.8.3:
resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==}
engines: {node: '>= 0.8.0'}
optionator@0.9.3:
resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
engines: {node: '>= 0.8.0'}
@@ -4039,8 +4011,8 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse5@7.2.1:
resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==}
parse5@7.1.2:
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
@@ -4107,6 +4079,10 @@ packages:
resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.1.2:
resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==}
engines: {node: '>= 0.8.0'}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -4141,8 +4117,8 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
psl@1.15.0:
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
psl@1.9.0:
resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
pump@3.0.0:
resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
@@ -4151,10 +4127,6 @@ packages:
resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==}
engines: {node: '>=6'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qr.js@0.0.0:
resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
@@ -4555,9 +4527,6 @@ packages:
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -4655,8 +4624,8 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
tough-cookie@4.1.4:
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
tough-cookie@4.1.2:
resolution: {integrity: sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==}
engines: {node: '>=6'}
tr46@0.0.3:
@@ -4747,6 +4716,10 @@ packages:
resolution: {integrity: sha512-DUHWQAcC8BTiUZDRzAYGvpSpGLiaOQPfYXlCieQbwUvmml/LRGIe3raKdrOPOoiX0DYlzxs2nH6BoWJoZrj8hA==}
hasBin: true
type-check@0.3.2:
resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==}
engines: {node: '>= 0.8.0'}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -5018,6 +4991,10 @@ packages:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word-wrap@1.2.3:
resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==}
engines: {node: '>=0.10.0'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
@@ -5094,14 +5071,13 @@ packages:
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
engines: {node: '>= 10'}
zustand@5.0.3:
resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==}
engines: {node: '>=12.20.0'}
zustand@4.5.2:
resolution: {integrity: sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=18.0.0'
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
@@ -5109,8 +5085,6 @@ packages:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
@@ -7091,24 +7065,19 @@ snapshots:
acorn-globals@7.0.1:
dependencies:
acorn: 8.14.0
acorn-walk: 8.3.4
acorn: 8.11.2
acorn-walk: 8.3.2
optional: true
acorn-jsx@5.3.2(acorn@8.11.2):
dependencies:
acorn: 8.11.2
acorn-walk@8.3.4:
dependencies:
acorn: 8.14.0
acorn-walk@8.3.2:
optional: true
acorn@8.11.2: {}
acorn@8.14.0:
optional: true
adler-32@1.3.1: {}
agent-base@6.0.2:
@@ -7151,7 +7120,7 @@ snapshots:
app-builder-bin@4.0.0: {}
app-builder-lib@24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3):
app-builder-lib@24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
dependencies:
'@develar/schema-utils': 2.6.5
'@electron/notarize': 2.2.1
@@ -7216,7 +7185,7 @@ snapshots:
archiver@5.3.2:
dependencies:
archiver-utils: 2.1.0
async: 3.2.6
async: 3.2.5
buffer-crc32: 0.2.13
readable-stream: 3.6.2
readdir-glob: 1.1.3
@@ -7268,8 +7237,6 @@ snapshots:
async@3.2.5: {}
async@3.2.6: {}
asynckit@0.4.0: {}
at-least-node@1.0.0: {}
@@ -7430,12 +7397,6 @@ snapshots:
es-errors: 1.3.0
function-bind: 1.1.2
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
optional: true
call-bind@1.0.2:
dependencies:
function-bind: 1.1.2
@@ -7679,7 +7640,7 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js@10.5.0:
decimal.js@10.4.3:
optional: true
decompress-response@6.0.0:
@@ -7728,7 +7689,7 @@ snapshots:
dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3):
dependencies:
app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3)
app-builder-lib: 24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
builder-util: 24.13.1
builder-util-runtime: 9.2.4
fs-extra: 10.1.0
@@ -7794,7 +7755,7 @@ snapshots:
electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3):
dependencies:
app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3)
app-builder-lib: 24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
archiver: 5.3.2
builder-util: 24.13.1
fs-extra: 10.1.0
@@ -7802,9 +7763,9 @@ snapshots:
- dmg-builder
- supports-color
electron-builder@24.13.3(electron-builder-squirrel-windows@24.13.3):
electron-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
dependencies:
app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3)
app-builder-lib: 24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
builder-util: 24.13.1
builder-util-runtime: 9.2.4
chalk: 4.1.2
@@ -7907,25 +7868,12 @@ snapshots:
dependencies:
es-errors: 1.3.0
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
optional: true
es-set-tostringtag@2.0.1:
dependencies:
get-intrinsic: 1.2.2
has: 1.0.3
has-tostringtag: 1.0.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.2.7
has-tostringtag: 1.0.2
hasown: 2.0.2
optional: true
es-shim-unscopables@1.0.0:
dependencies:
has: 1.0.3
@@ -8027,11 +7975,12 @@ snapshots:
escape-string-regexp@4.0.0: {}
escodegen@2.1.0:
escodegen@2.0.0:
dependencies:
esprima: 4.0.1
estraverse: 5.3.0
esutils: 2.0.3
optionator: 0.8.3
optionalDependencies:
source-map: 0.6.1
optional: true
@@ -8327,14 +8276,6 @@ snapshots:
combined-stream: 1.0.8
mime-types: 2.1.35
form-data@4.0.2:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
optional: true
forwarded@0.2.0: {}
frac@1.1.2: {}
@@ -8439,28 +8380,8 @@ snapshots:
hasown: 2.0.2
math-intrinsics: 1.1.0
get-intrinsic@1.2.7:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
optional: true
get-nonce@1.0.1: {}
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
optional: true
get-stream@5.2.0:
dependencies:
pump: 3.0.0
@@ -8629,11 +8550,6 @@ snapshots:
dependencies:
has-symbols: 1.0.3
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
optional: true
has@1.0.3:
dependencies:
function-bind: 1.1.2
@@ -8872,24 +8788,24 @@ snapshots:
jsdom@21.1.0:
dependencies:
abab: 2.0.6
acorn: 8.14.0
acorn: 8.11.2
acorn-globals: 7.0.1
cssom: 0.5.0
cssstyle: 2.3.0
data-urls: 3.0.2
decimal.js: 10.5.0
decimal.js: 10.4.3
domexception: 4.0.0
escodegen: 2.1.0
form-data: 4.0.2
escodegen: 2.0.0
form-data: 4.0.0
html-encoding-sniffer: 3.0.0
http-proxy-agent: 5.0.0
https-proxy-agent: 5.0.1
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.16
parse5: 7.2.1
nwsapi: 2.2.2
parse5: 7.1.2
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 4.1.4
tough-cookie: 4.1.2
w3c-xmlserializer: 4.0.0
webidl-conversions: 7.0.0
whatwg-encoding: 2.0.0
@@ -8958,6 +8874,12 @@ snapshots:
dependencies:
readable-stream: 2.3.8
levn@0.3.0:
dependencies:
prelude-ls: 1.1.2
type-check: 0.3.2
optional: true
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -9138,7 +9060,7 @@ snapshots:
normalize-url@8.0.1: {}
nwsapi@2.2.16:
nwsapi@2.2.2:
optional: true
object-assign@4.1.1: {}
@@ -9189,6 +9111,16 @@ snapshots:
dependencies:
wrappy: 1.0.2
optionator@0.8.3:
dependencies:
deep-is: 0.1.4
fast-levenshtein: 2.0.6
levn: 0.3.0
prelude-ls: 1.1.2
type-check: 0.3.2
word-wrap: 1.2.3
optional: true
optionator@0.9.3:
dependencies:
'@aashutoshrathi/word-wrap': 1.2.6
@@ -9223,7 +9155,7 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse5@7.2.1:
parse5@7.1.2:
dependencies:
entities: 4.5.0
optional: true
@@ -9277,6 +9209,9 @@ snapshots:
picocolors: 1.0.1
source-map-js: 1.2.0
prelude-ls@1.1.2:
optional: true
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.0:
@@ -9307,9 +9242,7 @@ snapshots:
proxy-from-env@1.1.0: {}
psl@1.15.0:
dependencies:
punycode: 2.3.1
psl@1.9.0:
optional: true
pump@3.0.0:
@@ -9319,9 +9252,6 @@ snapshots:
punycode@2.1.1: {}
punycode@2.3.1:
optional: true
qr.js@0.0.0: {}
qs@6.11.0:
@@ -9460,7 +9390,7 @@ snapshots:
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
string_decoder: 1.1.1
util-deprecate: 1.0.2
readdir-glob@1.1.3:
@@ -9782,10 +9712,6 @@ snapshots:
dependencies:
safe-buffer: 5.1.2
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@@ -9876,10 +9802,10 @@ snapshots:
toidentifier@1.0.1: {}
tough-cookie@4.1.4:
tough-cookie@4.1.2:
dependencies:
psl: 1.15.0
punycode: 2.3.1
psl: 1.9.0
punycode: 2.1.1
universalify: 0.2.0
url-parse: 1.5.10
optional: true
@@ -9888,7 +9814,7 @@ snapshots:
tr46@3.0.0:
dependencies:
punycode: 2.3.1
punycode: 2.1.1
optional: true
truncate-utf8-bytes@1.0.2:
@@ -9952,6 +9878,11 @@ snapshots:
turbo-windows-64: 2.3.3
turbo-windows-arm64: 2.3.3
type-check@0.3.2:
dependencies:
prelude-ls: 1.1.2
optional: true
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -10045,7 +9976,6 @@ snapshots:
use-sync-external-store@1.2.0(react@18.3.1):
dependencies:
react: 18.3.1
optional: true
utf8-byte-length@1.0.4: {}
@@ -10293,6 +10223,9 @@ snapshots:
wmf@1.0.2: {}
word-wrap@1.2.3:
optional: true
word@0.3.0: {}
wrap-ansi@7.0.0:
@@ -10358,8 +10291,9 @@ snapshots:
compress-commons: 4.1.2
readable-stream: 3.6.2
zustand@5.0.3(@types/react@18.0.26)(react@18.3.1)(use-sync-external-store@1.2.0(react@18.3.1)):
zustand@4.5.2(@types/react@18.0.26)(react@18.3.1):
dependencies:
use-sync-external-store: 1.2.0(react@18.3.1)
optionalDependencies:
'@types/react': 18.0.26
react: 18.3.1
use-sync-external-store: 1.2.0(react@18.3.1)