mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 13:23:35 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 261e71e8c3 | |||
| 1061ed2a0e | |||
| 3d37f42fe0 | |||
| 1bdea8e436 | |||
| 2c25910863 | |||
| 8f30c0a7df | |||
| 67fa747aae | |||
| 8116b169b2 | |||
| ec823e0095 | |||
| c7faab00a3 | |||
| cfcf6a5684 | |||
| 9b464ff2db | |||
| 544f01630b | |||
| df1cf7a96b | |||
| 0f3feca7ab | |||
| 41fe213ebb | |||
| e91d5f5cd9 | |||
| bedab221d9 | |||
| 97208c052a | |||
| b048d0f88d | |||
| d8f7d4bba6 | |||
| 022778f892 | |||
| c9545d06e2 | |||
| c9013fc8cf | |||
| 4b49d13366 |
@@ -13,10 +13,10 @@
|
|||||||
"@fontsource/open-sans": "^5.0.28",
|
"@fontsource/open-sans": "^5.0.28",
|
||||||
"@mantine/hooks": "^7.13.3",
|
"@mantine/hooks": "^7.13.3",
|
||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
"@sentry/react": "^8.19.0",
|
"@sentry/react": "^8.43.0",
|
||||||
"@tanstack/react-query": "^5.17.9",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
"@tanstack/react-query-devtools": "^5.17.9",
|
"@tanstack/react-query-devtools": "^5.62.7",
|
||||||
"@tanstack/react-table": "^8.11.3",
|
"@tanstack/react-table": "^8.20.5",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.2.0",
|
"axios": "^1.2.0",
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export async function fetchNormalisedRundown(): Promise<RundownCached> {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to post new event
|
* HTTP request to post new event
|
||||||
*/
|
*/
|
||||||
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
|
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> {
|
||||||
return axios.post(rundownPath, data);
|
return axios.post(rundownPath, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infoLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $element-spacing;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
background-color: $gray-1100;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: $inner-section-text-size;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: $info-blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
DrawerOverlay,
|
DrawerOverlay,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
|
||||||
|
|
||||||
|
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||||
|
|
||||||
import ParamInput from './ParamInput';
|
import ParamInput from './ParamInput';
|
||||||
import { isSection, ViewOption } from './types';
|
import { isSection, ViewOption } from './types';
|
||||||
@@ -87,6 +90,8 @@ interface EditFormDrawerProps {
|
|||||||
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
|
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
|
||||||
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { data: viewSettings } = useViewSettings();
|
||||||
|
|
||||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,6 +132,12 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
|||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<DrawerBody>
|
<DrawerBody>
|
||||||
|
{viewSettings.overrideStyles && (
|
||||||
|
<div className={style.infoLabel}>
|
||||||
|
<IoAlertCircle />
|
||||||
|
This view style is being modified by a custom CSS file. <br />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
||||||
{viewOptions.map((option) => {
|
{viewOptions.map((option) => {
|
||||||
if (isSection(option)) {
|
if (isSection(option)) {
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
import {
|
||||||
|
isOntimeEvent,
|
||||||
|
OntimeBlock,
|
||||||
|
OntimeDelay,
|
||||||
|
OntimeEvent,
|
||||||
|
OntimeRundownEntry,
|
||||||
|
RundownCached,
|
||||||
|
TransientEventPayload,
|
||||||
|
} from 'ontime-types';
|
||||||
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
||||||
|
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
@@ -19,6 +27,16 @@ import {
|
|||||||
import { logAxiosError } from '../api/utils';
|
import { logAxiosError } from '../api/utils';
|
||||||
import { useEditorSettings } from '../stores/editorSettings';
|
import { useEditorSettings } from '../stores/editorSettings';
|
||||||
|
|
||||||
|
export type EventOptions = Partial<{
|
||||||
|
// options to any new block (event / delay / block)
|
||||||
|
after: string;
|
||||||
|
before: string;
|
||||||
|
// options to blocks of type OntimeEvent
|
||||||
|
defaultPublic: boolean;
|
||||||
|
linkPrevious: boolean;
|
||||||
|
lastEventId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
|
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
|
||||||
*/
|
*/
|
||||||
@@ -47,31 +65,19 @@ export const useEventAction = () => {
|
|||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
|
|
||||||
// options to any new block (event / delay / block)
|
|
||||||
type BaseOptions = {
|
|
||||||
after?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// options to blocks of type OntimeEvent
|
|
||||||
type EventOptions = BaseOptions &
|
|
||||||
Partial<{
|
|
||||||
defaultPublic: boolean;
|
|
||||||
linkPrevious: boolean;
|
|
||||||
lastEventId: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds an event to rundown
|
* Adds an event to rundown
|
||||||
*/
|
*/
|
||||||
const addEvent = useCallback(
|
const addEvent = useCallback(
|
||||||
async (event: Partial<OntimeRundownEntry>, options?: EventOptions) => {
|
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => {
|
||||||
const newEvent: Partial<OntimeRundownEntry> = { ...event };
|
const newEvent: TransientEventPayload = { ...event };
|
||||||
|
|
||||||
// ************* CHECK OPTIONS specific to events
|
// ************* CHECK OPTIONS specific to events
|
||||||
if (isOntimeEvent(newEvent)) {
|
if (isOntimeEvent(newEvent)) {
|
||||||
// merge creation time options with event settings
|
// merge creation time options with event settings
|
||||||
const applicationOptions = {
|
const applicationOptions = {
|
||||||
after: options?.after,
|
after: options?.after,
|
||||||
|
before: options?.before,
|
||||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||||
lastEventId: options?.lastEventId,
|
lastEventId: options?.lastEventId,
|
||||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||||
@@ -121,11 +127,16 @@ export const useEventAction = () => {
|
|||||||
|
|
||||||
// handle adding options that concern all event type
|
// handle adding options that concern all event type
|
||||||
if (options?.after) {
|
if (options?.after) {
|
||||||
|
// @ts-expect-error -- not sure how to type this, <after> is a transient property
|
||||||
newEvent.after = options.after;
|
newEvent.after = options.after;
|
||||||
}
|
}
|
||||||
|
if (options?.before) {
|
||||||
|
// @ts-expect-error -- not sure how to type this, <before> is a transient property
|
||||||
|
newEvent.before = options.before;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _addEventMutation.mutateAsync(newEvent);
|
await _addEventMutation.mutateAsync(newEvent as TransientEventPayload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Failed adding event', error);
|
logAxiosError('Failed adding event', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const useMessagePreview = () => {
|
|||||||
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
|
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
|
||||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
||||||
timerType: state.eventNow?.timerType ?? null,
|
timerType: state.eventNow?.timerType ?? null,
|
||||||
|
isTimeToEnd: state.eventNow?.isTimeToEnd ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
@@ -148,19 +149,21 @@ export const setAuxTimer = {
|
|||||||
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCuesheet = () => {
|
export const useSelectedEventId = () => {
|
||||||
const featureSelector = (state: RuntimeStore) => ({
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
playback: state.timer.playback,
|
|
||||||
currentBlockId: state.currentBlock.block?.id ?? null,
|
|
||||||
selectedEventId: state.eventNow?.id ?? null,
|
selectedEventId: state.eventNow?.id ?? null,
|
||||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
|
||||||
numEvents: state.runtime.numEvents,
|
|
||||||
titleNow: state.eventNow?.title || '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return useRuntimeStore(featureSelector);
|
return useRuntimeStore(featureSelector);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useCurrentBlockId = () => {
|
||||||
|
const featureSelector = (state: RuntimeStore) => ({
|
||||||
|
currentBlockId: state.currentBlock.block?.id ?? null,
|
||||||
|
});
|
||||||
|
return useRuntimeStore(featureSelector);
|
||||||
|
};
|
||||||
|
|
||||||
export const setEventPlayback = {
|
export const setEventPlayback = {
|
||||||
loadEvent: (id: string) => socketSendJson('load', { id }),
|
loadEvent: (id: string) => socketSendJson('load', { id }),
|
||||||
startEvent: (id: string) => socketSendJson('start', { id }),
|
startEvent: (id: string) => socketSendJson('start', { id }),
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ export type ViewExtendedTimer = {
|
|||||||
|
|
||||||
clock: number;
|
clock: number;
|
||||||
timerType: TimerType;
|
timerType: TimerType;
|
||||||
|
isTimeToEnd: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
|
|||||||
* @return {OntimeEvent} clean event
|
* @return {OntimeEvent} clean event
|
||||||
*/
|
*/
|
||||||
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
|
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
|
||||||
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
|
||||||
return {
|
return {
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
@@ -17,12 +17,12 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
|||||||
timeEnd: event.timeEnd,
|
timeEnd: event.timeEnd,
|
||||||
timerType: event.timerType,
|
timerType: event.timerType,
|
||||||
timeStrategy: event.timeStrategy,
|
timeStrategy: event.timeStrategy,
|
||||||
|
isTimeToEnd: event.isTimeToEnd,
|
||||||
linkStart: event.linkStart,
|
linkStart: event.linkStart,
|
||||||
endAction: event.endAction,
|
endAction: event.endAction,
|
||||||
isPublic: event.isPublic,
|
isPublic: event.isPublic,
|
||||||
skip: event.skip,
|
skip: event.skip,
|
||||||
colour: event.colour,
|
colour: event.colour,
|
||||||
after,
|
|
||||||
revision: 0,
|
revision: 0,
|
||||||
timeWarning: event.timeWarning,
|
timeWarning: event.timeWarning,
|
||||||
timeDanger: event.timeDanger,
|
timeDanger: event.timeDanger,
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ export default function EditorSettingsForm() {
|
|||||||
>
|
>
|
||||||
<option value={TimerType.CountDown}>Count down</option>
|
<option value={TimerType.CountDown}>Count down</option>
|
||||||
<option value={TimerType.CountUp}>Count up</option>
|
<option value={TimerType.CountUp}>Count up</option>
|
||||||
<option value={TimerType.TimeToEnd}>Time to end</option>
|
|
||||||
<option value={TimerType.Clock}>Clock</option>
|
<option value={TimerType.Clock}>Clock</option>
|
||||||
<option value={TimerType.None}>None</option>
|
<option value={TimerType.None}>None</option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const namedImportMap = {
|
|||||||
Duration: 'duration',
|
Duration: 'duration',
|
||||||
Cue: 'cue',
|
Cue: 'cue',
|
||||||
Title: 'title',
|
Title: 'title',
|
||||||
|
'Time to end': 'time to end',
|
||||||
'Is Public': 'public',
|
'Is Public': 'public',
|
||||||
Skip: 'skip',
|
Skip: 'skip',
|
||||||
Note: 'notes',
|
Note: 'notes',
|
||||||
@@ -47,6 +48,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
|||||||
duration: namedImportMap.Duration,
|
duration: namedImportMap.Duration,
|
||||||
cue: namedImportMap.Cue,
|
cue: namedImportMap.Cue,
|
||||||
title: namedImportMap.Title,
|
title: namedImportMap.Title,
|
||||||
|
isTimeToEnd: namedImportMap['Time to end'],
|
||||||
isPublic: namedImportMap['Is Public'],
|
isPublic: namedImportMap['Is Public'],
|
||||||
skip: namedImportMap.Skip,
|
skip: namedImportMap.Skip,
|
||||||
note: namedImportMap.Note,
|
note: namedImportMap.Note,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
<th>Warning Time</th>
|
<th>Warning Time</th>
|
||||||
<th>Danger Time</th>
|
<th>Danger Time</th>
|
||||||
|
<th>Is Time to End</th>
|
||||||
<th>Is Public</th>
|
<th>Is Public</th>
|
||||||
<th>Skip</th>
|
<th>Skip</th>
|
||||||
<th>Colour</th>
|
<th>Colour</th>
|
||||||
@@ -71,6 +72,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
}
|
}
|
||||||
eventIndex += 1;
|
eventIndex += 1;
|
||||||
const colour = event.colour ? getAccessibleColour(event.colour) : {};
|
const colour = event.colour ? getAccessibleColour(event.colour) : {};
|
||||||
|
const isTimeToEnd = booleanToText(event.isTimeToEnd);
|
||||||
const isPublic = booleanToText(event.isPublic);
|
const isPublic = booleanToText(event.isPublic);
|
||||||
const skip = booleanToText(event.skip);
|
const skip = booleanToText(event.skip);
|
||||||
|
|
||||||
@@ -93,6 +95,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
|||||||
<td>{millisToString(event.duration)}</td>
|
<td>{millisToString(event.duration)}</td>
|
||||||
<td>{millisToString(event.timeWarning)}</td>
|
<td>{millisToString(event.timeWarning)}</td>
|
||||||
<td>{millisToString(event.timeDanger)}</td>
|
<td>{millisToString(event.timeDanger)}</td>
|
||||||
|
<td className={style.center}>{isTimeToEnd && <Tag>{isTimeToEnd}</Tag>}</td>
|
||||||
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
|
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
|
||||||
<td>{skip && <Tag>{skip}</Tag>}</td>
|
<td>{skip && <Tag>{skip}</Tag>}</td>
|
||||||
<td style={{ ...colour }}>{event.colour}</td>
|
<td style={{ ...colour }}>{event.colour}</td>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Corner } from '../../editors/editor-utils/EditorUtils';
|
|||||||
import style from './MessageControl.module.scss';
|
import style from './MessageControl.module.scss';
|
||||||
|
|
||||||
export default function TimerPreview() {
|
export default function TimerPreview() {
|
||||||
const { blink, blackout, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
|
const { blink, blackout, isTimeToEnd, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
|
||||||
useMessagePreview();
|
useMessagePreview();
|
||||||
const { data } = useViewSettings();
|
const { data } = useViewSettings();
|
||||||
|
|
||||||
@@ -24,11 +24,11 @@ export default function TimerPreview() {
|
|||||||
|
|
||||||
const main = (() => {
|
const main = (() => {
|
||||||
if (showTimerMessage) return 'Message';
|
if (showTimerMessage) return 'Message';
|
||||||
|
if (timerType === TimerType.None) return timerPlaceholder;
|
||||||
if (phase === TimerPhase.Pending) return 'Standby to start';
|
if (phase === TimerPhase.Pending) return 'Standby to start';
|
||||||
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
|
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
|
||||||
if (timerType === TimerType.TimeToEnd) return 'Time to end';
|
|
||||||
if (timerType === TimerType.Clock) return 'Clock';
|
if (timerType === TimerType.Clock) return 'Clock';
|
||||||
if (timerType === TimerType.None) return timerPlaceholder;
|
if (isTimeToEnd) return 'Target event scheduled end';
|
||||||
return 'Timer';
|
return 'Timer';
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -74,12 +74,16 @@ export default function TimerPreview() {
|
|||||||
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
|
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||||
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
|
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
|
|
||||||
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label='Time type: None' openDelay={tooltipDelayMid} shouldWrapChildren>
|
<Tooltip label='Time type: None' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||||
<IoBan className={style.statusIcon} data-active={timerType === TimerType.None} />
|
<IoBan className={style.statusIcon} data-active={timerType === TimerType.None} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
label={isTimeToEnd ? 'Count to schedule' : 'Count from start'}
|
||||||
|
openDelay={tooltipDelayMid}
|
||||||
|
shouldWrapChildren
|
||||||
|
>
|
||||||
|
<IoFlag className={style.statusIcon} data-active={isTimeToEnd} />
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,4 +16,8 @@
|
|||||||
&.finished {
|
&.finished {
|
||||||
color: $timer-finished-color;
|
color: $timer-finished-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.muted {
|
||||||
|
color: $muted-gray;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function TimerDisplay(props: TimerDisplayProps) {
|
|||||||
const isNegative = (time ?? 0) < 0;
|
const isNegative = (time ?? 0) < 0;
|
||||||
const display =
|
const display =
|
||||||
time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
|
time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
|
||||||
const classes = cx([style.timer, isNegative ? style.finished : null]);
|
const classes = cx([style.timer, isNegative ? style.finished : null, time === null && style.muted]);
|
||||||
|
|
||||||
return <div className={classes}>{display}</div>;
|
return <div className={classes}>{display}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: $label-gray;
|
color: $ui-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
|
|||||||
@@ -4,6 +4,23 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.isOffline {
|
||||||
|
.info {
|
||||||
|
opacity: $opacity-disabled;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: 'Disconnected';
|
||||||
|
position: absolute;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background-color: $red-700;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { memo, useMemo } from 'react';
|
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||||
import { useRuntimeOverview, useRuntimePlaybackOverview, useTimer } from '../../common/hooks/useSocket';
|
import { useIsOnline, useRuntimeOverview, useRuntimePlaybackOverview, useTimer } from '../../common/hooks/useSocket';
|
||||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||||
import { enDash } from '../../common/utils/styleUtils';
|
import { cx, enDash, timerPlaceholder } from '../../common/utils/styleUtils';
|
||||||
|
|
||||||
import { TimeColumn, TimeRow } from './composite/TimeLayout';
|
import { TimeColumn, TimeRow } from './composite/TimeLayout';
|
||||||
import { calculateEndAndDaySpan, formatedTime, getOffsetText } from './overviewUtils';
|
import { calculateEndAndDaySpan, formatedTime, getOffsetText } from './overviewUtils';
|
||||||
@@ -13,7 +13,7 @@ import style from './Overview.module.scss';
|
|||||||
|
|
||||||
export const EditorOverview = memo(_EditorOverview);
|
export const EditorOverview = memo(_EditorOverview);
|
||||||
|
|
||||||
function _EditorOverview({ children }: { children: React.ReactNode }) {
|
function _EditorOverview({ children }: PropsWithChildren) {
|
||||||
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
|
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
|
||||||
|
|
||||||
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
|
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
|
||||||
@@ -23,36 +23,48 @@ function _EditorOverview({ children }: { children: React.ReactNode }) {
|
|||||||
const expectedEndText = formatedTime(maybeExpectedEnd);
|
const expectedEndText = formatedTime(maybeExpectedEnd);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.overview}>
|
<OverviewWrapper navElements={children}>
|
||||||
<ErrorBoundary>
|
<TitlesOverview />
|
||||||
<div className={style.nav}>{children}</div>
|
<div>
|
||||||
<div className={style.info}>
|
<TimeRow
|
||||||
<TitlesOverview />
|
label='Planned start'
|
||||||
<div>
|
value={formatedTime(plannedStart)}
|
||||||
<TimeRow label='Planned start' value={formatedTime(plannedStart)} className={style.start} />
|
className={style.start}
|
||||||
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
|
muted={plannedStart === null}
|
||||||
</div>
|
/>
|
||||||
<ProgressOverview />
|
<TimeRow
|
||||||
<CurrentBlockOverview />
|
label='Actual start'
|
||||||
<RuntimeOverview />
|
value={formatedTime(actualStart)}
|
||||||
<div>
|
className={style.start}
|
||||||
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
|
muted={actualStart === null}
|
||||||
<TimeRow
|
/>
|
||||||
label='Expected end'
|
</div>
|
||||||
value={expectedEndText}
|
<ProgressOverview />
|
||||||
className={style.end}
|
<CurrentBlockOverview />
|
||||||
daySpan={maybeExpectedDaySpan}
|
<RuntimeOverview />
|
||||||
/>
|
<div>
|
||||||
</div>
|
<TimeRow
|
||||||
</div>
|
label='Planned end'
|
||||||
</ErrorBoundary>
|
value={plannedEndText}
|
||||||
</div>
|
className={style.end}
|
||||||
|
daySpan={maybePlannedDaySpan}
|
||||||
|
muted={maybePlannedEnd === null}
|
||||||
|
/>
|
||||||
|
<TimeRow
|
||||||
|
label='Expected end'
|
||||||
|
value={expectedEndText}
|
||||||
|
className={style.end}
|
||||||
|
daySpan={maybeExpectedDaySpan}
|
||||||
|
muted={maybeExpectedEnd === null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</OverviewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CuesheetOverview = memo(_CuesheetOverview);
|
export const CuesheetOverview = memo(_CuesheetOverview);
|
||||||
|
|
||||||
function _CuesheetOverview({ children }: { children: React.ReactNode }) {
|
function _CuesheetOverview({ children }: PropsWithChildren) {
|
||||||
const { plannedEnd, expectedEnd } = useRuntimeOverview();
|
const { plannedEnd, expectedEnd } = useRuntimeOverview();
|
||||||
|
|
||||||
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
|
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
|
||||||
@@ -62,23 +74,41 @@ function _CuesheetOverview({ children }: { children: React.ReactNode }) {
|
|||||||
const expectedEndText = formatedTime(maybeExpectedEnd);
|
const expectedEndText = formatedTime(maybeExpectedEnd);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.overview}>
|
<OverviewWrapper navElements={children}>
|
||||||
|
<TitlesOverview />
|
||||||
|
<TimerOverview />
|
||||||
|
<RuntimeOverview />
|
||||||
|
<div>
|
||||||
|
<TimeRow
|
||||||
|
label='Planned end'
|
||||||
|
value={plannedEndText}
|
||||||
|
className={style.end}
|
||||||
|
daySpan={maybePlannedDaySpan}
|
||||||
|
muted={maybePlannedEnd === null}
|
||||||
|
/>
|
||||||
|
<TimeRow
|
||||||
|
label='Expected end'
|
||||||
|
value={expectedEndText}
|
||||||
|
className={style.end}
|
||||||
|
daySpan={maybeExpectedDaySpan}
|
||||||
|
muted={maybeExpectedEnd === null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</OverviewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewWrapperProps {
|
||||||
|
navElements: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewWrapper({ navElements, children }: PropsWithChildren<OverviewWrapperProps>) {
|
||||||
|
const { isOnline } = useIsOnline();
|
||||||
|
return (
|
||||||
|
<div className={cx([style.overview, !isOnline && style.isOffline])}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className={style.nav}>{children}</div>
|
<div className={style.nav}>{navElements}</div>
|
||||||
<div className={style.info}>
|
<div className={style.info}>{children}</div>
|
||||||
<TitlesOverview />
|
|
||||||
<TimerOverview />
|
|
||||||
<RuntimeOverview />
|
|
||||||
<div>
|
|
||||||
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
|
|
||||||
<TimeRow
|
|
||||||
label='Expected end'
|
|
||||||
value={expectedEndText}
|
|
||||||
className={style.end}
|
|
||||||
daySpan={maybeExpectedDaySpan}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -104,15 +134,22 @@ function CurrentBlockOverview() {
|
|||||||
|
|
||||||
const timeInBlock = formatedTime(currentBlock.startedAt === null ? null : clock - currentBlock.startedAt);
|
const timeInBlock = formatedTime(currentBlock.startedAt === null ? null : clock - currentBlock.startedAt);
|
||||||
|
|
||||||
return <TimeColumn label='Time in block' value={timeInBlock} className={style.clock} />;
|
return (
|
||||||
|
<TimeColumn
|
||||||
|
label='Time in block'
|
||||||
|
value={timeInBlock}
|
||||||
|
className={style.clock}
|
||||||
|
muted={currentBlock.startedAt === null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimerOverview() {
|
function TimerOverview() {
|
||||||
const { current } = useTimer();
|
const { current } = useTimer();
|
||||||
|
|
||||||
const display = millisToString(current);
|
const display = millisToString(current, { fallback: timerPlaceholder });
|
||||||
|
|
||||||
return <TimeColumn label='Running timer' value={display} />;
|
return <TimeColumn label='Running timer' value={display} muted={current === null} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressOverview() {
|
function ProgressOverview() {
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
content: "*";
|
content: "*";
|
||||||
vertical-align: super;
|
vertical-align: super;
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
color: $blue-500;
|
color: $info-blue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: $muted-gray;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,20 +7,21 @@ import style from './TimeLayout.module.scss';
|
|||||||
interface TimeLayoutProps {
|
interface TimeLayoutProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
muted?: boolean;
|
||||||
daySpan?: number;
|
daySpan?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimeColumn({ label, value, className }: TimeLayoutProps) {
|
export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<span className={style.label}>{label}</span>
|
<span className={style.label}>{label}</span>
|
||||||
<span className={cx([style.clock, className])}>{value}</span>
|
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimeRow({ label, value, daySpan, className }: TimeLayoutProps) {
|
export function TimeRow({ label, value, daySpan, muted, className }: TimeLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className={style.row}>
|
<div className={style.row}>
|
||||||
<span className={style.label}>{label}</span>
|
<span className={style.label}>{label}</span>
|
||||||
@@ -29,7 +30,7 @@ export function TimeRow({ label, value, daySpan, className }: TimeLayoutProps) {
|
|||||||
<span className={cx([style.clock, style.daySpan, className])}>{value}</span>
|
<span className={cx([style.clock, style.daySpan, className])}>{value}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<span className={cx([style.clock, className])}>{value}</span>
|
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
isOntimeBlock,
|
isOntimeBlock,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
|
MaybeString,
|
||||||
PlayableEvent,
|
PlayableEvent,
|
||||||
Playback,
|
Playback,
|
||||||
RundownCached,
|
RundownCached,
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
isNewLatest,
|
isNewLatest,
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction';
|
||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||||
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
||||||
@@ -82,36 +83,36 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
const cloneEntry = rundown[copyId];
|
const cloneEntry = rundown[copyId];
|
||||||
if (cloneEntry?.type === SupportedEvent.Event) {
|
if (cloneEntry?.type === SupportedEvent.Event) {
|
||||||
//if we don't have a cursor add the new event on top
|
//if we don't have a cursor add the new event on top
|
||||||
const newEvent = cloneEvent(cloneEntry, adjustedCursor ?? undefined);
|
const newEvent = cloneEvent(cloneEntry);
|
||||||
addEvent(newEvent);
|
addEvent(newEvent, { after: adjustedCursor ?? undefined });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addEvent, order, rundown],
|
[addEvent, order, rundown],
|
||||||
);
|
);
|
||||||
|
|
||||||
const insertAtId = useCallback(
|
const insertAtId = useCallback(
|
||||||
(type: SupportedEvent, id: string | null, above = false) => {
|
(type: SupportedEvent, id: MaybeString, above = false) => {
|
||||||
const adjustedCursor = above ? getPreviousNormal(rundown, order, id ?? '').entry?.id ?? null : id;
|
const options: EventOptions =
|
||||||
if (adjustedCursor === null) {
|
id === null
|
||||||
// the only thing to do is adding an event at top
|
? {}
|
||||||
addEvent({ type });
|
: {
|
||||||
return;
|
after: above ? undefined : id,
|
||||||
}
|
before: above ? id : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
if (type === SupportedEvent.Event) {
|
if (type === SupportedEvent.Event) {
|
||||||
const newEvent = {
|
const newEvent = {
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
};
|
};
|
||||||
const options = {
|
if (!above && id) {
|
||||||
after: adjustedCursor,
|
options.lastEventId = id;
|
||||||
lastEventId: adjustedCursor,
|
}
|
||||||
};
|
|
||||||
addEvent(newEvent, options);
|
addEvent(newEvent, options);
|
||||||
} else {
|
} else {
|
||||||
addEvent({ type }, { after: adjustedCursor });
|
addEvent({ type }, options);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[rundown, order, addEvent],
|
[addEvent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectBlock = useCallback(
|
const selectBlock = useCallback(
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
return deleteEvent([data.id]);
|
return deleteEvent([data.id]);
|
||||||
}
|
}
|
||||||
case 'clone': {
|
case 'clone': {
|
||||||
const newEvent = cloneEvent(data as OntimeEvent, data.id);
|
const newEvent = cloneEvent(data as OntimeEvent);
|
||||||
addEvent(newEvent, { after: data.id });
|
addEvent(newEvent, { after: data.id });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -150,6 +150,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
if (data.type === SupportedEvent.Event) {
|
if (data.type === SupportedEvent.Event) {
|
||||||
return (
|
return (
|
||||||
<EventBlock
|
<EventBlock
|
||||||
|
eventId={data.id}
|
||||||
eventIndex={eventIndex}
|
eventIndex={eventIndex}
|
||||||
cue={data.cue}
|
cue={data.cue}
|
||||||
timeStart={data.timeStart}
|
timeStart={data.timeStart}
|
||||||
@@ -157,7 +158,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
duration={data.duration}
|
duration={data.duration}
|
||||||
timeStrategy={data.timeStrategy}
|
timeStrategy={data.timeStrategy}
|
||||||
linkStart={data.linkStart}
|
linkStart={data.linkStart}
|
||||||
eventId={data.id}
|
isTimeToEnd={data.isTimeToEnd}
|
||||||
isPublic={data.isPublic}
|
isPublic={data.isPublic}
|
||||||
endAction={data.endAction}
|
endAction={data.endAction}
|
||||||
timerType={data.timerType}
|
timerType={data.timerType}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { handleLinks } from '../../common/utils/linkUtils';
|
|||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import { Corner } from '../editors/editor-utils/EditorUtils';
|
import { Corner } from '../editors/editor-utils/EditorUtils';
|
||||||
|
|
||||||
import EventEditor from './event-editor/EventEditor';
|
import RundownEventEditor from './event-editor/RundownEventEditor';
|
||||||
import RundownWrapper from './RundownWrapper';
|
import RundownWrapper from './RundownWrapper';
|
||||||
|
|
||||||
import style from './RundownExport.module.scss';
|
import style from './RundownExport.module.scss';
|
||||||
@@ -33,7 +33,7 @@ const RundownExport = () => {
|
|||||||
{!hideSideBar && (
|
{!hideSideBar && (
|
||||||
<div className={style.side}>
|
<div className={style.side}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<EventEditor />
|
<RundownEventEditor />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,13 +24,14 @@ import RundownIndicators from './RundownIndicators';
|
|||||||
import style from './EventBlock.module.scss';
|
import style from './EventBlock.module.scss';
|
||||||
|
|
||||||
interface EventBlockProps {
|
interface EventBlockProps {
|
||||||
|
eventId: string;
|
||||||
cue: string;
|
cue: string;
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
timeEnd: number;
|
timeEnd: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
timeStrategy: TimeStrategy;
|
timeStrategy: TimeStrategy;
|
||||||
linkStart: MaybeString;
|
linkStart: MaybeString;
|
||||||
eventId: string;
|
isTimeToEnd: boolean;
|
||||||
eventIndex: number;
|
eventIndex: number;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
endAction: EndAction;
|
endAction: EndAction;
|
||||||
@@ -68,6 +69,7 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
duration,
|
duration,
|
||||||
timeStrategy,
|
timeStrategy,
|
||||||
linkStart,
|
linkStart,
|
||||||
|
isTimeToEnd,
|
||||||
isPublic = true,
|
isPublic = true,
|
||||||
eventIndex,
|
eventIndex,
|
||||||
endAction,
|
endAction,
|
||||||
@@ -286,6 +288,7 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
timeEnd={timeEnd}
|
timeEnd={timeEnd}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
linkStart={linkStart}
|
linkStart={linkStart}
|
||||||
|
isTimeToEnd={isTimeToEnd}
|
||||||
timeStrategy={timeStrategy}
|
timeStrategy={timeStrategy}
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
eventIndex={eventIndex}
|
eventIndex={eventIndex}
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ import EventBlockProgressBar from './composite/EventBlockProgressBar';
|
|||||||
import style from './EventBlock.module.scss';
|
import style from './EventBlock.module.scss';
|
||||||
|
|
||||||
interface EventBlockInnerProps {
|
interface EventBlockInnerProps {
|
||||||
|
eventId: string;
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
timeEnd: number;
|
timeEnd: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
timeStrategy: TimeStrategy;
|
timeStrategy: TimeStrategy;
|
||||||
linkStart: MaybeString;
|
linkStart: MaybeString;
|
||||||
eventId: string;
|
isTimeToEnd: boolean;
|
||||||
eventIndex: number;
|
eventIndex: number;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
endAction: EndAction;
|
endAction: EndAction;
|
||||||
@@ -43,14 +44,15 @@ interface EventBlockInnerProps {
|
|||||||
isRolling: boolean;
|
isRolling: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EventBlockInner = (props: EventBlockInnerProps) => {
|
function EventBlockInner(props: EventBlockInnerProps) {
|
||||||
const {
|
const {
|
||||||
|
eventId,
|
||||||
timeStart,
|
timeStart,
|
||||||
timeEnd,
|
timeEnd,
|
||||||
duration,
|
duration,
|
||||||
timeStrategy,
|
timeStrategy,
|
||||||
linkStart,
|
linkStart,
|
||||||
eventId,
|
isTimeToEnd,
|
||||||
isPublic = true,
|
isPublic = true,
|
||||||
endAction,
|
endAction,
|
||||||
timerType,
|
timerType,
|
||||||
@@ -91,7 +93,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
|||||||
delay={delay}
|
delay={delay}
|
||||||
timeStrategy={timeStrategy}
|
timeStrategy={timeStrategy}
|
||||||
linkStart={linkStart}
|
linkStart={linkStart}
|
||||||
timerType={timerType}
|
isTimeToEnd={isTimeToEnd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.titleSection}>
|
<div className={style.titleSection}>
|
||||||
@@ -122,6 +124,11 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
|||||||
<EndActionIcon action={endAction} className={style.statusIcon} />
|
<EndActionIcon action={endAction} className={style.statusIcon} />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip label={`${isTimeToEnd ? 'Time to End' : 'Count from start'}`} openDelay={tooltipDelayMid}>
|
||||||
|
<span>
|
||||||
|
<IoFlag className={`${style.statusIcon} ${isTimeToEnd ? style.active : style.disabled}`} />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}>
|
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}>
|
||||||
<span>
|
<span>
|
||||||
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
|
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
|
||||||
@@ -131,7 +138,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default memo(EventBlockInner);
|
export default memo(EventBlockInner);
|
||||||
|
|
||||||
@@ -162,9 +169,5 @@ function TimerIcon(props: { type: TimerType; className: string }) {
|
|||||||
if (type === TimerType.None) {
|
if (type === TimerType.None) {
|
||||||
return <IoBan className={className} />;
|
return <IoBan className={className} />;
|
||||||
}
|
}
|
||||||
if (type === TimerType.TimeToEnd) {
|
|
||||||
const classes = cx([style.active, className]);
|
|
||||||
return <IoFlag className={classes} />;
|
|
||||||
}
|
|
||||||
return <IoArrowDown className={className} />;
|
return <IoArrowDown className={className} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import useRundown from '../../../common/hooks-query/useRundown';
|
||||||
|
|
||||||
|
import EventEditor from './EventEditor';
|
||||||
|
|
||||||
|
import style from './EventEditor.module.scss';
|
||||||
|
|
||||||
|
interface CuesheetEventEditorProps {
|
||||||
|
eventId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
|
||||||
|
const { eventId } = props;
|
||||||
|
const { data } = useRundown();
|
||||||
|
const { order, rundown } = data;
|
||||||
|
|
||||||
|
const [event, setEvent] = useState<OntimeEvent | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (order.length === 0) {
|
||||||
|
setEvent(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = rundown[eventId];
|
||||||
|
if (event && isOntimeEvent(event)) {
|
||||||
|
setEvent(event);
|
||||||
|
} else {
|
||||||
|
setEvent(null);
|
||||||
|
}
|
||||||
|
}, [data, eventId, order, rundown]);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.eventEditor} data-testid='editor-container'>
|
||||||
|
<EventEditor event={event} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,18 +16,10 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2rem;
|
gap: 1rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
|
||||||
border-top: 1px solid $white-10;
|
|
||||||
padding-top: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeSettings {
|
.timeSettings {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -38,6 +30,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.decorated {
|
.decorated {
|
||||||
@@ -63,6 +56,7 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
max-width: max-content;
|
max-width: max-content;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
height: 30px; // manually match the height of a text input
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline {
|
.inline {
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { CSSProperties, memo, useCallback, useEffect, useState } from 'react';
|
import { CSSProperties, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import CopyTag from '../../../common/components/copy-tag/CopyTag';
|
|
||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import useRundown from '../../../common/hooks-query/useRundown';
|
|
||||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
||||||
import { useEventSelection } from '../useEventSelection';
|
|
||||||
|
|
||||||
import EventEditorTimes from './composite/EventEditorTimes';
|
import EventEditorTimes from './composite/EventEditorTimes';
|
||||||
import EventEditorTitles from './composite/EventEditorTitles';
|
import EventEditorTitles from './composite/EventEditorTitles';
|
||||||
@@ -22,35 +19,17 @@ export type EventEditorSubmitActions = keyof OntimeEvent;
|
|||||||
|
|
||||||
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
|
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
|
||||||
|
|
||||||
export default function EventEditor() {
|
interface EventEditorProps {
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
event: OntimeEvent;
|
||||||
const { data } = useRundown();
|
}
|
||||||
|
|
||||||
|
export default function EventEditor(props: EventEditorProps) {
|
||||||
|
const { event } = props;
|
||||||
const { data: customFields } = useCustomFields();
|
const { data: customFields } = useCustomFields();
|
||||||
const { order, rundown } = data;
|
|
||||||
const { updateEvent } = useEventAction();
|
const { updateEvent } = useEventAction();
|
||||||
const [_searchParams, setSearchParams] = useSearchParams();
|
const [_searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const [event, setEvent] = useState<OntimeEvent | null>(null);
|
const isEditor = window.location.pathname.includes('editor');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (order.length === 0) {
|
|
||||||
setEvent(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedEventId = order.find((eventId) => selectedEvents.has(eventId));
|
|
||||||
if (!selectedEventId) {
|
|
||||||
setEvent(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const event = rundown[selectedEventId];
|
|
||||||
|
|
||||||
if (event && isOntimeEvent(event)) {
|
|
||||||
setEvent(event);
|
|
||||||
} else {
|
|
||||||
setEvent(null);
|
|
||||||
}
|
|
||||||
}, [order, rundown, selectedEvents]);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(field: EditorUpdateFields, value: string) => {
|
(field: EditorUpdateFields, value: string) => {
|
||||||
@@ -73,86 +52,61 @@ export default function EventEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.eventEditor} data-testid='editor-container'>
|
<div className={style.content}>
|
||||||
<div className={style.content}>
|
<EventEditorTimes
|
||||||
<EventEditorTimes
|
key={`${event.id}-times`}
|
||||||
key={`${event.id}-times`}
|
eventId={event.id}
|
||||||
eventId={event.id}
|
timeStart={event.timeStart}
|
||||||
timeStart={event.timeStart}
|
timeEnd={event.timeEnd}
|
||||||
timeEnd={event.timeEnd}
|
duration={event.duration}
|
||||||
duration={event.duration}
|
timeStrategy={event.timeStrategy}
|
||||||
timeStrategy={event.timeStrategy}
|
linkStart={event.linkStart}
|
||||||
linkStart={event.linkStart}
|
isTimeToEnd={event.isTimeToEnd}
|
||||||
delay={event.delay ?? 0}
|
delay={event.delay ?? 0}
|
||||||
isPublic={event.isPublic}
|
isPublic={event.isPublic}
|
||||||
endAction={event.endAction}
|
endAction={event.endAction}
|
||||||
timerType={event.timerType}
|
timerType={event.timerType}
|
||||||
timeWarning={event.timeWarning}
|
timeWarning={event.timeWarning}
|
||||||
timeDanger={event.timeDanger}
|
timeDanger={event.timeDanger}
|
||||||
/>
|
/>
|
||||||
<EventEditorTitles
|
<EventEditorTitles
|
||||||
key={`${event.id}-titles`}
|
key={`${event.id}-titles`}
|
||||||
eventId={event.id}
|
eventId={event.id}
|
||||||
cue={event.cue}
|
cue={event.cue}
|
||||||
title={event.title}
|
title={event.title}
|
||||||
note={event.note}
|
note={event.note}
|
||||||
colour={event.colour}
|
colour={event.colour}
|
||||||
handleSubmit={handleSubmit}
|
handleSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Editor.Title>Custom Fields</Editor.Title>
|
<Editor.Title>Custom Fields</Editor.Title>
|
||||||
|
{isEditor && (
|
||||||
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
|
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
|
||||||
Manage
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
{Object.keys(customFields).map((fieldKey) => {
|
|
||||||
const key = `${event.id}-${fieldKey}`;
|
|
||||||
const fieldName = `custom-${fieldKey}`;
|
|
||||||
const initialValue = event.custom[fieldKey] ?? '';
|
|
||||||
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
|
||||||
const labelText = customFields[fieldKey].label;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EventTextArea
|
|
||||||
key={key}
|
|
||||||
field={fieldName}
|
|
||||||
label={labelText}
|
|
||||||
initialValue={initialValue}
|
|
||||||
submitHandler={handleSubmit}
|
|
||||||
className={style.decorated}
|
|
||||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
{Object.keys(customFields).map((fieldKey) => {
|
||||||
|
const key = `${event.id}-${fieldKey}`;
|
||||||
|
const fieldName = `custom-${fieldKey}`;
|
||||||
|
const initialValue = event.custom[fieldKey] ?? '';
|
||||||
|
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
||||||
|
const labelText = customFields[fieldKey].label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventTextArea
|
||||||
|
key={key}
|
||||||
|
field={fieldName}
|
||||||
|
label={labelText}
|
||||||
|
initialValue={initialValue}
|
||||||
|
submitHandler={handleSubmit}
|
||||||
|
className={style.decorated}
|
||||||
|
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<EventEditorFooter id={event.id} cue={event.cue} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EventEditorFooterProps {
|
|
||||||
id: string;
|
|
||||||
cue: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EventEditorFooter = memo(_EventEditorFooter);
|
|
||||||
|
|
||||||
function _EventEditorFooter(props: EventEditorFooterProps) {
|
|
||||||
const { id, cue } = props;
|
|
||||||
|
|
||||||
const loadById = `/ontime/load/id "${id}"`;
|
|
||||||
const loadByCue = `/ontime/load/cue "${cue}"`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.footer}>
|
|
||||||
<CopyTag copyValue={loadById} label='OSC trigger by ID'>
|
|
||||||
{loadById}
|
|
||||||
</CopyTag>
|
|
||||||
<CopyTag copyValue={loadByCue} label='OSC trigger by cue'>
|
|
||||||
{loadByCue}
|
|
||||||
</CopyTag>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import useRundown from '../../../common/hooks-query/useRundown';
|
||||||
|
import { useEventSelection } from '../useEventSelection';
|
||||||
|
|
||||||
|
import { EventEditorFooter } from './composite/EventEditorFooter';
|
||||||
|
import EventEditor from './EventEditor';
|
||||||
|
import EventEditorEmpty from './EventEditorEmpty';
|
||||||
|
|
||||||
|
import style from './EventEditor.module.scss';
|
||||||
|
|
||||||
|
export default function RundownEventEditor() {
|
||||||
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
|
const { data } = useRundown();
|
||||||
|
const { order, rundown } = data;
|
||||||
|
|
||||||
|
const [event, setEvent] = useState<OntimeEvent | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (order.length === 0) {
|
||||||
|
setEvent(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedEventId = order.find((eventId) => selectedEvents.has(eventId));
|
||||||
|
if (!selectedEventId) {
|
||||||
|
setEvent(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const event = rundown[selectedEventId];
|
||||||
|
|
||||||
|
if (event && isOntimeEvent(event)) {
|
||||||
|
setEvent(event);
|
||||||
|
} else {
|
||||||
|
setEvent(null);
|
||||||
|
}
|
||||||
|
}, [order, rundown, selectedEvents]);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return <EventEditorEmpty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.eventEditor} data-testid='editor-container'>
|
||||||
|
<EventEditor event={event} />
|
||||||
|
<EventEditorFooter id={event.id} cue={event.cue} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.footer {
|
||||||
|
border-top: 1px solid $white-10;
|
||||||
|
padding-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||||
|
|
||||||
|
import style from './EventEditorFooter.module.scss';
|
||||||
|
|
||||||
|
interface EventEditorFooterProps {
|
||||||
|
id: string;
|
||||||
|
cue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EventEditorFooter = memo(_EventEditorFooter);
|
||||||
|
|
||||||
|
function _EventEditorFooter(props: EventEditorFooterProps) {
|
||||||
|
const { id, cue } = props;
|
||||||
|
|
||||||
|
const loadById = `/ontime/load/id "${id}"`;
|
||||||
|
const loadByCue = `/ontime/load/cue "${cue}"`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.footer}>
|
||||||
|
<CopyTag copyValue={loadById} label='OSC trigger by ID'>
|
||||||
|
{loadById}
|
||||||
|
</CopyTag>
|
||||||
|
<CopyTag copyValue={loadByCue} label='OSC trigger by cue'>
|
||||||
|
{loadByCue}
|
||||||
|
</CopyTag>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ interface EventEditorTimesProps {
|
|||||||
duration: number;
|
duration: number;
|
||||||
timeStrategy: TimeStrategy;
|
timeStrategy: TimeStrategy;
|
||||||
linkStart: MaybeString;
|
linkStart: MaybeString;
|
||||||
|
isTimeToEnd: boolean;
|
||||||
delay: number;
|
delay: number;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
endAction: EndAction;
|
endAction: EndAction;
|
||||||
@@ -26,9 +27,9 @@ interface EventEditorTimesProps {
|
|||||||
timeDanger: number;
|
timeDanger: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type HandledActions = 'timerType' | 'endAction' | 'isPublic' | 'timeWarning' | 'timeDanger';
|
type HandledActions = 'isTimeToEnd' | 'timerType' | 'endAction' | 'isPublic' | 'timeWarning' | 'timeDanger';
|
||||||
|
|
||||||
const EventEditorTimes = (props: EventEditorTimesProps) => {
|
function EventEditorTimes(props: EventEditorTimesProps) {
|
||||||
const {
|
const {
|
||||||
eventId,
|
eventId,
|
||||||
timeStart,
|
timeStart,
|
||||||
@@ -36,6 +37,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
|||||||
duration,
|
duration,
|
||||||
timeStrategy,
|
timeStrategy,
|
||||||
linkStart,
|
linkStart,
|
||||||
|
isTimeToEnd,
|
||||||
delay,
|
delay,
|
||||||
isPublic,
|
isPublic,
|
||||||
endAction,
|
endAction,
|
||||||
@@ -51,6 +53,11 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field === 'isTimeToEnd') {
|
||||||
|
updateEvent({ id: eventId, isTimeToEnd: !(value as boolean) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (field === 'timeWarning' || field === 'timeDanger') {
|
if (field === 'timeWarning' || field === 'timeDanger') {
|
||||||
const newTime = parseUserTime(value as string);
|
const newTime = parseUserTime(value as string);
|
||||||
updateEvent({ id: eventId, [field]: newTime });
|
updateEvent({ id: eventId, [field]: newTime });
|
||||||
@@ -71,95 +78,115 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.column}>
|
<>
|
||||||
<div>
|
<div className={style.column}>
|
||||||
<Editor.Label>Event schedule</Editor.Label>
|
<div>
|
||||||
<div className={style.inline}>
|
<Editor.Label>Event schedule</Editor.Label>
|
||||||
<TimeInputFlow
|
<div className={style.inline}>
|
||||||
eventId={eventId}
|
<TimeInputFlow
|
||||||
timeStart={timeStart}
|
eventId={eventId}
|
||||||
timeEnd={timeEnd}
|
timeStart={timeStart}
|
||||||
duration={duration}
|
timeEnd={timeEnd}
|
||||||
timeStrategy={timeStrategy}
|
duration={duration}
|
||||||
linkStart={linkStart}
|
timeStrategy={timeStrategy}
|
||||||
delay={delay}
|
linkStart={linkStart}
|
||||||
timerType={timerType}
|
delay={delay}
|
||||||
/>
|
isTimeToEnd={isTimeToEnd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={style.delayLabel}>{delayLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.delayLabel}>{delayLabel}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.splitTwo}>
|
<Editor.Title>Event behaviour</Editor.Title>
|
||||||
<div>
|
<div className={style.splitTwo}>
|
||||||
<Editor.Label htmlFor='timeWarning'>Warning Time</Editor.Label>
|
<div>
|
||||||
<TimeInput
|
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
|
||||||
id='timeWarning'
|
<Select
|
||||||
name='timeWarning'
|
id='endAction'
|
||||||
submitHandler={handleSubmit}
|
size='sm'
|
||||||
time={timeWarning}
|
name='endAction'
|
||||||
placeholder='Duration'
|
value={endAction}
|
||||||
/>
|
onChange={(event) => handleSubmit('endAction', event.target.value)}
|
||||||
</div>
|
variant='ontime'
|
||||||
<div>
|
>
|
||||||
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
|
<option value={EndAction.None}>None</option>
|
||||||
<Select
|
<option value={EndAction.Stop}>Stop rundown</option>
|
||||||
size='sm'
|
<option value={EndAction.LoadNext}>Load next event</option>
|
||||||
id='timerType'
|
<option value={EndAction.PlayNext}>Play next event</option>
|
||||||
name='timerType'
|
</Select>
|
||||||
value={timerType}
|
</div>
|
||||||
onChange={(event) => handleSubmit('timerType', event.target.value)}
|
<div>
|
||||||
variant='ontime'
|
<Editor.Label htmlFor='timeToEnd'>Target Event Scheduled End</Editor.Label>
|
||||||
>
|
<Editor.Label className={style.switchLabel}>
|
||||||
<option value={TimerType.CountDown}>Count down</option>
|
<Switch
|
||||||
<option value={TimerType.CountUp}>Count up</option>
|
id='timeToEnd'
|
||||||
<option value={TimerType.TimeToEnd}>Time to end</option>
|
size='md'
|
||||||
<option value={TimerType.Clock}>Clock</option>
|
isChecked={isTimeToEnd}
|
||||||
<option value={TimerType.None}>None</option>
|
onChange={() => handleSubmit('isTimeToEnd', isTimeToEnd)}
|
||||||
</Select>
|
variant='ontime'
|
||||||
</div>
|
/>
|
||||||
<div>
|
{isTimeToEnd ? 'On' : 'Off'}
|
||||||
<Editor.Label htmlFor='timeDanger'>Danger Time</Editor.Label>
|
</Editor.Label>
|
||||||
<TimeInput
|
</div>
|
||||||
id='timeDanger'
|
|
||||||
name='timeDanger'
|
|
||||||
submitHandler={handleSubmit}
|
|
||||||
time={timeDanger}
|
|
||||||
placeholder='Duration'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
|
|
||||||
<Select
|
|
||||||
id='endAction'
|
|
||||||
size='sm'
|
|
||||||
name='endAction'
|
|
||||||
value={endAction}
|
|
||||||
onChange={(event) => handleSubmit('endAction', event.target.value)}
|
|
||||||
variant='ontime'
|
|
||||||
>
|
|
||||||
<option value={EndAction.None}>None</option>
|
|
||||||
<option value={EndAction.Stop}>Stop</option>
|
|
||||||
<option value={EndAction.LoadNext}>Load Next</option>
|
|
||||||
<option value={EndAction.PlayNext}>Play Next</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={style.column}>
|
||||||
|
<Editor.Title>Display options</Editor.Title>
|
||||||
|
<div className={style.splitTwo}>
|
||||||
|
<div>
|
||||||
|
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
|
||||||
|
<Select
|
||||||
|
size='sm'
|
||||||
|
id='timerType'
|
||||||
|
name='timerType'
|
||||||
|
value={timerType}
|
||||||
|
onChange={(event) => handleSubmit('timerType', event.target.value)}
|
||||||
|
variant='ontime'
|
||||||
|
>
|
||||||
|
<option value={TimerType.CountDown}>Count down</option>
|
||||||
|
<option value={TimerType.CountUp}>Count up</option>
|
||||||
|
<option value={TimerType.Clock}>Clock</option>
|
||||||
|
<option value={TimerType.None}>None</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Editor.Label htmlFor='timeWarning'>Warning Time</Editor.Label>
|
||||||
|
<TimeInput
|
||||||
|
id='timeWarning'
|
||||||
|
name='timeWarning'
|
||||||
|
submitHandler={handleSubmit}
|
||||||
|
time={timeWarning}
|
||||||
|
placeholder='Duration'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Editor.Label htmlFor='isPublic'>Event Visibility</Editor.Label>
|
<Editor.Label htmlFor='isPublic'>Event Visibility</Editor.Label>
|
||||||
<Editor.Label className={style.switchLabel}>
|
<Editor.Label className={style.switchLabel}>
|
||||||
<Switch
|
<Switch
|
||||||
id='isPublic'
|
id='isPublic'
|
||||||
size='md'
|
size='md'
|
||||||
isChecked={isPublic}
|
isChecked={isPublic}
|
||||||
onChange={() => handleSubmit('isPublic', isPublic)}
|
onChange={() => handleSubmit('isPublic', isPublic)}
|
||||||
variant='ontime'
|
variant='ontime'
|
||||||
/>
|
/>
|
||||||
{isPublic ? 'Public' : 'Private'}
|
{isPublic ? 'Public' : 'Private'}
|
||||||
</Editor.Label>
|
</Editor.Label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Editor.Label htmlFor='timeDanger'>Danger Time</Editor.Label>
|
||||||
|
<TimeInput
|
||||||
|
id='timeDanger'
|
||||||
|
name='timeDanger'
|
||||||
|
submitHandler={handleSubmit}
|
||||||
|
time={timeDanger}
|
||||||
|
placeholder='Duration'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default memo(EventEditorTimes);
|
export default memo(EventEditorTimes);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
|
<Editor.Title>Event data</Editor.Title>
|
||||||
<div className={style.splitTwo}>
|
<div className={style.splitTwo}>
|
||||||
<div>
|
<div>
|
||||||
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
|
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { IoLink } from '@react-icons/all-files/io5/IoLink';
|
|||||||
import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed';
|
import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed';
|
||||||
import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
|
import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
|
||||||
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
|
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
|
||||||
import { MaybeString, TimerType, TimeStrategy } from 'ontime-types';
|
import { MaybeString, TimeStrategy } from 'ontime-types';
|
||||||
|
|
||||||
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
@@ -16,19 +16,19 @@ import style from './TimeInputFlow.module.scss';
|
|||||||
|
|
||||||
interface EventBlockTimerProps {
|
interface EventBlockTimerProps {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
isTimeToEnd: boolean;
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
timeEnd: number;
|
timeEnd: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
timeStrategy: TimeStrategy;
|
timeStrategy: TimeStrategy;
|
||||||
linkStart: MaybeString;
|
linkStart: MaybeString;
|
||||||
delay: number;
|
delay: number;
|
||||||
timerType: TimerType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimeActions = 'timeStart' | 'timeEnd' | 'duration';
|
type TimeActions = 'timeStart' | 'timeEnd' | 'duration';
|
||||||
|
|
||||||
const TimeInputFlow = (props: EventBlockTimerProps) => {
|
function TimeInputFlow(props: EventBlockTimerProps) {
|
||||||
const { eventId, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, timerType } = props;
|
const { eventId, isTimeToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay } = props;
|
||||||
const { updateEvent, updateTimer } = useEventAction();
|
const { updateEvent, updateTimer } = useEventAction();
|
||||||
|
|
||||||
// In sync with EventEditorTimes
|
// In sync with EventEditorTimes
|
||||||
@@ -49,8 +49,8 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
|||||||
warnings.push('Over midnight');
|
warnings.push('Over midnight');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timerType === TimerType.TimeToEnd) {
|
if (isTimeToEnd) {
|
||||||
warnings.push('Time to end');
|
warnings.push('Target event scheduled end');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasDelay = delay !== 0;
|
const hasDelay = delay !== 0;
|
||||||
@@ -128,6 +128,6 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default memo(TimeInputFlow);
|
export default memo(TimeInputFlow);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
SimpleTimerState,
|
SimpleTimerState,
|
||||||
SupportedEvent,
|
SupportedEvent,
|
||||||
|
TimerType,
|
||||||
ViewSettings,
|
ViewSettings,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { useStore } from 'zustand';
|
import { useStore } from 'zustand';
|
||||||
@@ -74,16 +75,14 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
const selectedId = eventNow?.id ?? null;
|
const selectedId = eventNow?.id ?? null;
|
||||||
const nextId = eventNext?.id ?? null;
|
const nextId = eventNext?.id ?? null;
|
||||||
|
|
||||||
/******************************************/
|
/**
|
||||||
/*** + TimeManagerType ***/
|
* Contains an extended timer object with properties from the current event
|
||||||
/*** WRAP INFORMATION RELATED TO TIME ***/
|
*/
|
||||||
/*** -------------------------------- ***/
|
const timeManagerType: ViewExtendedTimer = {
|
||||||
/******************************************/
|
|
||||||
|
|
||||||
const TimeManagerType = {
|
|
||||||
...timer,
|
...timer,
|
||||||
clock,
|
clock,
|
||||||
timerType: eventNow?.timerType ?? null,
|
timerType: eventNow?.timerType ?? TimerType.CountDown,
|
||||||
|
isTimeToEnd: eventNow?.isTimeToEnd ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,7 +107,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
runtime={runtime}
|
runtime={runtime}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
time={TimeManagerType}
|
time={timeManagerType}
|
||||||
viewSettings={viewSettings}
|
viewSettings={viewSettings}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -5,16 +5,22 @@ import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type'
|
|||||||
import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
|
import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
|
||||||
import { formatTime } from '../../../common/utils/time';
|
import { formatTime } from '../../../common/utils/time';
|
||||||
|
|
||||||
type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>;
|
type TimerTypeParams = Pick<ViewExtendedTimer, 'isTimeToEnd' | 'timerType' | 'current' | 'elapsed' | 'clock'>;
|
||||||
|
|
||||||
export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null {
|
export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null {
|
||||||
if (!timerObject) {
|
if (!timerObject) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (timerObject.isTimeToEnd) {
|
||||||
|
if (timerObject.current === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
|
||||||
|
}
|
||||||
|
|
||||||
switch (timerObject.timerType) {
|
switch (timerObject.timerType) {
|
||||||
case TimerType.CountDown:
|
case TimerType.CountDown:
|
||||||
case TimerType.TimeToEnd:
|
|
||||||
if (timerObject.current === null) {
|
if (timerObject.current === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -32,6 +38,10 @@ export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a string to semantically verify if it represents a true value
|
||||||
|
* Used in the context of parsing search params and local storage items which can be strings or null
|
||||||
|
*/
|
||||||
export function isStringBoolean(text: string | null) {
|
export function isStringBoolean(text: string | null) {
|
||||||
if (text === null) {
|
if (text === null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
|||||||
|
|
||||||
const isPlaying = time.playback !== Playback.Pause;
|
const isPlaying = time.playback !== Playback.Pause;
|
||||||
|
|
||||||
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.timerType === TimerType.TimeToEnd;
|
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.isTimeToEnd;
|
||||||
const finished = time.phase === TimerPhase.Overtime;
|
const finished = time.phase === TimerPhase.Overtime;
|
||||||
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage && !hideEndMessage;
|
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage && !hideEndMessage;
|
||||||
const showFinished =
|
const showFinished =
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function Timer(props: TimerProps) {
|
|||||||
const finished = time.phase === TimerPhase.Overtime;
|
const finished = time.phase === TimerPhase.Overtime;
|
||||||
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
||||||
|
|
||||||
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.timerType === TimerType.TimeToEnd;
|
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.isTimeToEnd;
|
||||||
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage;
|
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage;
|
||||||
const showProgress =
|
const showProgress =
|
||||||
eventNow !== null &&
|
eventNow !== null &&
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ $action-text-color: $blue-400;
|
|||||||
$ontime-color: #ff7597;
|
$ontime-color: #ff7597;
|
||||||
$error-red: $red-500;
|
$error-red: $red-500;
|
||||||
$warning-orange: $orange-500;
|
$warning-orange: $orange-500;
|
||||||
|
$info-blue: $blue-500;
|
||||||
$opacity-disabled: 0.4;
|
$opacity-disabled: 0.4;
|
||||||
$active-red: $red-700;
|
$active-red: $red-700;
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ $main-spacing: 2rem;
|
|||||||
$ontime-font-family: "Open Sans", "Segoe UI", sans-serif;
|
$ontime-font-family: "Open Sans", "Segoe UI", sans-serif;
|
||||||
$label-gray: $gray-400;
|
$label-gray: $gray-400;
|
||||||
$secondary-text-gray: $gray-400;
|
$secondary-text-gray: $gray-400;
|
||||||
|
$muted-gray: $gray-600;
|
||||||
$section-white: $ui-white;
|
$section-white: $ui-white;
|
||||||
$inner-section-text-size: calc(1rem - 2px);
|
$inner-section-text-size: calc(1rem - 2px);
|
||||||
$text-body-size: calc(1rem - 1px);
|
$text-body-size: calc(1rem - 1px);
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ export const ontimeInputGhosted = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ontimeInputTransparent = {
|
||||||
|
field: {
|
||||||
|
...commonStyles,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
_hover: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.10)', // $white-10
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const ontimeTextAreaFilled = {
|
export const ontimeTextAreaFilled = {
|
||||||
...commonStyles,
|
...commonStyles,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { ontimeTab } from './ontimeTab';
|
|||||||
import {
|
import {
|
||||||
ontimeInputFilled,
|
ontimeInputFilled,
|
||||||
ontimeInputGhosted,
|
ontimeInputGhosted,
|
||||||
|
ontimeInputTransparent,
|
||||||
ontimeTextAreaFilled,
|
ontimeTextAreaFilled,
|
||||||
ontimeTextAreaTransparent,
|
ontimeTextAreaTransparent,
|
||||||
} from './ontimeTextInputs';
|
} from './ontimeTextInputs';
|
||||||
@@ -78,6 +79,7 @@ const theme = extendTheme({
|
|||||||
variants: {
|
variants: {
|
||||||
'ontime-filled': { ...ontimeInputFilled },
|
'ontime-filled': { ...ontimeInputFilled },
|
||||||
'ontime-ghosted': { ...ontimeInputGhosted },
|
'ontime-ghosted': { ...ontimeInputGhosted },
|
||||||
|
'ontime-transparent': { ...ontimeInputTransparent },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Kbd: {
|
Kbd: {
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
|
||||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
|
||||||
import Color from 'color';
|
|
||||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
|
||||||
import { getAccessibleColour } from '../../common/utils/styleUtils';
|
|
||||||
|
|
||||||
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 CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
|
||||||
import { useCuesheetSettings } from './store/cuesheetSettingsStore';
|
|
||||||
import useColumnManager from './useColumnManager';
|
|
||||||
|
|
||||||
import style from './Cuesheet.module.scss';
|
|
||||||
|
|
||||||
interface CuesheetProps {
|
|
||||||
data: OntimeRundown;
|
|
||||||
columns: ColumnDef<OntimeRundownEntry>[];
|
|
||||||
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
|
|
||||||
selectedId: string | null;
|
|
||||||
currentBlockId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId, currentBlockId }: CuesheetProps) {
|
|
||||||
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
|
|
||||||
const {
|
|
||||||
columnVisibility,
|
|
||||||
columnOrder,
|
|
||||||
columnSizing,
|
|
||||||
resetColumnOrder,
|
|
||||||
setColumnVisibility,
|
|
||||||
saveColumnOrder,
|
|
||||||
setColumnSizing,
|
|
||||||
} = useColumnManager(columns);
|
|
||||||
|
|
||||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
|
||||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
columnResizeMode: 'onChange',
|
|
||||||
state: {
|
|
||||||
columnOrder,
|
|
||||||
columnVisibility,
|
|
||||||
columnSizing,
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
handleUpdate,
|
|
||||||
},
|
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
|
||||||
onColumnSizingChange: setColumnSizing,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const setAllVisible = () => {
|
|
||||||
table.toggleAllColumnsVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetColumnResizing = () => {
|
|
||||||
setColumnSizing({});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reorder = useCallback(
|
|
||||||
(fromId: string, toId: string) => {
|
|
||||||
// get index of from
|
|
||||||
const fromIndex = columnOrder.indexOf(fromId);
|
|
||||||
|
|
||||||
// get index of to
|
|
||||||
const toIndex = columnOrder.indexOf(toId);
|
|
||||||
|
|
||||||
if (toIndex === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reorderedCols = [...columnOrder];
|
|
||||||
const reorderedItem = reorderedCols.splice(fromIndex, 1);
|
|
||||||
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
|
|
||||||
saveColumnOrder(reorderedCols);
|
|
||||||
},
|
|
||||||
[columnOrder, saveColumnOrder],
|
|
||||||
);
|
|
||||||
|
|
||||||
const headerGroups = table.getHeaderGroups();
|
|
||||||
const rowModel = table.getRowModel();
|
|
||||||
const allLeafColumns = table.getAllLeafColumns();
|
|
||||||
|
|
||||||
let eventIndex = 0;
|
|
||||||
let isPast = Boolean(selectedId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showSettings && (
|
|
||||||
<CuesheetTableSettings
|
|
||||||
columns={allLeafColumns}
|
|
||||||
handleResetResizing={resetColumnResizing}
|
|
||||||
handleResetReordering={resetColumnOrder}
|
|
||||||
handleClearToggles={setAllVisible}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
|
||||||
<table className={style.cuesheet}>
|
|
||||||
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} showIndexColumn={showIndexColumn} />
|
|
||||||
<tbody>
|
|
||||||
{rowModel.rows.map((row) => {
|
|
||||||
const key = row.original.id;
|
|
||||||
const isSelected = selectedId === key;
|
|
||||||
if (isSelected) {
|
|
||||||
isPast = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOntimeBlock(row.original)) {
|
|
||||||
if (isPast && !showPrevious && key !== currentBlockId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return <BlockRow key={key} title={row.original.title} />;
|
|
||||||
}
|
|
||||||
if (isOntimeDelay(row.original)) {
|
|
||||||
if (isPast && !showPrevious) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const delayVal = row.original.duration;
|
|
||||||
if (!showDelayBlock || delayVal === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DelayRow key={key} duration={delayVal} />;
|
|
||||||
}
|
|
||||||
if (isOntimeEvent(row.original)) {
|
|
||||||
eventIndex++;
|
|
||||||
const isSelected = key === selectedId;
|
|
||||||
|
|
||||||
if (isPast && !showPrevious) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rowBgColour: string | undefined;
|
|
||||||
if (isSelected) {
|
|
||||||
rowBgColour = '#D20300'; // $red-700
|
|
||||||
} else if (row.original.colour) {
|
|
||||||
try {
|
|
||||||
// the colour is user defined and might be invalid
|
|
||||||
const accessibleBackgroundColor = Color(getAccessibleColour(row.original.colour).backgroundColor);
|
|
||||||
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
|
|
||||||
} catch (_error) {
|
|
||||||
/* we do not handle errors here */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EventRow
|
|
||||||
key={key}
|
|
||||||
eventIndex={eventIndex}
|
|
||||||
isPast={isPast}
|
|
||||||
selectedRef={isSelected ? selectedRef : undefined}
|
|
||||||
skip={row.original.skip}
|
|
||||||
colour={row.original.colour}
|
|
||||||
showIndexColumn={showIndexColumn}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// currently there is no scenario where entryType is not handled above, either way...
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,10 @@
|
|||||||
padding: 1rem 0.5rem;
|
padding: 1rem 0.5rem;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 3rem auto 1fr;
|
grid-template-rows: 3rem auto auto 1fr;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'overview'
|
'overview'
|
||||||
|
'progress'
|
||||||
'settings'
|
'settings'
|
||||||
'table';
|
'table';
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { IconButton, useDisclosure } from '@chakra-ui/react';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { IconButton, Modal, ModalContent, ModalOverlay, useDisclosure } from '@chakra-ui/react';
|
||||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||||
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
|
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
|
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||||
import { useCuesheet } from '../../common/hooks/useSocket';
|
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||||
import { CuesheetOverview } from '../../features/overview/Overview';
|
import { CuesheetOverview } from '../../features/overview/Overview';
|
||||||
|
import CuesheetEventEditor from '../../features/rundown/event-editor/CuesheetEventEditor';
|
||||||
|
|
||||||
|
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||||
import { useCuesheetSettings } from './store/cuesheetSettingsStore';
|
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetCols';
|
||||||
import Cuesheet from './Cuesheet';
|
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||||
import { makeCuesheetColumns } from './cuesheetCols';
|
import { cuesheetOptions } from './cuesheet.options';
|
||||||
|
|
||||||
import styles from './CuesheetPage.module.scss';
|
import styles from './CuesheetPage.module.scss';
|
||||||
|
|
||||||
@@ -24,21 +27,27 @@ export default function CuesheetPage() {
|
|||||||
// TODO: can we use the normalised rundown for the table?
|
// TODO: can we use the normalised rundown for the table?
|
||||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
||||||
const { data: customFields } = useCustomFields();
|
const { data: customFields } = useCustomFields();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const { isOpen: isEventEditorOpen, onOpen: onEventEditorOpen, onClose: onEventEditorClose } = useDisclosure();
|
||||||
|
const [eventId, setEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
const { updateCustomField } = useEventAction();
|
const { updateCustomField, updateEvent } = useEventAction();
|
||||||
const featureData = useCuesheet();
|
|
||||||
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
|
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
|
||||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
|
||||||
|
|
||||||
useWindowTitle('Cuesheet');
|
useWindowTitle('Cuesheet');
|
||||||
|
|
||||||
|
/** Handles showing the view params edit drawer */
|
||||||
|
const showEditFormDrawer = useCallback(() => {
|
||||||
|
searchParams.set('edit', 'true');
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles updating a field
|
* Handles updating a custom field
|
||||||
* Currently, only custom fields can be updated from the cuesheet
|
|
||||||
*/
|
*/
|
||||||
const handleUpdate = useCallback(
|
const handleUpdateCustom = useCallback(
|
||||||
async (rowIndex: number, accessor: CustomFieldLabel, payload: unknown) => {
|
async (rowIndex: number, accessor: CustomFieldLabel, payload: string) => {
|
||||||
if (!flatRundown || rundownStatus !== 'success') {
|
if (!flatRundown || rundownStatus !== 'success') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -53,29 +62,60 @@ export default function CuesheetPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skip if there is no value change
|
||||||
const previousValue = event.custom[accessor];
|
const previousValue = event.custom[accessor];
|
||||||
|
if (previousValue === payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateCustomField(event.id, accessor, payload);
|
||||||
|
},
|
||||||
|
[flatRundown, rundownStatus, updateCustomField],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles updating all other string fields
|
||||||
|
*/
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
async (rowIndex: number, accessor: keyof OntimeEvent, payload: string) => {
|
||||||
|
if (!flatRundown || rundownStatus !== 'success') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowIndex == null || accessor == null || payload == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if value is the same
|
||||||
|
const event = flatRundown[rowIndex];
|
||||||
|
if (!event || !isOntimeEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip if there is no value change
|
||||||
|
const previousValue = event[accessor];
|
||||||
if (previousValue === payload) {
|
if (previousValue === payload) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if value is valid
|
updateEvent({ id: event.id, [accessor]: payload });
|
||||||
// in anticipation to different types of event here
|
},
|
||||||
if (typeof payload !== 'string') {
|
[flatRundown, rundownStatus, updateEvent],
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// cleanup
|
/**
|
||||||
const cleanVal = payload.trim();
|
* Handles setting the edit modal target and visibility
|
||||||
|
*/
|
||||||
// submit
|
const setShowModal = useCallback(
|
||||||
try {
|
(eventId: string | null) => {
|
||||||
await updateCustomField(event.id, accessor, cleanVal);
|
if (eventId) {
|
||||||
} catch (error) {
|
setEventId(eventId);
|
||||||
console.error(error);
|
onEventEditorOpen();
|
||||||
|
} else {
|
||||||
|
setEventId(null);
|
||||||
|
onEventEditorClose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[flatRundown, rundownStatus, updateCustomField],
|
[onEventEditorClose, onEventEditorOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!customFields || !flatRundown || rundownStatus !== 'success') {
|
if (!customFields || !flatRundown || rundownStatus !== 'success') {
|
||||||
@@ -83,32 +123,43 @@ export default function CuesheetPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
<>
|
||||||
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
<Modal isOpen={isEventEditorOpen} onClose={onEventEditorClose} variant='ontime'>
|
||||||
<CuesheetOverview>
|
<ModalOverlay />
|
||||||
<IconButton
|
<ModalContent maxWidth='max(640px, 40vw)' padding='1rem'>
|
||||||
aria-label='Toggle navigation'
|
<CuesheetEventEditor eventId={eventId!} />
|
||||||
variant='ontime-subtle-white'
|
</ModalContent>
|
||||||
size='lg'
|
</Modal>
|
||||||
icon={<IoApps />}
|
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||||
onClick={onOpen}
|
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
|
||||||
/>
|
<ViewParamsEditor viewOptions={cuesheetOptions} />
|
||||||
<IconButton
|
<CuesheetOverview>
|
||||||
aria-label='Toggle settings'
|
<IconButton
|
||||||
variant='ontime-subtle-white'
|
aria-label='Toggle navigation'
|
||||||
size='lg'
|
variant='ontime-subtle-white'
|
||||||
icon={<IoSettingsOutline />}
|
size='lg'
|
||||||
onClick={() => toggleSettings()}
|
icon={<IoApps />}
|
||||||
/>
|
onClick={onOpen}
|
||||||
</CuesheetOverview>
|
/>
|
||||||
<CuesheetProgress />
|
<IconButton
|
||||||
<Cuesheet
|
aria-label='Toggle settings'
|
||||||
data={flatRundown}
|
variant='ontime-subtle-white'
|
||||||
columns={columns}
|
size='lg'
|
||||||
handleUpdate={handleUpdate}
|
icon={<IoSettingsOutline />}
|
||||||
selectedId={featureData.selectedEventId}
|
onClick={showEditFormDrawer}
|
||||||
currentBlockId={featureData.currentBlockId}
|
/>
|
||||||
/>
|
</CuesheetOverview>
|
||||||
</div>
|
<CuesheetProgress />
|
||||||
|
<CuesheetDnd columns={columns}>
|
||||||
|
<CuesheetTable
|
||||||
|
data={flatRundown}
|
||||||
|
columns={columns}
|
||||||
|
handleUpdate={handleUpdate}
|
||||||
|
handleUpdateCustom={handleUpdateCustom}
|
||||||
|
showModal={setShowModal}
|
||||||
|
/>
|
||||||
|
</CuesheetDnd>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
import {
|
||||||
|
closestCorners,
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
import useColumnManager from '../cuesheet-table/useColumnManager';
|
||||||
|
|
||||||
|
interface CuesheetDndProps {
|
||||||
|
columns: ColumnDef<OntimeRundownEntry>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CuesheetDnd(props: PropsWithChildren<CuesheetDndProps>) {
|
||||||
|
const { columns, children } = props;
|
||||||
|
|
||||||
|
const { columnOrder, saveColumnOrder } = useColumnManager(columns);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 100,
|
||||||
|
tolerance: 50,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 100,
|
||||||
|
tolerance: 50,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { delta, active, over } = event;
|
||||||
|
|
||||||
|
// cancel if delta y is greater than 200
|
||||||
|
if (delta.y > 200) return;
|
||||||
|
// cancel if we do not have an over id
|
||||||
|
if (over?.id == null) return;
|
||||||
|
|
||||||
|
// get index of from
|
||||||
|
const fromIndex = columnOrder.indexOf(active.id as string);
|
||||||
|
|
||||||
|
// get index of to
|
||||||
|
const toIndex = columnOrder.indexOf(over.id as string);
|
||||||
|
|
||||||
|
if (toIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderedCols = [...columnOrder];
|
||||||
|
const reorderedItem = reorderedCols.splice(fromIndex, 1);
|
||||||
|
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
|
||||||
|
saveColumnOrder(reorderedCols);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleOnDragEnd}>
|
||||||
|
{children}
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
.progressOverride {
|
.progressOverride {
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
|
grid-area: progress;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
import style from '../Cuesheet.module.scss';
|
|
||||||
|
|
||||||
interface BlockRowProps {
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function BlockRow(props: BlockRowProps) {
|
|
||||||
const { title } = props;
|
|
||||||
return (
|
|
||||||
<tr className={style.blockRow}>
|
|
||||||
<td>{title}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(BlockRow);
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import {
|
|
||||||
closestCorners,
|
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
PointerSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
|
|
||||||
import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
|
||||||
import { OntimeRundownEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
|
||||||
|
|
||||||
import { SortableCell } from './SortableCell';
|
|
||||||
|
|
||||||
import style from '../Cuesheet.module.scss';
|
|
||||||
|
|
||||||
interface CuesheetHeaderProps {
|
|
||||||
headerGroups: HeaderGroup<OntimeRundownEntry>[];
|
|
||||||
saveColumnOrder: (fromId: string, toId: string) => void;
|
|
||||||
showIndexColumn: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CuesheetHeader(props: CuesheetHeaderProps) {
|
|
||||||
const { headerGroups, saveColumnOrder, showIndexColumn } = props;
|
|
||||||
|
|
||||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { delta, active, over } = event;
|
|
||||||
|
|
||||||
// cancel if delta y is greater than 200
|
|
||||||
if (delta.y > 200) return;
|
|
||||||
// cancel if we do not have an over id
|
|
||||||
if (over?.id == null) return;
|
|
||||||
|
|
||||||
saveColumnOrder(active.id as string, over.id as string);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 100,
|
|
||||||
tolerance: 50,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 100,
|
|
||||||
tolerance: 50,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<thead className={style.tableHeader}>
|
|
||||||
{headerGroups.map((headerGroup) => {
|
|
||||||
const key = headerGroup.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext key={key} sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleOnDragEnd}>
|
|
||||||
<tr key={headerGroup.id}>
|
|
||||||
<th className={style.indexColumn}>{showIndexColumn && '#'}</th>
|
|
||||||
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
const width = header.getSize();
|
|
||||||
// @ts-expect-error -- we inject this into react-table
|
|
||||||
const customBackground = header.column.columnDef?.meta?.colour;
|
|
||||||
|
|
||||||
let customStyles = {};
|
|
||||||
if (customBackground) {
|
|
||||||
const customColour = getAccessibleColour(customBackground);
|
|
||||||
customStyles = { backgroundColor: customColour.backgroundColor, color: customColour.color };
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
|
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</SortableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SortableContext>
|
|
||||||
</tr>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</thead>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { ChangeEvent, memo, useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { getHotkeyHandler } from '@mantine/hooks';
|
|
||||||
|
|
||||||
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
|
|
||||||
|
|
||||||
interface EditableCellProps {
|
|
||||||
value: string;
|
|
||||||
handleUpdate: (newValue: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EditableCell = (props: EditableCellProps) => {
|
|
||||||
const { value: initialValue, handleUpdate } = props;
|
|
||||||
|
|
||||||
// We need to keep and update the state of the cell normally
|
|
||||||
const [value, setValue] = useState(initialValue);
|
|
||||||
const ref = useRef<HTMLAreaElement>();
|
|
||||||
const onChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => setValue(event.target.value), []);
|
|
||||||
|
|
||||||
// We'll only update the external data when the input is blurred
|
|
||||||
const onBlur = useCallback(() => handleUpdate(value), [handleUpdate, value]);
|
|
||||||
|
|
||||||
//TODO: maybe we can unify this with `useReactiveTextInput`
|
|
||||||
const onKeyDown = getHotkeyHandler([
|
|
||||||
['mod + Enter', () => ref.current?.blur()],
|
|
||||||
[
|
|
||||||
'Escape',
|
|
||||||
() => {
|
|
||||||
setValue(initialValue);
|
|
||||||
setTimeout(() => ref.current?.blur());
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If the initialValue is changed external, sync it up with our state
|
|
||||||
useEffect(() => {
|
|
||||||
setValue(initialValue);
|
|
||||||
}, [initialValue]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AutoTextArea
|
|
||||||
size='sm'
|
|
||||||
value={value}
|
|
||||||
inputref={ref}
|
|
||||||
onChange={onChange}
|
|
||||||
onBlur={onBlur}
|
|
||||||
rows={1}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
transition='none'
|
|
||||||
spellCheck={false}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(EditableCell);
|
|
||||||
+15
-5
@@ -1,5 +1,5 @@
|
|||||||
$table-font-size: calc(1rem - 2px);
|
$table-font-size: 1rem;
|
||||||
$table-header-font-size: calc(1rem - 3px);
|
$table-header-font-size: calc(1rem - 2px);
|
||||||
|
|
||||||
.cuesheetContainer {
|
.cuesheetContainer {
|
||||||
grid-area: table;
|
grid-area: table;
|
||||||
@@ -33,13 +33,22 @@ $table-header-font-size: calc(1rem - 3px);
|
|||||||
.tableHeader,
|
.tableHeader,
|
||||||
.eventRow {
|
.eventRow {
|
||||||
.indexColumn {
|
.indexColumn {
|
||||||
min-width: 2rem;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: end;
|
||||||
|
|
||||||
|
min-width: 3em; // allow for 3-digit numbers
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
font-size: $table-header-font-size;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background-color: $gray-1300;
|
background-color: $gray-1300; // will be overridden inline
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionColumn {
|
||||||
|
width: calc(1.5rem + 0.5rem); // sm button size (--chakra-sizes-6) + 2 * padding
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +58,8 @@ $table-header-font-size: calc(1rem - 3px);
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
background-color: $ui-black;
|
background-color: $ui-black;
|
||||||
font-size: $table-header-font-size;
|
font-size: $table-header-font-size;
|
||||||
color: $label-gray;}
|
color: $label-gray;
|
||||||
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background-color: $gray-1300;
|
background-color: $gray-1300;
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { IconButton, Menu, MenuButton } from '@chakra-ui/react';
|
||||||
|
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
||||||
|
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
|
import Color from 'color';
|
||||||
|
import {
|
||||||
|
CustomFieldLabel,
|
||||||
|
isOntimeBlock,
|
||||||
|
isOntimeDelay,
|
||||||
|
isOntimeEvent,
|
||||||
|
MaybeString,
|
||||||
|
OntimeRundown,
|
||||||
|
OntimeRundownEntry,
|
||||||
|
} from 'ontime-types';
|
||||||
|
|
||||||
|
import useFollowComponent from '../../../common/hooks/useFollowComponent';
|
||||||
|
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||||
|
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
|
import { useCuesheetOptions } from '../cuesheet.options';
|
||||||
|
|
||||||
|
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 CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||||
|
import CuesheetTableMenu from './CuesheetTableMenu';
|
||||||
|
import useColumnManager from './useColumnManager';
|
||||||
|
|
||||||
|
import style from './CuesheetTable.module.scss';
|
||||||
|
|
||||||
|
interface CuesheetTableProps {
|
||||||
|
data: OntimeRundown;
|
||||||
|
columns: ColumnDef<OntimeRundownEntry>[];
|
||||||
|
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: string) => void;
|
||||||
|
handleUpdateCustom: (rowIndex: number, accessor: CustomFieldLabel, payload: string) => void;
|
||||||
|
showModal: (eventId: MaybeString) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CuesheetTable(props: CuesheetTableProps) {
|
||||||
|
const { data, columns, handleUpdate, handleUpdateCustom, showModal } = props;
|
||||||
|
|
||||||
|
const { selectedEventId } = useSelectedEventId();
|
||||||
|
const { followSelected, hideDelays, hidePast, hideIndexColumn } = useCuesheetOptions();
|
||||||
|
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
|
||||||
|
useColumnManager(columns);
|
||||||
|
|
||||||
|
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: {
|
||||||
|
columnOrder,
|
||||||
|
columnVisibility,
|
||||||
|
columnSizing,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
handleUpdate,
|
||||||
|
handleUpdateCustom,
|
||||||
|
},
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onColumnSizingChange: setColumnSizing,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setAllVisible = () => {
|
||||||
|
table.toggleAllColumnsVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetColumnResizing = () => {
|
||||||
|
setColumnSizing({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerGroups = table.getHeaderGroups();
|
||||||
|
const rowModel = table.getRowModel();
|
||||||
|
const allLeafColumns = table.getAllLeafColumns();
|
||||||
|
|
||||||
|
let eventIndex = 0;
|
||||||
|
// for the first event, it will be past if there is something selected
|
||||||
|
let isPast = Boolean(selectedEventId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CuesheetTableSettings
|
||||||
|
columns={allLeafColumns}
|
||||||
|
handleResetResizing={resetColumnResizing}
|
||||||
|
handleResetReordering={resetColumnOrder}
|
||||||
|
handleClearToggles={setAllVisible}
|
||||||
|
/>
|
||||||
|
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||||
|
<table className={style.cuesheet} id='cuesheet'>
|
||||||
|
<CuesheetHeader headerGroups={headerGroups} showIndexColumn={!hideIndexColumn} />
|
||||||
|
<tbody>
|
||||||
|
{rowModel.rows.map((row, index) => {
|
||||||
|
const key = row.original.id;
|
||||||
|
const isSelected = selectedEventId === key;
|
||||||
|
const entry = row.original;
|
||||||
|
if (isSelected) {
|
||||||
|
isPast = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOntimeBlock(entry)) {
|
||||||
|
return <BlockRow key={key} title={entry.title} hidePast={isPast && hidePast} />;
|
||||||
|
}
|
||||||
|
if (isOntimeDelay(entry)) {
|
||||||
|
if (isPast && hidePast) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const delayVal = entry.duration;
|
||||||
|
if (hideDelays || delayVal === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DelayRow key={key} duration={delayVal} />;
|
||||||
|
}
|
||||||
|
if (isOntimeEvent(entry)) {
|
||||||
|
eventIndex++;
|
||||||
|
const isSelected = key === selectedEventId;
|
||||||
|
|
||||||
|
if (isPast && hidePast) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rowBgColour: string | undefined;
|
||||||
|
if (isSelected) {
|
||||||
|
rowBgColour = '#D20300'; // $red-700
|
||||||
|
} else if (entry.colour) {
|
||||||
|
try {
|
||||||
|
// the colour is user defined and might be invalid
|
||||||
|
const accessibleBackgroundColor = Color(getAccessibleColour(entry.colour).backgroundColor);
|
||||||
|
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
|
||||||
|
} catch (_error) {
|
||||||
|
/* we do not handle errors here */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
showIndexColumn={!hideIndexColumn}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<MenuButton
|
||||||
|
as={IconButton}
|
||||||
|
size='xs'
|
||||||
|
aria-label='Options'
|
||||||
|
icon={<IoEllipsisHorizontal />}
|
||||||
|
variant='ontime-ghosted'
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// currently there is no scenario where entryType is not handled above, either way...
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
|
||||||
|
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||||
|
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
|
||||||
|
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 { OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
|
import { cloneEvent } from '../../../common/utils/eventsManager';
|
||||||
|
|
||||||
|
interface CuesheetTableMenuProps {
|
||||||
|
event: OntimeEvent;
|
||||||
|
entryIndex: number;
|
||||||
|
showModal: (entryId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CuesheetTableMenu(props: CuesheetTableMenuProps) {
|
||||||
|
const { event, entryIndex, showModal } = props;
|
||||||
|
const { addEvent, reorderEvent, deleteEvent } = useEventAction();
|
||||||
|
|
||||||
|
const handleCloneEvent = () => {
|
||||||
|
const newEvent = cloneEvent(event);
|
||||||
|
try {
|
||||||
|
addEvent(newEvent, { after: event.id });
|
||||||
|
} catch (_error) {
|
||||||
|
// we do not handle errors here
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuList>
|
||||||
|
<MenuItem icon={<IoOptions />} onClick={() => showModal(event.id)}>
|
||||||
|
Edit ...
|
||||||
|
</MenuItem>
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItem icon={<IoAdd />} onClick={() => addEvent({ type: SupportedEvent.Event }, { before: event.id })}>
|
||||||
|
Add event above
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem icon={<IoAdd />} onClick={() => addEvent({ type: SupportedEvent.Event }, { after: event.id })}>
|
||||||
|
Add event below
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem icon={<IoDuplicateOutline />} onClick={handleCloneEvent}>
|
||||||
|
Clone event
|
||||||
|
</MenuItem>
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItem
|
||||||
|
isDisabled={entryIndex < 1}
|
||||||
|
icon={<IoArrowUp />}
|
||||||
|
onClick={() => reorderEvent(event.id, entryIndex, entryIndex - 1)}
|
||||||
|
>
|
||||||
|
Move up
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem icon={<IoArrowDown />} onClick={() => reorderEvent(event.id, entryIndex, entryIndex + 1)}>
|
||||||
|
Move down
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem icon={<IoTrash />} onClick={() => deleteEvent([event.id])}>
|
||||||
|
Delete
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
import { useCurrentBlockId } from '../../../../common/hooks/useSocket';
|
||||||
|
|
||||||
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
|
interface BlockRowProps {
|
||||||
|
hidePast: boolean;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockRow(props: BlockRowProps) {
|
||||||
|
const { hidePast, title } = props;
|
||||||
|
const { currentBlockId } = useCurrentBlockId();
|
||||||
|
|
||||||
|
if (hidePast && !currentBlockId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={style.blockRow}>
|
||||||
|
<td>{title}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(BlockRow);
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
|
||||||
|
import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
||||||
|
import { OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
|
|
||||||
|
import { SortableCell } from './SortableCell';
|
||||||
|
|
||||||
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
|
interface CuesheetHeaderProps {
|
||||||
|
headerGroups: HeaderGroup<OntimeRundownEntry>[];
|
||||||
|
showIndexColumn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||||
|
const { headerGroups, showIndexColumn } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<thead className={style.tableHeader}>
|
||||||
|
{headerGroups.map((headerGroup) => {
|
||||||
|
const key = headerGroup.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
<th className={style.indexColumn}>{showIndexColumn && '#'}</th>
|
||||||
|
<th className={style.actionColumn} />
|
||||||
|
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const width = header.getSize();
|
||||||
|
// @ts-expect-error -- we inject this into react-table
|
||||||
|
const customBackground = header.column.columnDef?.meta?.colour;
|
||||||
|
|
||||||
|
let customStyles = {};
|
||||||
|
if (customBackground) {
|
||||||
|
const customColour = getAccessibleColour(customBackground);
|
||||||
|
customStyles = { backgroundColor: customColour.backgroundColor, color: customColour.color };
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</SortableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { millisToDelayString } from '../../../common/utils/dateConfig';
|
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||||
|
|
||||||
import style from '../Cuesheet.module.scss';
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
interface DelayRowProps {
|
interface DelayRowProps {
|
||||||
duration: number;
|
duration: number;
|
||||||
+9
-10
@@ -1,10 +1,9 @@
|
|||||||
import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react';
|
import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import Color from 'color';
|
||||||
|
|
||||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
|
|
||||||
import style from '../Cuesheet.module.scss';
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
const pastOpacity = '0.2';
|
|
||||||
|
|
||||||
interface EventRowProps {
|
interface EventRowProps {
|
||||||
eventIndex: number;
|
eventIndex: number;
|
||||||
@@ -20,9 +19,6 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
|
|||||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
const textColour = getAccessibleColour(colour);
|
|
||||||
const bgColour = textColour.backgroundColor;
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
@@ -50,13 +46,16 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
|
|||||||
};
|
};
|
||||||
}, [ownRef, selectedRef]);
|
}, [ownRef, selectedRef]);
|
||||||
|
|
||||||
|
const { color, backgroundColor } = getAccessibleColour(colour);
|
||||||
|
const mutedText = Color(color).fade(0.4).hexa();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
className={`${style.eventRow} ${skip ? style.skip : ''}`}
|
className={cx([style.eventRow, skip ?? style.skip])}
|
||||||
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
|
style={{ opacity: `${isPast ? '0.2' : '1'}` }}
|
||||||
ref={selectedRef ?? ownRef}
|
ref={selectedRef ?? ownRef}
|
||||||
>
|
>
|
||||||
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour.color }}>
|
<td className={style.indexColumn} style={{ backgroundColor, color: mutedText }}>
|
||||||
{showIndexColumn && eventIndex}
|
{showIndexColumn && eventIndex}
|
||||||
</td>
|
</td>
|
||||||
{isVisible ? children : null}
|
{isVisible ? children : null}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
import { memo, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
|
||||||
|
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
|
|
||||||
|
interface MultiLineCellProps {
|
||||||
|
initialValue: string;
|
||||||
|
handleUpdate: (newValue: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiLineCell = (props: MultiLineCellProps) => {
|
||||||
|
const { initialValue, handleUpdate } = props;
|
||||||
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
|
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
||||||
|
|
||||||
|
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||||
|
submitOnCtrlEnter: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AutoTextArea
|
||||||
|
inputref={ref}
|
||||||
|
rows={1}
|
||||||
|
size='sm'
|
||||||
|
padding={0}
|
||||||
|
fontSize='1rem'
|
||||||
|
transition='none'
|
||||||
|
variant='ontime-transparent'
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(MultiLineCell);
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { memo, useCallback, useRef } from 'react';
|
||||||
|
import { Input } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
|
|
||||||
|
interface SingleLineCellProps {
|
||||||
|
initialValue: string;
|
||||||
|
handleUpdate: (newValue: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SingleLineCell = (props: SingleLineCellProps) => {
|
||||||
|
const { initialValue, handleUpdate } = props;
|
||||||
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
|
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
||||||
|
|
||||||
|
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||||
|
submitOnCtrlEnter: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
size='sx'
|
||||||
|
variant='ontime-transparent'
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(SingleLineCell);
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
import { Header } from '@tanstack/react-table';
|
import { Header } from '@tanstack/react-table';
|
||||||
import { OntimeRundownEntry } from 'ontime-types';
|
import { OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
import styles from '../Cuesheet.module.scss';
|
import styles from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
interface SortableCellProps {
|
interface SortableCellProps {
|
||||||
header: Header<OntimeRundownEntry, unknown>;
|
header: Header<OntimeRundownEntry, unknown>;
|
||||||
+56
-30
@@ -1,46 +1,40 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
|
||||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||||
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||||
import RunningTime from '../../features/viewers/common/running-time/RunningTime';
|
import RunningTime from '../../../../features/viewers/common/running-time/RunningTime';
|
||||||
|
import { useCuesheetOptions } from '../../cuesheet.options';
|
||||||
|
|
||||||
import EditableCell from './cuesheet-table-elements/EditableCell';
|
import MultiLineCell from './MultiLineCell';
|
||||||
import { useCuesheetSettings } from './store/cuesheetSettingsStore';
|
import SingleLineCell from './SingleLineCell';
|
||||||
|
|
||||||
import style from './Cuesheet.module.scss';
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
|
|
||||||
const cellValue = row.getValue();
|
|
||||||
return cellValue ? <IoCheckmark className={style.check} /> : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
const { showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
|
||||||
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
|
|
||||||
const cellValue = (getValue() as number | null) ?? 0;
|
const cellValue = (getValue() as number | null) ?? 0;
|
||||||
const delayValue = (original as OntimeEvent)?.delay ?? 0;
|
const delayValue = (original as OntimeEvent)?.delay ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={style.time}>
|
<span className={style.time}>
|
||||||
<DelayIndicator delayValue={delayValue} />
|
<DelayIndicator delayValue={delayValue} />
|
||||||
<RunningTime value={cellValue} hideSeconds={hideSeconds} />
|
<RunningTime value={cellValue} hideSeconds={hideTableSeconds} />
|
||||||
{delayValue !== 0 && showDelayedTimes && (
|
{delayValue !== 0 && showDelayedTimes && (
|
||||||
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideSeconds} />
|
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideTableSeconds} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
|
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
|
const { hideTableSeconds } = useCuesheetOptions();
|
||||||
const cellValue = (getValue() as number | null) ?? 0;
|
const cellValue = (getValue() as number | null) ?? 0;
|
||||||
|
|
||||||
return <RunningTime value={cellValue} hideSeconds={hideSeconds} />;
|
return <RunningTime value={cellValue} hideSeconds={hideTableSeconds} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
// @ts-expect-error -- we inject this into react-table
|
// @ts-expect-error -- we inject this into react-table
|
||||||
@@ -55,10 +49,49 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry,
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// events dont necessarily contain all custom fields
|
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? '';
|
||||||
|
|
||||||
|
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
|
const update = useCallback(
|
||||||
|
(newValue: string) => {
|
||||||
|
// @ts-expect-error -- we inject this into react-table
|
||||||
|
table.options.meta?.handleUpdate(row.index, column.id, newValue);
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||||
|
[column.id, row.index],
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = row.original;
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? '';
|
||||||
|
|
||||||
|
return <SingleLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
|
const update = useCallback(
|
||||||
|
(newValue: string) => {
|
||||||
|
// @ts-expect-error -- we inject this into react-table
|
||||||
|
table.options.meta?.handleUpdateCustom(row.index, column.id, newValue);
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||||
|
[column.id, row.index],
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = row.original;
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const initialValue = event.custom[column.id] ?? '';
|
const initialValue = event.custom[column.id] ?? '';
|
||||||
|
|
||||||
return <EditableCell value={initialValue} handleUpdate={update} />;
|
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
|
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
|
||||||
@@ -76,16 +109,9 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
|||||||
accessorKey: 'cue',
|
accessorKey: 'cue',
|
||||||
id: 'cue',
|
id: 'cue',
|
||||||
header: 'Cue',
|
header: 'Cue',
|
||||||
cell: (row) => row.getValue(),
|
cell: MakeSingleLineField,
|
||||||
size: 75,
|
size: 75,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: 'isPublic',
|
|
||||||
id: 'isPublic',
|
|
||||||
header: 'Public',
|
|
||||||
cell: makePublic,
|
|
||||||
size: 45,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: 'timeStart',
|
accessorKey: 'timeStart',
|
||||||
id: 'timeStart',
|
id: 'timeStart',
|
||||||
@@ -111,14 +137,14 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
|||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
id: 'title',
|
id: 'title',
|
||||||
header: 'Title',
|
header: 'Title',
|
||||||
cell: (row) => row.getValue(),
|
cell: MakeSingleLineField,
|
||||||
size: 250,
|
size: 250,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'note',
|
accessorKey: 'note',
|
||||||
id: 'note',
|
id: 'note',
|
||||||
header: 'Note',
|
header: 'Note',
|
||||||
cell: (row) => row.getValue(),
|
cell: MakeMultiLineField,
|
||||||
size: 250,
|
size: 250,
|
||||||
},
|
},
|
||||||
...dynamicCustomFields,
|
...dynamicCustomFields,
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
.tableSettings {
|
.tableSettings {
|
||||||
grid-area: settings;
|
grid-area: settings;
|
||||||
padding: 0.5rem 1rem;
|
padding-inline: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 5rem;
|
gap: 5rem;
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
+1
-1
@@ -3,7 +3,7 @@ import { Button, Checkbox } from '@chakra-ui/react';
|
|||||||
import { Column } from '@tanstack/react-table';
|
import { Column } from '@tanstack/react-table';
|
||||||
import { OntimeRundownEntry } from 'ontime-types';
|
import { OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
import * as Editor from '../../../features/editors/editor-utils/EditorUtils';
|
import * as Editor from '../../../../features/editors/editor-utils/EditorUtils';
|
||||||
|
|
||||||
import style from './CuesheetTableSettings.module.scss';
|
import style from './CuesheetTableSettings.module.scss';
|
||||||
|
|
||||||
@@ -1,19 +1,90 @@
|
|||||||
import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
|
import { useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||||
|
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description set default column order
|
* In the specific case of the cuesheet options
|
||||||
|
* we save the user preferences in the local storage
|
||||||
*/
|
*/
|
||||||
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
|
export const cuesheetOptions: ViewOption[] = [
|
||||||
'isPublic',
|
{ section: 'Table options' },
|
||||||
'cue',
|
{
|
||||||
'timeStart',
|
id: 'hideTableSeconds',
|
||||||
'timeEnd',
|
title: 'Hide seconds in table',
|
||||||
'duration',
|
description: 'Whether to hide seconds in the time fields displayed in the table',
|
||||||
'title',
|
type: 'boolean',
|
||||||
'note',
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'followSelected',
|
||||||
|
title: 'Follow selected event',
|
||||||
|
description: 'Whether the view should automatically scroll to the selected event',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hidePast',
|
||||||
|
title: 'Hide Past Events',
|
||||||
|
description: 'Whether to hide events that have passed',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideIndexColumn',
|
||||||
|
title: 'Hide index column',
|
||||||
|
description: 'Whether the hide the event indexes in the table',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{ section: 'Delay flow' },
|
||||||
|
{
|
||||||
|
id: 'showDelayedTimes',
|
||||||
|
title: 'Show delayed times',
|
||||||
|
description: 'Whether the time fields should include delays',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideDelays',
|
||||||
|
title: 'Hide delays',
|
||||||
|
description: 'Whether to hide the rows containing scheduled delays',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type CuesheetOptions = {
|
||||||
|
hideTableSeconds: boolean;
|
||||||
|
followSelected: boolean;
|
||||||
|
hidePast: boolean;
|
||||||
|
hideIndexColumn: boolean;
|
||||||
|
showDelayedTimes: boolean;
|
||||||
|
hideDelays: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description set default hidden columns
|
* Utility extract the view options from URL Params
|
||||||
|
* the names and fallbacks are manually matched with cuesheetOptions
|
||||||
*/
|
*/
|
||||||
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [];
|
export function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
|
||||||
|
// we manually make an object that matches the key above
|
||||||
|
return {
|
||||||
|
hideTableSeconds: isStringBoolean(searchParams.get('hideTableSeconds')),
|
||||||
|
followSelected: isStringBoolean(searchParams.get('followSelected')),
|
||||||
|
hidePast: isStringBoolean(searchParams.get('hidePast')),
|
||||||
|
hideIndexColumn: isStringBoolean(searchParams.get('hideIndexColumn')),
|
||||||
|
showDelayedTimes: isStringBoolean(searchParams.get('showDelayedTimes')),
|
||||||
|
hideDelays: isStringBoolean(searchParams.get('hideDelays')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook exposes the cuesheet view options
|
||||||
|
*/
|
||||||
|
export function useCuesheetOptions(): CuesheetOptions {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
|
|
||||||
|
|
||||||
interface CuesheetSettingsStore {
|
|
||||||
showSettings: boolean;
|
|
||||||
showIndexColumn: boolean;
|
|
||||||
followSelected: boolean;
|
|
||||||
showPrevious: boolean;
|
|
||||||
showDelayBlock: boolean;
|
|
||||||
showDelayedTimes: boolean;
|
|
||||||
hideSeconds: boolean;
|
|
||||||
|
|
||||||
toggleSettings: (newValue?: boolean) => void;
|
|
||||||
toggleFollow: (newValue?: boolean) => void;
|
|
||||||
togglePreviousVisibility: (newValue?: boolean) => void;
|
|
||||||
toggleIndexColumn: (newValue?: boolean) => void;
|
|
||||||
toggleDelayVisibility: (newValue?: boolean) => void;
|
|
||||||
toggleDelayedTimes: (newValue?: boolean) => void;
|
|
||||||
toggleSecondsVisibility: (newValue?: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(oldValue: boolean, value?: boolean) {
|
|
||||||
if (typeof value === 'undefined') {
|
|
||||||
return !oldValue;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CuesheetKeys {
|
|
||||||
Follow = 'ontime-cuesheet-follow-selected',
|
|
||||||
DelayVisibility = 'ontime-cuesheet-show-delay',
|
|
||||||
PreviousVisibility = 'ontime-cuesheet-show-previous',
|
|
||||||
ColumnIndex = 'ontime-cuesheet-show-index-column',
|
|
||||||
DelayedTimes = 'ontime-cuesheet-show-delayed',
|
|
||||||
Seconds = 'ontime-cuesheet-hide-sceconds',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCuesheetSettings = create<CuesheetSettingsStore>()((set) => ({
|
|
||||||
showSettings: false,
|
|
||||||
showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true),
|
|
||||||
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
|
|
||||||
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
|
|
||||||
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
|
|
||||||
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
|
|
||||||
hideSeconds: booleanFromLocalStorage(CuesheetKeys.Seconds, false),
|
|
||||||
|
|
||||||
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
|
|
||||||
toggleFollow: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const followSelected = toggle(state.followSelected, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.Follow, String(followSelected));
|
|
||||||
return { followSelected };
|
|
||||||
}),
|
|
||||||
toggleIndexColumn: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const showIndexColumn = toggle(state.showIndexColumn, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.ColumnIndex, String(showIndexColumn));
|
|
||||||
return { showIndexColumn };
|
|
||||||
}),
|
|
||||||
togglePreviousVisibility: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const showPrevious = toggle(state.showPrevious, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.PreviousVisibility, String(showPrevious));
|
|
||||||
return { showPrevious };
|
|
||||||
}),
|
|
||||||
toggleDelayVisibility: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const showDelayBlock = toggle(state.showDelayBlock, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.DelayVisibility, String(showDelayBlock));
|
|
||||||
return { showDelayBlock };
|
|
||||||
}),
|
|
||||||
toggleDelayedTimes: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const showDelayedTimes = toggle(state.showDelayedTimes, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
|
|
||||||
return { showDelayedTimes };
|
|
||||||
}),
|
|
||||||
toggleSecondsVisibility: (newValue?: boolean) =>
|
|
||||||
set((state) => {
|
|
||||||
const hideSeconds = toggle(state.hideSeconds, newValue);
|
|
||||||
localStorage.setItem(CuesheetKeys.Seconds, String(hideSeconds));
|
|
||||||
return { hideSeconds };
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
@@ -23,7 +23,7 @@ if (!isProduction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Flag holds server loading state */
|
/** Flag holds server loading state */
|
||||||
let loaded = 'Ontime running';
|
let loaded = 'Ontime starting';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flag whether user has requested a quit
|
* Flag whether user has requested a quit
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Request, Response, NextFunction } from 'express';
|
|||||||
|
|
||||||
export const rundownPostValidator = [
|
export const rundownPostValidator = [
|
||||||
body('type').isString().exists().isIn(['event', 'delay', 'block']),
|
body('type').isString().exists().isIn(['event', 'delay', 'block']),
|
||||||
|
body('after').optional().isString(),
|
||||||
|
body('before').optional().isString(),
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
|
|||||||
+56
-13
@@ -3,7 +3,7 @@ import { LogOrigin, Playback, SimpleDirection, SimplePlayback } from 'ontime-typ
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import expressStaticGzip from 'express-static-gzip';
|
import expressStaticGzip from 'express-static-gzip';
|
||||||
import http, { type Server } from 'http';
|
import http, { Server } from 'http';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import serverTiming from 'server-timing';
|
import serverTiming from 'server-timing';
|
||||||
import { extname } from 'node:path';
|
import { extname } from 'node:path';
|
||||||
@@ -179,9 +179,15 @@ export const startServer = async (
|
|||||||
escalateErrorFn?: (error: string) => void,
|
escalateErrorFn?: (error: string) => void,
|
||||||
): Promise<{ message: string; serverPort: number }> => {
|
): Promise<{ message: string; serverPort: number }> => {
|
||||||
checkStart(OntimeStartOrder.InitServer);
|
checkStart(OntimeStartOrder.InitServer);
|
||||||
const { serverPort } = getDataProvider().getSettings();
|
const settings = getDataProvider().getSettings();
|
||||||
|
const { serverPort: desiredPort } = settings;
|
||||||
|
|
||||||
expressServer = http.createServer(app);
|
expressServer = http.createServer(app);
|
||||||
|
|
||||||
|
// the express server must be started before the socket otherwise the on error eventlissner will not attach properly
|
||||||
|
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
|
||||||
|
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
|
||||||
|
|
||||||
socket.init(expressServer, prefix);
|
socket.init(expressServer, prefix);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,19 +231,20 @@ export const startServer = async (
|
|||||||
// TODO: pass event store to rundownservice
|
// TODO: pass event store to rundownservice
|
||||||
runtimeService.init(maybeRestorePoint);
|
runtimeService.init(maybeRestorePoint);
|
||||||
|
|
||||||
expressServer.listen(serverPort, '0.0.0.0', () => {
|
const nif = getNetworkInterfaces();
|
||||||
const nif = getNetworkInterfaces();
|
consoleSuccess(`Local: http://localhost:${resultPort}${prefix}/editor`);
|
||||||
consoleSuccess(`Local: http://localhost:${serverPort}${prefix}/editor`);
|
for (const key in nif) {
|
||||||
for (const key in nif) {
|
const address = nif[key].address;
|
||||||
const address = nif[key].address;
|
consoleSuccess(`Network: http://${address}:${resultPort}${prefix}/editor`);
|
||||||
consoleSuccess(`Network: http://${address}:${serverPort}${prefix}/editor`);
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const returnMessage = `Ontime is listening on port ${serverPort}`;
|
const returnMessage = `Ontime is listening on port ${resultPort}`;
|
||||||
logger.info(LogOrigin.Server, returnMessage);
|
logger.info(LogOrigin.Server, returnMessage);
|
||||||
|
|
||||||
return { message: returnMessage, serverPort };
|
return {
|
||||||
|
message: returnMessage,
|
||||||
|
serverPort: resultPort,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -307,7 +314,7 @@ process.on('unhandledRejection', async (error) => {
|
|||||||
consoleError(error.stack);
|
consoleError(error.stack);
|
||||||
}
|
}
|
||||||
generateCrashReport(error);
|
generateCrashReport(error);
|
||||||
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
|
logger.crash(LogOrigin.Server, `Uncaught rejection | ${error}`);
|
||||||
await shutdown(1);
|
await shutdown(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -324,3 +331,39 @@ process.on('uncaughtException', async (error) => {
|
|||||||
process.once('SIGHUP', async () => shutdown(0));
|
process.once('SIGHUP', async () => shutdown(0));
|
||||||
process.once('SIGINT', async () => shutdown(0));
|
process.once('SIGINT', async () => shutdown(0));
|
||||||
process.once('SIGTERM', async () => shutdown(0));
|
process.once('SIGTERM', async () => shutdown(0));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description tries to open the server with the desired port, and if getting a `EADDRINUSE` will change to an efemeral port
|
||||||
|
* @param {http.Server}server http server object
|
||||||
|
* @param {number}desiredPort the desired port
|
||||||
|
* @returns {number} the resulting port number
|
||||||
|
* @throws any other server errors will result in a throw
|
||||||
|
*/
|
||||||
|
async function serverTryDesiredPort(server: http.Server, desiredPort: number): Promise<number> {
|
||||||
|
return new Promise((res) => {
|
||||||
|
expressServer.once('error', (e) => {
|
||||||
|
if (testForPortInUser(e)) {
|
||||||
|
logger.crash(LogOrigin.Server, `Failed open the desired port: ${desiredPort} | to moving to Ephemeral port`);
|
||||||
|
server.listen(0, '0.0.0.0', () => {
|
||||||
|
// @ts-expect-error TODO: find proper documentation for this api
|
||||||
|
const port: number = server.address().port;
|
||||||
|
res(port);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.listen(desiredPort, '0.0.0.0', () => {
|
||||||
|
// @ts-expect-error TODO: find proper documentation for this api
|
||||||
|
const port: number = server.address().port;
|
||||||
|
res(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testForPortInUser(err: unknown) {
|
||||||
|
if (typeof err === 'object' && 'code' in err && err.code === 'EADDRINUSE') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.01',
|
note: 'SF1.01',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 36000000,
|
timeStart: 36000000,
|
||||||
@@ -34,6 +35,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.02',
|
note: 'SF1.02',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 37500000,
|
timeStart: 37500000,
|
||||||
@@ -58,6 +60,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.03',
|
note: 'SF1.03',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 39000000,
|
timeStart: 39000000,
|
||||||
@@ -82,6 +85,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.04',
|
note: 'SF1.04',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 40500000,
|
timeStart: 40500000,
|
||||||
@@ -106,6 +110,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.05',
|
note: 'SF1.05',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 42000000,
|
timeStart: 42000000,
|
||||||
@@ -135,6 +140,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.06',
|
note: 'SF1.06',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 47100000,
|
timeStart: 47100000,
|
||||||
@@ -159,6 +165,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.07',
|
note: 'SF1.07',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 48600000,
|
timeStart: 48600000,
|
||||||
@@ -183,6 +190,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.08',
|
note: 'SF1.08',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 50100000,
|
timeStart: 50100000,
|
||||||
@@ -207,6 +215,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.09',
|
note: 'SF1.09',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 51600000,
|
timeStart: 51600000,
|
||||||
@@ -231,6 +240,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.10',
|
note: 'SF1.10',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 53100000,
|
timeStart: 53100000,
|
||||||
@@ -260,6 +270,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.11',
|
note: 'SF1.11',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 56100000,
|
timeStart: 56100000,
|
||||||
@@ -284,9 +295,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.12',
|
note: 'SF1.12',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
|
|
||||||
timeStart: 57600000,
|
timeStart: 57600000,
|
||||||
timeEnd: 58800000,
|
timeEnd: 58800000,
|
||||||
duration: 1200000,
|
duration: 1200000,
|
||||||
@@ -309,6 +320,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.13',
|
note: 'SF1.13',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 59100000,
|
timeStart: 59100000,
|
||||||
@@ -333,6 +345,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'SF1.14',
|
note: 'SF1.14',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
timeStart: 60600000,
|
timeStart: 60600000,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
|
|||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
timeStrategy: TimeStrategy.LockDuration,
|
timeStrategy: TimeStrategy.LockDuration,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStart: 0,
|
timeStart: 0,
|
||||||
timeEnd: 0,
|
timeEnd: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ describe('getExpectedFinish()', () => {
|
|||||||
const state = {
|
const state = {
|
||||||
eventNow: {
|
eventNow: {
|
||||||
timeEnd: 30,
|
timeEnd: 30,
|
||||||
timerType: TimerType.TimeToEnd,
|
isTimeToEnd: true,
|
||||||
},
|
},
|
||||||
timer: {
|
timer: {
|
||||||
addedTime: 10,
|
addedTime: 10,
|
||||||
@@ -195,7 +195,7 @@ describe('getExpectedFinish()', () => {
|
|||||||
const state = {
|
const state = {
|
||||||
eventNow: {
|
eventNow: {
|
||||||
timeEnd: 600000, // 00:10:00
|
timeEnd: 600000, // 00:10:00
|
||||||
timerType: TimerType.TimeToEnd,
|
isTimeToEnd: true,
|
||||||
},
|
},
|
||||||
timer: {
|
timer: {
|
||||||
addedTime: 0,
|
addedTime: 0,
|
||||||
@@ -351,7 +351,7 @@ describe('getCurrent()', () => {
|
|||||||
const state = {
|
const state = {
|
||||||
eventNow: {
|
eventNow: {
|
||||||
timeEnd: 100,
|
timeEnd: 100,
|
||||||
timerType: TimerType.TimeToEnd,
|
isTimeToEnd: true,
|
||||||
},
|
},
|
||||||
clock: 30,
|
clock: 30,
|
||||||
timer: {
|
timer: {
|
||||||
@@ -376,7 +376,7 @@ describe('getCurrent()', () => {
|
|||||||
const state = {
|
const state = {
|
||||||
eventNow: {
|
eventNow: {
|
||||||
timeEnd: 100,
|
timeEnd: 100,
|
||||||
timerType: TimerType.TimeToEnd,
|
isTimeToEnd: true,
|
||||||
},
|
},
|
||||||
clock: 30,
|
clock: 30,
|
||||||
timer: {
|
timer: {
|
||||||
@@ -401,7 +401,7 @@ describe('getCurrent()', () => {
|
|||||||
const state = {
|
const state = {
|
||||||
eventNow: {
|
eventNow: {
|
||||||
timeEnd: 100,
|
timeEnd: 100,
|
||||||
timerType: TimerType.TimeToEnd,
|
isTimeToEnd: true,
|
||||||
},
|
},
|
||||||
clock: 30,
|
clock: 30,
|
||||||
timer: {
|
timer: {
|
||||||
@@ -427,7 +427,7 @@ describe('getCurrent()', () => {
|
|||||||
eventNow: {
|
eventNow: {
|
||||||
timeStart: 79200000, // 22:00:00
|
timeStart: 79200000, // 22:00:00
|
||||||
timeEnd: 600000, // 00:10:00
|
timeEnd: 600000, // 00:10:00
|
||||||
timerType: TimerType.TimeToEnd,
|
isTimeToEnd: true,
|
||||||
},
|
},
|
||||||
clock: 79500000, // 22:05:00
|
clock: 79500000, // 22:05:00
|
||||||
timer: {
|
timer: {
|
||||||
@@ -456,7 +456,7 @@ describe('getCurrent()', () => {
|
|||||||
timeStart: 77400000, // 21:30:00
|
timeStart: 77400000, // 21:30:00
|
||||||
timeEnd: 81000000, // 22:30:00
|
timeEnd: 81000000, // 22:30:00
|
||||||
duration: 3600000, // 01:00:00
|
duration: 3600000, // 01:00:00
|
||||||
timerType: TimerType.TimeToEnd,
|
isTimeToEnd: true,
|
||||||
},
|
},
|
||||||
timer: {
|
timer: {
|
||||||
addedTime: 0,
|
addedTime: 0,
|
||||||
@@ -909,7 +909,8 @@ describe('getRuntimeOffset()', () => {
|
|||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.TimeToEnd,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: true,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
skip: false,
|
skip: false,
|
||||||
note: '',
|
note: '',
|
||||||
@@ -961,7 +962,8 @@ describe('getRuntimeOffset()', () => {
|
|||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.TimeToEnd,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: true,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
skip: false,
|
skip: false,
|
||||||
note: '',
|
note: '',
|
||||||
@@ -1011,7 +1013,8 @@ describe('getRuntimeOffset()', () => {
|
|||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.TimeToEnd, // <--- but this is time to end
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: true,
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
selectedEventIndex: 0,
|
selectedEventIndex: 0,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
OntimeRundown,
|
OntimeRundown,
|
||||||
|
PatchWithId,
|
||||||
|
EventPostPayload,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { getCueCandidate } from 'ontime-utils';
|
import { getCueCandidate } from 'ontime-utils';
|
||||||
|
|
||||||
@@ -22,8 +24,6 @@ import { runtimeService } from '../runtime-service/RuntimeService.js';
|
|||||||
import * as cache from './rundownCache.js';
|
import * as cache from './rundownCache.js';
|
||||||
import { getPlayableEvents, getTimedEvents } from './rundownUtils.js';
|
import { getPlayableEvents, getTimedEvents } from './rundownUtils.js';
|
||||||
|
|
||||||
type PatchWithId = (Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string };
|
|
||||||
|
|
||||||
type CompleteEntry<T> =
|
type CompleteEntry<T> =
|
||||||
T extends Partial<OntimeEvent>
|
T extends Partial<OntimeEvent>
|
||||||
? OntimeEvent
|
? OntimeEvent
|
||||||
@@ -35,12 +35,13 @@ type CompleteEntry<T> =
|
|||||||
|
|
||||||
function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
|
function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
|
||||||
eventData: T,
|
eventData: T,
|
||||||
|
afterId?: string,
|
||||||
): CompleteEntry<T> {
|
): CompleteEntry<T> {
|
||||||
// we discard any UI provided IDs and add our own
|
// we discard any UI provided IDs and add our own
|
||||||
const id = cache.getUniqueId();
|
const id = cache.getUniqueId();
|
||||||
|
|
||||||
if (isOntimeEvent(eventData)) {
|
if (isOntimeEvent(eventData)) {
|
||||||
return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), eventData?.after)) as CompleteEntry<T>;
|
return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), afterId)) as CompleteEntry<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeDelay(eventData)) {
|
if (isOntimeDelay(eventData)) {
|
||||||
@@ -59,9 +60,11 @@ function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | P
|
|||||||
* @param {object} eventData
|
* @param {object} eventData
|
||||||
* @return {OntimeRundownEntry}
|
* @return {OntimeRundownEntry}
|
||||||
*/
|
*/
|
||||||
export async function addEvent(eventData: PatchWithId & { after?: string }): Promise<OntimeRundownEntry> {
|
export async function addEvent(eventData: EventPostPayload): Promise<OntimeRundownEntry> {
|
||||||
// if the user didnt provide an index, we add the event to start
|
// if the user didnt provide an index, we add the event to start
|
||||||
let atIndex = 0;
|
let atIndex = 0;
|
||||||
|
let afterId: string | undefined = eventData?.after;
|
||||||
|
|
||||||
if (eventData?.after !== undefined) {
|
if (eventData?.after !== undefined) {
|
||||||
const previousIndex = cache.getIndexOf(eventData.after);
|
const previousIndex = cache.getIndexOf(eventData.after);
|
||||||
if (previousIndex < 0) {
|
if (previousIndex < 0) {
|
||||||
@@ -69,10 +72,20 @@ export async function addEvent(eventData: PatchWithId & { after?: string }): Pro
|
|||||||
} else {
|
} else {
|
||||||
atIndex = previousIndex + 1;
|
atIndex = previousIndex + 1;
|
||||||
}
|
}
|
||||||
|
} else if (eventData?.before !== undefined) {
|
||||||
|
const previousIndex = cache.getIndexOf(eventData.before);
|
||||||
|
if (previousIndex < 0) {
|
||||||
|
logger.warning(LogOrigin.Server, `Could not find event with id ${eventData.before}`);
|
||||||
|
} else {
|
||||||
|
atIndex = previousIndex;
|
||||||
|
if (previousIndex > 0) {
|
||||||
|
afterId = cache.getPersistedRundown()[atIndex - 1].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// generate a fully formed event from the patch
|
// generate a fully formed event from the patch
|
||||||
const eventToAdd = generateEvent(eventData);
|
const eventToAdd = generateEvent(eventData, afterId);
|
||||||
|
|
||||||
// modify rundown
|
// modify rundown
|
||||||
const scopedMutation = cache.mutateCache(cache.add);
|
const scopedMutation = cache.mutateCache(cache.add);
|
||||||
|
|||||||
@@ -565,6 +565,7 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStart: 600000,
|
timeStart: 600000,
|
||||||
@@ -591,6 +592,7 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStart: 1200000,
|
timeStart: 1200000,
|
||||||
@@ -617,6 +619,7 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStart: 600000,
|
timeStart: 600000,
|
||||||
@@ -643,6 +646,7 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStart: 1200000,
|
timeStart: 1200000,
|
||||||
@@ -678,6 +682,7 @@ describe('getDelayAt()', () => {
|
|||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStart: 600000,
|
timeStart: 600000,
|
||||||
@@ -705,6 +710,7 @@ describe('getDelayAt()', () => {
|
|||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStart: 1200000,
|
timeStart: 1200000,
|
||||||
@@ -732,6 +738,7 @@ describe('getDelayAt()', () => {
|
|||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStart: 600000,
|
timeStart: 600000,
|
||||||
@@ -759,6 +766,7 @@ describe('getDelayAt()', () => {
|
|||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStart: 1200000,
|
timeStart: 1200000,
|
||||||
@@ -812,6 +820,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStart: 600000,
|
timeStart: 600000,
|
||||||
@@ -839,6 +848,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStart: 1200000,
|
timeStart: 1200000,
|
||||||
@@ -866,6 +876,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStart: 600000,
|
timeStart: 600000,
|
||||||
@@ -893,6 +904,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
timeStart: 1200000,
|
timeStart: 1200000,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
linkStart: null,
|
linkStart: null,
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
duration: 10800000,
|
duration: 10800000,
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
skip: false,
|
skip: false,
|
||||||
@@ -73,6 +74,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
timeEnd: 57600000,
|
timeEnd: 57600000,
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
duration: 10800000,
|
duration: 10800000,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
@@ -119,6 +121,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
timeEnd: 57600000,
|
timeEnd: 57600000,
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
duration: 10800000,
|
duration: 10800000,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
@@ -164,6 +167,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
timeEnd: 57600000,
|
timeEnd: 57600000,
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
duration: 10800000,
|
duration: 10800000,
|
||||||
@@ -195,6 +199,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
timeEnd: 57600000,
|
timeEnd: 57600000,
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
duration: 10800000,
|
duration: 10800000,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
@@ -227,6 +232,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
timeEnd: 57600000,
|
timeEnd: 57600000,
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
duration: 10800000,
|
duration: 10800000,
|
||||||
timeStrategy: TimeStrategy.LockEnd,
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
linkStart: null,
|
linkStart: null,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
|
import { MaybeNumber, TimerPhase } from 'ontime-types';
|
||||||
import { dayInMs } from 'ontime-utils';
|
import { dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||||
import { RuntimeState } from '../stores/runtimeState.js';
|
import { RuntimeState } from '../stores/runtimeState.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +19,7 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { timerType, timeEnd } = state.eventNow;
|
const { isTimeToEnd, timeEnd } = state.eventNow;
|
||||||
const { pausedAt } = state._timer;
|
const { pausedAt } = state._timer;
|
||||||
const { clock } = state;
|
const { clock } = state;
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
|
|||||||
|
|
||||||
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
|
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
|
||||||
|
|
||||||
if (timerType === TimerType.TimeToEnd) {
|
if (isTimeToEnd) {
|
||||||
return timeEnd + addedTime + pausedTime;
|
return timeEnd + addedTime + pausedTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,11 +62,11 @@ export function getCurrent(state: RuntimeState): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { startedAt, duration, addedTime } = state.timer;
|
const { startedAt, duration, addedTime } = state.timer;
|
||||||
const { timerType, timeStart, timeEnd } = state.eventNow;
|
const { isTimeToEnd, timeStart, timeEnd } = state.eventNow;
|
||||||
const { pausedAt } = state._timer;
|
const { pausedAt } = state._timer;
|
||||||
const { clock } = state;
|
const { clock } = state;
|
||||||
|
|
||||||
if (timerType === TimerType.TimeToEnd) {
|
if (isTimeToEnd) {
|
||||||
const isEventOverMidnight = timeStart > timeEnd;
|
const isEventOverMidnight = timeStart > timeEnd;
|
||||||
const correctDay = isEventOverMidnight ? dayInMs : 0;
|
const correctDay = isEventOverMidnight ? dayInMs : 0;
|
||||||
return correctDay - clock + timeEnd + addedTime;
|
return correctDay - clock + timeEnd + addedTime;
|
||||||
@@ -131,7 +131,7 @@ export function getRuntimeOffset(state: RuntimeState): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { clock } = state;
|
const { clock } = state;
|
||||||
const { timeStart, timerType } = state.eventNow;
|
const { isTimeToEnd, timeStart } = state.eventNow;
|
||||||
const { addedTime, current, startedAt } = state.timer;
|
const { addedTime, current, startedAt } = state.timer;
|
||||||
|
|
||||||
// if we havent started, but the timer is armed
|
// if we havent started, but the timer is armed
|
||||||
@@ -142,7 +142,7 @@ export function getRuntimeOffset(state: RuntimeState): number {
|
|||||||
|
|
||||||
const overtime = Math.min(current, 0);
|
const overtime = Math.min(current, 0);
|
||||||
// in time-to-end, offset is overtime
|
// in time-to-end, offset is overtime
|
||||||
if (timerType === TimerType.TimeToEnd) {
|
if (isTimeToEnd) {
|
||||||
return overtime;
|
return overtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,25 +187,12 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber {
|
|||||||
return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
|
return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility checks whether the playback is considered to be active
|
|
||||||
* @param state
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export function isPlaybackActive(state: RuntimeState): boolean {
|
|
||||||
return (
|
|
||||||
state.timer.playback === Playback.Play ||
|
|
||||||
state.timer.playback === Playback.Pause ||
|
|
||||||
state.timer.playback === Playback.Roll
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks running timer to see which phase it currently is in
|
* Checks running timer to see which phase it currently is in
|
||||||
* @param state
|
* @param state
|
||||||
*/
|
*/
|
||||||
export function getTimerPhase(state: RuntimeState): TimerPhase {
|
export function getTimerPhase(state: RuntimeState): TimerPhase {
|
||||||
if (!isPlaybackActive(state)) {
|
if (!isPlaybackActive(state.timer.playback)) {
|
||||||
return TimerPhase.None;
|
return TimerPhase.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ describe('mutation on runtimeState', () => {
|
|||||||
expect(newState.runtime.plannedStart).toBe(0);
|
expect(newState.runtime.plannedStart).toBe(0);
|
||||||
expect(newState.runtime.plannedEnd).toBe(1500);
|
expect(newState.runtime.plannedEnd).toBe(1500);
|
||||||
expect(newState.currentBlock.block).toBeNull();
|
expect(newState.currentBlock.block).toBeNull();
|
||||||
|
expect(newState.runtime.offset).toBe(0);
|
||||||
|
|
||||||
// 2. Start event
|
// 2. Start event
|
||||||
start();
|
start();
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ import {
|
|||||||
TimerPhase,
|
TimerPhase,
|
||||||
TimerState,
|
TimerState,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { calculateDuration, checkIsNow, dayInMs, filterTimedEvents, getPreviousBlock } from 'ontime-utils';
|
import {
|
||||||
|
calculateDuration,
|
||||||
|
checkIsNow,
|
||||||
|
dayInMs,
|
||||||
|
filterTimedEvents,
|
||||||
|
getPreviousBlock,
|
||||||
|
isPlaybackActive,
|
||||||
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { clock } from '../services/Clock.js';
|
import { clock } from '../services/Clock.js';
|
||||||
import { RestorePoint } from '../services/RestoreService.js';
|
import { RestorePoint } from '../services/RestoreService.js';
|
||||||
@@ -21,7 +28,6 @@ import {
|
|||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
isPlaybackActive,
|
|
||||||
} from '../services/timerUtils.js';
|
} from '../services/timerUtils.js';
|
||||||
import { timerConfig } from '../config/config.js';
|
import { timerConfig } from '../config/config.js';
|
||||||
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
|
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
|
||||||
@@ -485,7 +491,7 @@ export function update(): UpdateResult {
|
|||||||
runtimeState.clock = clock.timeNow(); // we update the clock on every update call
|
runtimeState.clock = clock.timeNow(); // we update the clock on every update call
|
||||||
|
|
||||||
// 1. is playback idle?
|
// 1. is playback idle?
|
||||||
if (!isPlaybackActive(runtimeState)) {
|
if (!isPlaybackActive(runtimeState.timer.playback)) {
|
||||||
return updateIfIdle();
|
return updateIfIdle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ describe('test event validator', () => {
|
|||||||
const event = {
|
const event = {
|
||||||
title: 'test',
|
title: 'test',
|
||||||
};
|
};
|
||||||
const validated = createEvent(event, 'test');
|
const validated = createEvent(event, 1);
|
||||||
|
|
||||||
expect(validated).toEqual(
|
expect(validated).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -471,12 +471,13 @@ describe('test event validator', () => {
|
|||||||
note: expect.any(String),
|
note: expect.any(String),
|
||||||
timeStart: expect.any(Number),
|
timeStart: expect.any(Number),
|
||||||
timeEnd: expect.any(Number),
|
timeEnd: expect.any(Number),
|
||||||
|
isTimeToEnd: expect.any(Boolean),
|
||||||
isPublic: expect.any(Boolean),
|
isPublic: expect.any(Boolean),
|
||||||
skip: expect.any(Boolean),
|
skip: expect.any(Boolean),
|
||||||
revision: expect.any(Number),
|
revision: expect.any(Number),
|
||||||
type: expect.any(String),
|
type: expect.any(String),
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
cue: 'test',
|
cue: '2',
|
||||||
colour: expect.any(String),
|
colour: expect.any(String),
|
||||||
custom: expect.any(Object),
|
custom: expect.any(Object),
|
||||||
}),
|
}),
|
||||||
@@ -485,7 +486,7 @@ describe('test event validator', () => {
|
|||||||
|
|
||||||
it('fails an empty object', () => {
|
it('fails an empty object', () => {
|
||||||
const event = {};
|
const event = {};
|
||||||
const validated = createEvent(event, 'none');
|
const validated = createEvent(event, 1);
|
||||||
expect(validated).toEqual(null);
|
expect(validated).toEqual(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -495,7 +496,7 @@ describe('test event validator', () => {
|
|||||||
note: '1899-12-30T08:00:10.000Z',
|
note: '1899-12-30T08:00:10.000Z',
|
||||||
};
|
};
|
||||||
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
||||||
const validated = createEvent(event, 'not-used');
|
const validated = createEvent(event, 1);
|
||||||
if (validated === null) {
|
if (validated === null) {
|
||||||
throw new Error('unexpected value');
|
throw new Error('unexpected value');
|
||||||
}
|
}
|
||||||
@@ -774,6 +775,7 @@ describe('getCustomFieldData()', () => {
|
|||||||
duration: 'duration',
|
duration: 'duration',
|
||||||
cue: 'cue',
|
cue: 'cue',
|
||||||
title: 'title',
|
title: 'title',
|
||||||
|
isTimeToEnd: 'time to end',
|
||||||
isPublic: 'public',
|
isPublic: 'public',
|
||||||
skip: 'skip',
|
skip: 'skip',
|
||||||
note: 'notes',
|
note: 'notes',
|
||||||
@@ -824,6 +826,7 @@ describe('getCustomFieldData()', () => {
|
|||||||
duration: 'duration',
|
duration: 'duration',
|
||||||
cue: 'cue',
|
cue: 'cue',
|
||||||
title: 'title',
|
title: 'title',
|
||||||
|
isTimeToEnd: 'time to end',
|
||||||
isPublic: 'public',
|
isPublic: 'public',
|
||||||
skip: 'skip',
|
skip: 'skip',
|
||||||
note: 'notes',
|
note: 'notes',
|
||||||
@@ -883,6 +886,7 @@ describe('parseExcel()', () => {
|
|||||||
'Title',
|
'Title',
|
||||||
'End Action',
|
'End Action',
|
||||||
'Timer type',
|
'Timer type',
|
||||||
|
'Time to end',
|
||||||
'Public',
|
'Public',
|
||||||
'Skip',
|
'Skip',
|
||||||
'Notes',
|
'Notes',
|
||||||
@@ -905,7 +909,8 @@ describe('parseExcel()', () => {
|
|||||||
'Guest Welcome',
|
'Guest Welcome',
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
'x',
|
'x', // <-- time to end
|
||||||
|
'x', // <-- public
|
||||||
'',
|
'',
|
||||||
'Ballyhoo',
|
'Ballyhoo',
|
||||||
'a0',
|
'a0',
|
||||||
@@ -927,7 +932,8 @@ describe('parseExcel()', () => {
|
|||||||
'A song from the hearth',
|
'A song from the hearth',
|
||||||
'load-next',
|
'load-next',
|
||||||
'clock',
|
'clock',
|
||||||
'',
|
'x', // <-- time to end
|
||||||
|
'', // <-- public
|
||||||
'x',
|
'x',
|
||||||
'Rainbow chase',
|
'Rainbow chase',
|
||||||
'b0',
|
'b0',
|
||||||
@@ -971,6 +977,7 @@ describe('parseExcel()', () => {
|
|||||||
timerType: 'count-down',
|
timerType: 'count-down',
|
||||||
endAction: 'none',
|
endAction: 'none',
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
|
isTimeToEnd: true,
|
||||||
skip: false,
|
skip: false,
|
||||||
note: 'Ballyhoo',
|
note: 'Ballyhoo',
|
||||||
custom: {
|
custom: {
|
||||||
@@ -994,6 +1001,7 @@ describe('parseExcel()', () => {
|
|||||||
timeEnd: 30600000,
|
timeEnd: 30600000,
|
||||||
title: 'A song from the hearth',
|
title: 'A song from the hearth',
|
||||||
timerType: 'clock',
|
timerType: 'clock',
|
||||||
|
isTimeToEnd: true,
|
||||||
endAction: 'load-next',
|
endAction: 'load-next',
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
skip: true,
|
skip: true,
|
||||||
@@ -1454,12 +1462,17 @@ describe('parseExcel()', () => {
|
|||||||
timeDanger: 'danger time',
|
timeDanger: 'danger time',
|
||||||
custom: {},
|
custom: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = parseExcel(testdata, {}, importMap);
|
const result = parseExcel(testdata, {}, importMap);
|
||||||
expect(result.rundown.length).toBe(2);
|
expect(result.rundown.length).toBe(2);
|
||||||
expect((result.rundown.at(0) as OntimeEvent).type).toBe(SupportedEvent.Event);
|
expect(result.rundown[0]).toMatchObject({
|
||||||
expect((result.rundown.at(0) as OntimeEvent).timerType).toBe(TimerType.CountDown);
|
type: SupportedEvent.Event,
|
||||||
expect((result.rundown.at(1) as OntimeEvent).type).toBe(SupportedEvent.Event);
|
timerType: TimerType.CountDown,
|
||||||
expect((result.rundown.at(1) as OntimeEvent).timerType).toBe(TimerType.CountDown);
|
});
|
||||||
|
expect(result.rundown[1]).toMatchObject({
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports as events if timer type is empty or has whitespace', () => {
|
it('imports as events if timer type is empty or has whitespace', () => {
|
||||||
@@ -1659,7 +1672,7 @@ describe('parseExcel()', () => {
|
|||||||
expect((events.at(0) as OntimeEvent).colour).toEqual('#F00'); //<--trailing white space in Excel data
|
expect((events.at(0) as OntimeEvent).colour).toEqual('#F00'); //<--trailing white space in Excel data
|
||||||
});
|
});
|
||||||
|
|
||||||
it('link start', () => {
|
it('parses link start and checks that is applicable', () => {
|
||||||
const testData = [
|
const testData = [
|
||||||
[
|
[
|
||||||
'Time Start',
|
'Time Start',
|
||||||
@@ -1675,11 +1688,11 @@ describe('parseExcel()', () => {
|
|||||||
'Timer type',
|
'Timer type',
|
||||||
],
|
],
|
||||||
['4:30:00', '9:45:00', 'A', 'load-next', '', '', 'Rainbow chase', '#F00', 102, '', 'count-down'],
|
['4:30:00', '9:45:00', 'A', 'load-next', '', '', 'Rainbow chase', '#F00', 102, '', 'count-down'],
|
||||||
['9:45:00', '10:56:00', 'C', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'],
|
['9:45:00', '10:56:00', 'B', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'],
|
||||||
['10:00:00', '16:36:00', 'D', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102, 'x', 'count-down'], //<-- incorrect start times are overridden
|
['10:00:00', '16:36:00', 'C', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102, 'x', 'count-down'], // <-- incorrect start times are overridden
|
||||||
['21:45:00', '22:56:00', 'E', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, '', 'count-down'],
|
['21:45:00', '22:56:00', 'D', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, '', 'count-down'],
|
||||||
['', '', 'BLOCK', '', '', '', '', '', '', '', 'block'],
|
['', '', 'BLOCK', '', '', '', '', '', '', '', 'block'],
|
||||||
['00:0:00', '23:56:00', 'G', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], //<-- link past blocks
|
['00:0:00', '23:56:00', 'E', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], // <-- link past blocks
|
||||||
[],
|
[],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1709,30 +1722,46 @@ describe('parseExcel()', () => {
|
|||||||
const { rundown, order } = cache.get();
|
const { rundown, order } = cache.get();
|
||||||
|
|
||||||
const firstId = order.at(0); // A
|
const firstId = order.at(0); // A
|
||||||
const secondId = order.at(1); // C
|
const secondId = order.at(1); // B
|
||||||
const thirdId = order.at(2); // D
|
const thirdId = order.at(2); // C
|
||||||
const fourthId = order.at(3); // E
|
const fourthId = order.at(3); // D
|
||||||
const fifhtId = order.at(4); // Block
|
const fifthId = order.at(4); // Block
|
||||||
const sixthId = order.at(5); // G
|
const sixthId = order.at(5); // E
|
||||||
|
|
||||||
if (!firstId || !secondId || !thirdId || !fourthId || !fifhtId || !sixthId) {
|
if (!firstId || !secondId || !thirdId || !fourthId || !fifthId || !sixthId) {
|
||||||
throw new Error('Unexpected value');
|
throw new Error('Unexpected value');
|
||||||
}
|
}
|
||||||
|
|
||||||
expect((rundown[firstId] as OntimeEvent).timeStart).toEqual(16200000);
|
expect(rundown).toMatchObject({
|
||||||
|
[firstId]: {
|
||||||
expect((rundown[secondId] as OntimeEvent).timeStart).toEqual((rundown[firstId] as OntimeEvent).timeEnd);
|
title: 'A',
|
||||||
expect((rundown[secondId] as OntimeEvent).linkStart).toEqual((rundown[firstId] as OntimeEvent).id);
|
timeStart: 16200000,
|
||||||
|
},
|
||||||
expect((rundown[thirdId] as OntimeEvent).timeStart).toEqual((rundown[secondId] as OntimeEvent).timeEnd);
|
[secondId]: {
|
||||||
expect((rundown[thirdId] as OntimeEvent).linkStart).toEqual((rundown[secondId] as OntimeEvent).id);
|
title: 'B',
|
||||||
|
timeStart: (rundown[firstId] as OntimeEvent).timeEnd,
|
||||||
expect((rundown[fourthId] as OntimeEvent).timeStart).toEqual(78300000);
|
linkStart: (rundown[firstId] as OntimeEvent).id,
|
||||||
|
},
|
||||||
expect((rundown[fifhtId] as OntimeEvent).type).toEqual(SupportedEvent.Block);
|
[thirdId]: {
|
||||||
|
title: 'C',
|
||||||
expect((rundown[sixthId] as OntimeEvent).timeStart).toEqual((rundown[fourthId] as OntimeEvent).timeEnd);
|
timeStart: (rundown[secondId] as OntimeEvent).timeEnd,
|
||||||
expect((rundown[sixthId] as OntimeEvent).linkStart).toEqual((rundown[fourthId] as OntimeEvent).id);
|
linkStart: (rundown[secondId] as OntimeEvent).id,
|
||||||
|
},
|
||||||
|
[fourthId]: {
|
||||||
|
title: 'D',
|
||||||
|
timeStart: 78300000,
|
||||||
|
linkStart: null,
|
||||||
|
},
|
||||||
|
[fifthId]: {
|
||||||
|
title: 'BLOCK',
|
||||||
|
type: SupportedEvent.Block,
|
||||||
|
},
|
||||||
|
[sixthId]: {
|
||||||
|
title: 'E',
|
||||||
|
timeStart: (rundown[fourthId] as OntimeEvent).timeEnd,
|
||||||
|
linkStart: (rundown[fourthId] as OntimeEvent).id,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('#971 BUG: parses time fields and booleans', () => {
|
it('#971 BUG: parses time fields and booleans', () => {
|
||||||
@@ -1762,7 +1791,7 @@ describe('parseExcel()', () => {
|
|||||||
'false',
|
'false',
|
||||||
'Setup',
|
'Setup',
|
||||||
'',
|
'',
|
||||||
'time-to-end',
|
'count-down',
|
||||||
'none',
|
'none',
|
||||||
'15',
|
'15',
|
||||||
'00:05:00',
|
'00:05:00',
|
||||||
@@ -1778,7 +1807,7 @@ describe('parseExcel()', () => {
|
|||||||
'false',
|
'false',
|
||||||
'Meeting 1',
|
'Meeting 1',
|
||||||
'',
|
'',
|
||||||
'time-to-end',
|
'count-down',
|
||||||
'none',
|
'none',
|
||||||
15,
|
15,
|
||||||
'00:05:00',
|
'00:05:00',
|
||||||
@@ -1794,7 +1823,7 @@ describe('parseExcel()', () => {
|
|||||||
'false',
|
'false',
|
||||||
'Meeting 2',
|
'Meeting 2',
|
||||||
'',
|
'',
|
||||||
'time-to-end',
|
'count-down',
|
||||||
'none',
|
'none',
|
||||||
'13',
|
'13',
|
||||||
'5',
|
'5',
|
||||||
@@ -1810,7 +1839,7 @@ describe('parseExcel()', () => {
|
|||||||
'true',
|
'true',
|
||||||
'Lunch',
|
'Lunch',
|
||||||
'',
|
'',
|
||||||
'time-to-end',
|
'count-down',
|
||||||
'none',
|
'none',
|
||||||
13,
|
13,
|
||||||
5,
|
5,
|
||||||
@@ -1839,26 +1868,46 @@ describe('parseExcel()', () => {
|
|||||||
const parsedData = parseExcel(testData, {});
|
const parsedData = parseExcel(testData, {});
|
||||||
const { rundown } = parsedData;
|
const { rundown } = parsedData;
|
||||||
|
|
||||||
|
// '15' as a string is parsed by smart time entry as minutes
|
||||||
|
expect(rundown[0]).toMatchObject({
|
||||||
|
cue: 'SETUP',
|
||||||
|
timeWarning: 15 * MILLIS_PER_MINUTE,
|
||||||
|
});
|
||||||
|
|
||||||
// elements in bug report
|
// elements in bug report
|
||||||
// 15 is a number, in which case we parse it as a minutes value
|
// 15 is a number, in which case we parse it as a minutes value
|
||||||
expect((rundown.at(1) as OntimeEvent).timeWarning).toBe(15 * MILLIS_PER_MINUTE);
|
expect(rundown[1]).toMatchObject({
|
||||||
|
cue: 'MEET1',
|
||||||
|
timeWarning: 15 * MILLIS_PER_MINUTE,
|
||||||
|
});
|
||||||
|
|
||||||
// in the case where a string is passed, we need to check whether it is an ISO 8601 date
|
// in the case where a string is passed, we need to check whether it is an ISO 8601 date
|
||||||
expect((rundown.at(2) as OntimeEvent).duration).toBe(60 * MILLIS_PER_MINUTE);
|
expect(rundown[2]).toMatchObject({
|
||||||
expect((rundown.at(2) as OntimeEvent).timeDanger).toBe(5 * MILLIS_PER_MINUTE);
|
cue: 'MEET2',
|
||||||
|
duration: 60 * MILLIS_PER_MINUTE,
|
||||||
|
timeDanger: 5 * MILLIS_PER_MINUTE,
|
||||||
|
});
|
||||||
|
|
||||||
expect((rundown.at(3) as OntimeEvent).timeWarning).toBe(13 * MILLIS_PER_MINUTE);
|
expect(rundown[3]).toMatchObject({
|
||||||
expect((rundown.at(3) as OntimeEvent).timeDanger).toBe(5 * MILLIS_PER_MINUTE);
|
cue: 'lunch',
|
||||||
|
timeWarning: 13 * MILLIS_PER_MINUTE,
|
||||||
|
timeDanger: 5 * MILLIS_PER_MINUTE,
|
||||||
|
});
|
||||||
|
|
||||||
expect((rundown.at(4) as OntimeEvent).duration).toBe(90 * MILLIS_PER_MINUTE);
|
expect(rundown[4]).toMatchObject({
|
||||||
expect((rundown.at(4) as OntimeEvent).linkStart).toBe(false);
|
cue: 'MEET3',
|
||||||
expect((rundown.at(4) as OntimeEvent).timeWarning).toBe(11 * MILLIS_PER_MINUTE);
|
duration: 90 * MILLIS_PER_MINUTE,
|
||||||
expect((rundown.at(4) as OntimeEvent).timeDanger).toBe(5 * MILLIS_PER_MINUTE);
|
linkStart: false,
|
||||||
|
timeWarning: 11 * MILLIS_PER_MINUTE,
|
||||||
|
timeDanger: 5 * MILLIS_PER_MINUTE,
|
||||||
|
});
|
||||||
|
|
||||||
expect((rundown.at(5) as OntimeEvent).duration).toBe(30 * MILLIS_PER_MINUTE);
|
expect(rundown[5]).toMatchObject({
|
||||||
|
cue: 'MEET4',
|
||||||
// if we get a boolean, we should just use that
|
duration: 30 * MILLIS_PER_MINUTE,
|
||||||
expect((rundown.at(5) as OntimeEvent).linkStart).toBe(true);
|
timeWarning: 11 * MILLIS_PER_MINUTE,
|
||||||
expect((rundown.at(5) as OntimeEvent).timeWarning).toBe(11 * MILLIS_PER_MINUTE);
|
// if we get a boolean, we should just use that
|
||||||
|
linkStart: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -405,28 +405,6 @@ describe('sanitiseCustomFields()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('parseRundown() linking', () => {
|
describe('parseRundown() linking', () => {
|
||||||
const blankEvent: OntimeEvent = {
|
|
||||||
id: '',
|
|
||||||
type: SupportedEvent.Event,
|
|
||||||
cue: '',
|
|
||||||
title: '',
|
|
||||||
note: '',
|
|
||||||
endAction: EndAction.None,
|
|
||||||
timerType: TimerType.CountDown,
|
|
||||||
linkStart: null,
|
|
||||||
timeStrategy: TimeStrategy.LockDuration,
|
|
||||||
timeStart: 0,
|
|
||||||
timeEnd: 0,
|
|
||||||
duration: 0,
|
|
||||||
isPublic: false,
|
|
||||||
skip: false,
|
|
||||||
colour: '',
|
|
||||||
revision: 0,
|
|
||||||
timeWarning: 120000,
|
|
||||||
timeDanger: 60000,
|
|
||||||
custom: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
it('returns linked events', () => {
|
it('returns linked events', () => {
|
||||||
const data: Partial<DatabaseModel> = {
|
const data: Partial<DatabaseModel> = {
|
||||||
rundown: [
|
rundown: [
|
||||||
@@ -445,12 +423,11 @@ describe('parseRundown() linking', () => {
|
|||||||
customFields: {},
|
customFields: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const expected: OntimeRundown = [
|
|
||||||
{ ...blankEvent, id: '1', cue: '0' },
|
|
||||||
{ ...blankEvent, id: '2', cue: '1', linkStart: '1' },
|
|
||||||
];
|
|
||||||
const result = parseRundown(data);
|
const result = parseRundown(data);
|
||||||
expect(result.rundown).toEqual(expected);
|
expect(result.rundown[1]).toMatchObject({
|
||||||
|
id: '2',
|
||||||
|
linkStart: '1',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns unlinked if no previous', () => {
|
it('returns unlinked if no previous', () => {
|
||||||
@@ -466,9 +443,11 @@ describe('parseRundown() linking', () => {
|
|||||||
customFields: {},
|
customFields: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const expected: OntimeRundown = [{ ...blankEvent, id: '2', cue: '0' }];
|
|
||||||
const result = parseRundown(data);
|
const result = parseRundown(data);
|
||||||
expect(result.rundown).toEqual(expected);
|
expect(result.rundown[0]).toMatchObject({
|
||||||
|
id: '2',
|
||||||
|
linkStart: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns linked events past blocks and delays', () => {
|
it('returns linked events past blocks and delays', () => {
|
||||||
@@ -505,14 +484,65 @@ describe('parseRundown() linking', () => {
|
|||||||
customFields: {},
|
customFields: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const expected: OntimeRundown = [
|
|
||||||
{ ...blankEvent, id: '1', cue: '0' },
|
|
||||||
{ id: 'delay1', type: SupportedEvent.Delay, duration: 0 },
|
|
||||||
{ ...blankEvent, id: '2', cue: '1', linkStart: '1' },
|
|
||||||
{ id: 'block1', type: SupportedEvent.Block, title: '' },
|
|
||||||
{ ...blankEvent, id: '3', cue: '2', linkStart: '2' },
|
|
||||||
];
|
|
||||||
const result = parseRundown(data);
|
const result = parseRundown(data);
|
||||||
expect(result.rundown).toEqual(expected);
|
expect(result.rundown[0]).toMatchObject({
|
||||||
|
id: '1',
|
||||||
|
cue: '1',
|
||||||
|
});
|
||||||
|
// skip delay
|
||||||
|
expect(result.rundown[2]).toMatchObject({
|
||||||
|
id: '2',
|
||||||
|
cue: '2',
|
||||||
|
linkStart: '1',
|
||||||
|
});
|
||||||
|
// skip block
|
||||||
|
expect(result.rundown[4]).toMatchObject({
|
||||||
|
id: '3',
|
||||||
|
cue: '3',
|
||||||
|
linkStart: '2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRundown() migrations', () => {
|
||||||
|
const legacyEvent = {
|
||||||
|
id: '1',
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
cue: '',
|
||||||
|
title: '',
|
||||||
|
note: '',
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: 'time-to-end',
|
||||||
|
linkStart: null,
|
||||||
|
timeStrategy: TimeStrategy.LockDuration,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 0,
|
||||||
|
duration: 0,
|
||||||
|
isPublic: false,
|
||||||
|
skip: false,
|
||||||
|
colour: '',
|
||||||
|
revision: 0,
|
||||||
|
timeWarning: 120000,
|
||||||
|
timeDanger: 60000,
|
||||||
|
custom: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('migrates an event with time-to-end', () => {
|
||||||
|
const result = parseRundown({ rundown: [legacyEvent] as OntimeRundown });
|
||||||
|
expect(result.rundown[0]).toMatchObject({
|
||||||
|
id: '1',
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates an event without time-to-end', () => {
|
||||||
|
const countdownEvent = { ...legacyEvent, timerType: TimerType.CountDown };
|
||||||
|
const result = parseRundown({ rundown: [countdownEvent] as OntimeRundown });
|
||||||
|
expect(result.rundown[0]).toMatchObject({
|
||||||
|
id: '1',
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
isTimeToEnd: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
CustomFields,
|
CustomFields,
|
||||||
DatabaseModel,
|
DatabaseModel,
|
||||||
EventCustomFields,
|
EventCustomFields,
|
||||||
|
isOntimeBlock,
|
||||||
LogOrigin,
|
LogOrigin,
|
||||||
OntimeBlock,
|
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
OntimeRundown,
|
OntimeRundown,
|
||||||
SupportedEvent,
|
SupportedEvent,
|
||||||
@@ -110,6 +110,7 @@ export const parseExcel = (
|
|||||||
// options: booleans
|
// options: booleans
|
||||||
let isPublicIndex: number | null = null;
|
let isPublicIndex: number | null = null;
|
||||||
let skipIndex: number | null = null;
|
let skipIndex: number | null = null;
|
||||||
|
let isTimeToEndIndex: number | null = null;
|
||||||
|
|
||||||
let linkStartIndex: number | null = null;
|
let linkStartIndex: number | null = null;
|
||||||
|
|
||||||
@@ -159,6 +160,10 @@ export const parseExcel = (
|
|||||||
titleIndex = col;
|
titleIndex = col;
|
||||||
rundownMetadata['title'] = { row, col };
|
rundownMetadata['title'] = { row, col };
|
||||||
},
|
},
|
||||||
|
[importMap.isTimeToEnd]: (row: number, col: number) => {
|
||||||
|
isTimeToEndIndex = col;
|
||||||
|
rundownMetadata['isTimeToEnd'] = { row, col };
|
||||||
|
},
|
||||||
[importMap.isPublic]: (row: number, col: number) => {
|
[importMap.isPublic]: (row: number, col: number) => {
|
||||||
isPublicIndex = col;
|
isPublicIndex = col;
|
||||||
rundownMetadata['isPublic'] = { row, col };
|
rundownMetadata['isPublic'] = { row, col };
|
||||||
@@ -226,6 +231,8 @@ export const parseExcel = (
|
|||||||
event.duration = parseExcelDate(column);
|
event.duration = parseExcelDate(column);
|
||||||
} else if (j === cueIndex) {
|
} else if (j === cueIndex) {
|
||||||
event.cue = makeString(column, '');
|
event.cue = makeString(column, '');
|
||||||
|
} else if (j === isTimeToEndIndex) {
|
||||||
|
event.isTimeToEnd = parseBooleanString(column);
|
||||||
} else if (j === isPublicIndex) {
|
} else if (j === isPublicIndex) {
|
||||||
event.isPublic = parseBooleanString(column);
|
event.isPublic = parseBooleanString(column);
|
||||||
} else if (j === skipIndex) {
|
} else if (j === skipIndex) {
|
||||||
@@ -273,8 +280,8 @@ export const parseExcel = (
|
|||||||
const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length;
|
const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length;
|
||||||
if (keysFound > 0) {
|
if (keysFound > 0) {
|
||||||
// if it is a Block type drop all other filed
|
// if it is a Block type drop all other filed
|
||||||
if (event.type === SupportedEvent.Block) {
|
if (isOntimeBlock(event)) {
|
||||||
rundown.push({ type: event.type, id: event.id, title: event.title } as OntimeBlock);
|
rundown.push({ type: event.type, id: event.id, title: event.title });
|
||||||
} else {
|
} else {
|
||||||
if (timerTypeIndex === null) {
|
if (timerTypeIndex === null) {
|
||||||
event.timerType = TimerType.CountDown;
|
event.timerType = TimerType.CountDown;
|
||||||
@@ -360,7 +367,6 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
|
|||||||
patchEvent?.duration ?? originalEvent.duration,
|
patchEvent?.duration ?? originalEvent.duration,
|
||||||
patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
|
patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
|
||||||
);
|
);
|
||||||
const maybeLinkStart = patchEvent.linkStart !== undefined ? patchEvent.linkStart : originalEvent.linkStart;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: originalEvent.id,
|
id: originalEvent.id,
|
||||||
@@ -370,9 +376,10 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
|
|||||||
timeEnd,
|
timeEnd,
|
||||||
duration,
|
duration,
|
||||||
timeStrategy,
|
timeStrategy,
|
||||||
linkStart: validateLinkStart(maybeLinkStart),
|
linkStart: validateLinkStart(patchEvent.linkStart),
|
||||||
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
|
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
|
||||||
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
|
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
|
||||||
|
isTimeToEnd: typeof patchEvent.isTimeToEnd === 'boolean' ? patchEvent.isTimeToEnd : originalEvent.isTimeToEnd,
|
||||||
isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic,
|
isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic,
|
||||||
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
||||||
note: makeString(patchEvent.note, originalEvent.note),
|
note: makeString(patchEvent.note, originalEvent.note),
|
||||||
@@ -389,17 +396,19 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
|
|||||||
/**
|
/**
|
||||||
* @description Enforces formatting for events
|
* @description Enforces formatting for events
|
||||||
* @param {object} eventArgs - attributes of event
|
* @param {object} eventArgs - attributes of event
|
||||||
* @param cueFallback
|
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
|
||||||
* @returns {object|null} - formatted object or null in case is invalid
|
* @returns {object|null} - formatted object or null in case is invalid
|
||||||
*/
|
*/
|
||||||
export const createEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: string): OntimeEvent | null => {
|
export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number | string): OntimeEvent | null => {
|
||||||
if (Object.keys(eventArgs).length === 0) {
|
if (Object.keys(eventArgs).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
|
||||||
|
|
||||||
const baseEvent = {
|
const baseEvent = {
|
||||||
id: eventArgs?.id ?? generateId(),
|
id: eventArgs?.id ?? generateId(),
|
||||||
cue: cueFallback,
|
cue,
|
||||||
...eventDef,
|
...eventDef,
|
||||||
};
|
};
|
||||||
const event = createPatch(baseEvent, eventArgs);
|
const event = createPatch(baseEvent, eventArgs);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
OscSubscription,
|
OscSubscription,
|
||||||
ProjectData,
|
ProjectData,
|
||||||
Settings,
|
Settings,
|
||||||
|
TimerType,
|
||||||
URLPreset,
|
URLPreset,
|
||||||
ViewSettings,
|
ViewSettings,
|
||||||
isOntimeBlock,
|
isOntimeBlock,
|
||||||
@@ -19,13 +20,7 @@ import {
|
|||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import { customFieldLabelToKey, generateId, getErrorMessage, isAlphanumericWithSpace } from 'ontime-utils';
|
||||||
customFieldLabelToKey,
|
|
||||||
generateId,
|
|
||||||
getErrorMessage,
|
|
||||||
getLastEvent,
|
|
||||||
isAlphanumericWithSpace,
|
|
||||||
} from 'ontime-utils';
|
|
||||||
|
|
||||||
import { dbModel } from '../models/dataModel.js';
|
import { dbModel } from '../models/dataModel.js';
|
||||||
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
|
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
|
||||||
@@ -52,6 +47,7 @@ export function parseRundown(
|
|||||||
|
|
||||||
const rundown: OntimeRundown = [];
|
const rundown: OntimeRundown = [];
|
||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
|
let previousId: string | null = null;
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
|
|
||||||
for (const event of data.rundown) {
|
for (const event of data.rundown) {
|
||||||
@@ -64,12 +60,13 @@ export function parseRundown(
|
|||||||
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
|
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
|
||||||
|
|
||||||
if (isOntimeEvent(event)) {
|
if (isOntimeEvent(event)) {
|
||||||
|
const maybeEvent = runEventMigrations({ ...event, id });
|
||||||
|
|
||||||
if (event.linkStart) {
|
if (event.linkStart) {
|
||||||
const prevId = getLastEvent(rundown).lastEvent?.id ?? null;
|
maybeEvent.linkStart = previousId;
|
||||||
event.linkStart = prevId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newEvent = createEvent(event, eventIndex.toString());
|
newEvent = createEvent(maybeEvent, eventIndex);
|
||||||
// skip if event is invalid
|
// skip if event is invalid
|
||||||
if (newEvent == null) {
|
if (newEvent == null) {
|
||||||
emitError?.('Skipping event without payload');
|
emitError?.('Skipping event without payload');
|
||||||
@@ -84,6 +81,7 @@ export function parseRundown(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousId = id;
|
||||||
eventIndex += 1;
|
eventIndex += 1;
|
||||||
} else if (isOntimeDelay(event)) {
|
} else if (isOntimeDelay(event)) {
|
||||||
newEvent = { ...delayDef, duration: event.duration, id };
|
newEvent = { ...delayDef, duration: event.duration, id };
|
||||||
@@ -349,3 +347,22 @@ export function sanitiseCustomFields(data: object): CustomFields {
|
|||||||
|
|
||||||
return newCustomFields;
|
return newCustomFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time to end was moved from a TimerType to a standalone boolean
|
||||||
|
* Released as part of v3.10.0
|
||||||
|
*/
|
||||||
|
function migrateTimeToEnd(event: any): OntimeEvent {
|
||||||
|
if (event.timerType === 'time-to-end') {
|
||||||
|
event.timerType = TimerType.CountDown;
|
||||||
|
event.isTimeToEnd = true;
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutating function migrates event data entries
|
||||||
|
*/
|
||||||
|
function runEventMigrations(event: any): OntimeEvent {
|
||||||
|
return migrateTimeToEnd(event);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
test('cuesheet displays events and exports csv', async ({ page }) => {
|
test('cuesheet displays events', async ({ page }) => {
|
||||||
// same elements in cuesheet
|
// same elements in cuesheet
|
||||||
await page.goto('http://localhost:4001/cuesheet');
|
await page.goto('http://localhost:4001/cuesheet');
|
||||||
await page.getByText('Eurovision Song Contest').click();
|
await expect(page.getByText('Eurovision Song Contest')).toBeVisible();
|
||||||
await page.getByRole('cell', { name: 'Lunch break' }).click();
|
await expect(page.getByRole('row', { name: 'Lunch break' })).toBeVisible();
|
||||||
await page.getByRole('cell', { name: 'Albania' }).click();
|
await expect(page.getByRole('row', { name: 'Afternoon break' })).toBeVisible();
|
||||||
await page.getByRole('cell', { name: 'Latvia' }).click();
|
|
||||||
await page.getByRole('cell', { name: 'Lithuania' }).click();
|
await expect(page.locator('#cuesheet')).toBeVisible();
|
||||||
|
|
||||||
|
// there should be 16 rows in the table (same as the amount of events in the rundown)
|
||||||
|
const rowCount = await page.locator('#cuesheet tbody tr').count();
|
||||||
|
expect(rowCount).toBe(16);
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -1,3 +1,4 @@
|
|||||||
|
import type { OntimeBlock, OntimeDelay, OntimeEvent } from '../../definitions/core/OntimeEvent.type.js';
|
||||||
import type { OntimeRundownEntry } from '../../definitions/core/Rundown.type.js';
|
import type { OntimeRundownEntry } from '../../definitions/core/Rundown.type.js';
|
||||||
|
|
||||||
type EventId = string;
|
type EventId = string;
|
||||||
@@ -8,3 +9,14 @@ export interface RundownCached {
|
|||||||
order: EventId[];
|
order: EventId[];
|
||||||
revision: number;
|
revision: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PatchWithId = Partial<OntimeEvent | OntimeDelay | OntimeBlock> & { id: string };
|
||||||
|
export type EventPostPayload = Partial<OntimeRundownEntry> & {
|
||||||
|
after?: string;
|
||||||
|
before?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TransientEventPayload = Partial<OntimeEvent | OntimeDelay | OntimeBlock> & {
|
||||||
|
after?: string;
|
||||||
|
before?: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export enum TimerType {
|
export enum TimerType {
|
||||||
CountDown = 'count-down',
|
CountDown = 'count-down',
|
||||||
CountUp = 'count-up',
|
CountUp = 'count-up',
|
||||||
TimeToEnd = 'time-to-end',
|
|
||||||
Clock = 'clock',
|
Clock = 'clock',
|
||||||
None = 'none',
|
None = 'none',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export enum SupportedEvent {
|
|||||||
export type OntimeBaseEvent = {
|
export type OntimeBaseEvent = {
|
||||||
type: SupportedEvent;
|
type: SupportedEvent;
|
||||||
id: string;
|
id: string;
|
||||||
after?: string; // used when creating an event to indicate its position in rundown
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OntimeDelay = OntimeBaseEvent & {
|
export type OntimeDelay = OntimeBaseEvent & {
|
||||||
@@ -29,6 +28,7 @@ export type OntimeEvent = OntimeBaseEvent & {
|
|||||||
note: string;
|
note: string;
|
||||||
endAction: EndAction;
|
endAction: EndAction;
|
||||||
timerType: TimerType;
|
timerType: TimerType;
|
||||||
|
isTimeToEnd: boolean;
|
||||||
linkStart: MaybeString; // ID of event to link to
|
linkStart: MaybeString; // ID of event to link to
|
||||||
timeStrategy: TimeStrategy;
|
timeStrategy: TimeStrategy;
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ export type {
|
|||||||
ProjectLogoResponse,
|
ProjectLogoResponse,
|
||||||
} from './api/ontime-controller/BackendResponse.type.js';
|
} from './api/ontime-controller/BackendResponse.type.js';
|
||||||
export type { QuickStartData } from './api/db/db.type.js';
|
export type { QuickStartData } from './api/db/db.type.js';
|
||||||
export type { RundownCached, NormalisedRundown } from './api/rundown-controller/BackendResponse.type.js';
|
export type {
|
||||||
|
EventPostPayload,
|
||||||
|
NormalisedRundown,
|
||||||
|
PatchWithId,
|
||||||
|
RundownCached,
|
||||||
|
TransientEventPayload,
|
||||||
|
} from './api/rundown-controller/BackendResponse.type.js';
|
||||||
|
|
||||||
// SERVER RUNTIME
|
// SERVER RUNTIME
|
||||||
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
||||||
|
|||||||
@@ -89,3 +89,5 @@ export {
|
|||||||
defaultImportMap,
|
defaultImportMap,
|
||||||
isImportMap,
|
isImportMap,
|
||||||
} from './src/feature/spreadsheet-import/spreadsheetImport.js';
|
} from './src/feature/spreadsheet-import/spreadsheetImport.js';
|
||||||
|
|
||||||
|
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ describe('isImportMap()', () => {
|
|||||||
duration: 'duration',
|
duration: 'duration',
|
||||||
cue: 'cue',
|
cue: 'cue',
|
||||||
title: 'title',
|
title: 'title',
|
||||||
|
isTimeToEnd: 'time to end',
|
||||||
isPublic: 'public',
|
isPublic: 'public',
|
||||||
skip: 'skip',
|
skip: 'skip',
|
||||||
note: 'notes',
|
note: 'notes',
|
||||||
@@ -34,6 +35,7 @@ describe('isImportMap()', () => {
|
|||||||
duration: 'duration',
|
duration: 'duration',
|
||||||
cue: 'cue',
|
cue: 'cue',
|
||||||
title: 'title',
|
title: 'title',
|
||||||
|
isTimeToEnd: 'time to end',
|
||||||
isPublic: 'public',
|
isPublic: 'public',
|
||||||
skip: 'skip',
|
skip: 'skip',
|
||||||
note: 'notes',
|
note: 'notes',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const defaultImportMap = {
|
|||||||
duration: 'duration',
|
duration: 'duration',
|
||||||
cue: 'cue',
|
cue: 'cue',
|
||||||
title: 'title',
|
title: 'title',
|
||||||
|
isTimeToEnd: 'time to end',
|
||||||
isPublic: 'public',
|
isPublic: 'public',
|
||||||
skip: 'skip',
|
skip: 'skip',
|
||||||
note: 'notes',
|
note: 'notes',
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Playback } from 'ontime-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility checks whether the playback is considered to be active
|
||||||
|
* @param state
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function isPlaybackActive(state: Playback): boolean {
|
||||||
|
return state === Playback.Play || state === Playback.Pause || state === Playback.Roll;
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@ describe('validateEndAction()', () => {
|
|||||||
|
|
||||||
describe('validateTimerType()', () => {
|
describe('validateTimerType()', () => {
|
||||||
it('recognises a string representation of an action', () => {
|
it('recognises a string representation of an action', () => {
|
||||||
const timerType = validateTimerType('time-to-end');
|
const timerType = validateTimerType('count-up');
|
||||||
expect(timerType).toBe(TimerType.TimeToEnd);
|
expect(timerType).toBe(TimerType.CountUp);
|
||||||
});
|
});
|
||||||
it('returns fallback otherwise', () => {
|
it('returns fallback otherwise', () => {
|
||||||
const emptyType = validateTimerType('', TimerType.Clock);
|
const emptyType = validateTimerType('', TimerType.Clock);
|
||||||
|
|||||||
Generated
+153
-673
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user