mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-05 15:33:59 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a2554d32a |
@@ -57,21 +57,20 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sentry/vite-plugin": "5.1.1",
|
"@sentry/vite-plugin": "^2.16.1",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^19.1.12",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "5.2.0",
|
"@vitejs/plugin-react": "4.5.1",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"sass": "^1.57.1",
|
"sass": "^1.57.1",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "7.3.1",
|
"vite": "6.3.1",
|
||||||
"vite-plugin-compression2": "2.5.1",
|
"vite-plugin-compression2": "2.2.0",
|
||||||
"vite-plugin-prismjs-plus": "1.1.0",
|
"vite-plugin-svgr": "4.3.0",
|
||||||
"vite-plugin-svgr": "4.5.0",
|
"vite-tsconfig-paths": "5.1.4",
|
||||||
"vite-tsconfig-paths": "6.1.1",
|
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export const RUNDOWN = ['rundown'];
|
|||||||
export const RUNTIME = ['runtimeStore'];
|
export const RUNTIME = ['runtimeStore'];
|
||||||
export const URL_PRESETS = ['urlpresets'];
|
export const URL_PRESETS = ['urlpresets'];
|
||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
export const CSS_OVERRIDE = ['cssOverride'];
|
|
||||||
export const CLIENT_LIST = ['clientList'];
|
export const CLIENT_LIST = ['clientList'];
|
||||||
export const REPORT = ['report'];
|
export const REPORT = ['report'];
|
||||||
export const TRANSLATION = ['translation'];
|
export const TRANSLATION = ['translation'];
|
||||||
@@ -22,7 +21,9 @@ export const TRANSLATION = ['translation'];
|
|||||||
export const apiEntryUrl = `${serverURL}/data`;
|
export const apiEntryUrl = `${serverURL}/data`;
|
||||||
|
|
||||||
const userAssetsPath = 'user';
|
const userAssetsPath = 'user';
|
||||||
|
const cssOverridePath = 'styles/override.css';
|
||||||
const customTranslationsPath = 'translations/translations.json';
|
const customTranslationsPath = 'translations/translations.json';
|
||||||
|
|
||||||
|
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||||
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||||
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
|
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
|
||||||
|
|||||||
@@ -153,20 +153,6 @@ export async function postCloneEntry(
|
|||||||
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
|
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PastePayload = {
|
|
||||||
entryIds: EntryId[];
|
|
||||||
sourceRundownId: string;
|
|
||||||
afterId?: EntryId;
|
|
||||||
beforeId?: EntryId;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP request for pasting entries into the active rundown
|
|
||||||
*/
|
|
||||||
export async function postPasteEntries(rundownId: RundownId, data: PastePayload): Promise<AxiosResponse<Rundown>> {
|
|
||||||
return axios.post(`${rundownPath}/${rundownId}/paste`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request for grouping a list of entries into a group
|
* HTTP request for grouping a list of entries into a group
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { getCSSContents } from '../api/assets';
|
|
||||||
import { CSS_OVERRIDE } from '../api/constants';
|
|
||||||
|
|
||||||
export default function useCssOverride(enabled: boolean) {
|
|
||||||
const { data, status } = useQuery({
|
|
||||||
queryKey: CSS_OVERRIDE,
|
|
||||||
queryFn: ({ signal }) => getCSSContents({ signal }),
|
|
||||||
staleTime: MILLIS_PER_HOUR,
|
|
||||||
enabled
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: data ?? '', status
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -35,13 +35,11 @@ import { useCallback, useMemo } from 'react';
|
|||||||
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
|
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
import {
|
import {
|
||||||
PastePayload,
|
|
||||||
ReorderEntry,
|
ReorderEntry,
|
||||||
deleteEntries,
|
deleteEntries,
|
||||||
patchReorderEntry,
|
patchReorderEntry,
|
||||||
postAddEntry,
|
postAddEntry,
|
||||||
postCloneEntry,
|
postCloneEntry,
|
||||||
postPasteEntries,
|
|
||||||
putBatchEditEvents,
|
putBatchEditEvents,
|
||||||
putEditEntry,
|
putEditEntry,
|
||||||
requestApplyDelay,
|
requestApplyDelay,
|
||||||
@@ -272,35 +270,6 @@ export const useEntryActions = () => {
|
|||||||
[cloneEntryMutation, getCurrentRundownData],
|
[cloneEntryMutation, getCurrentRundownData],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls mutation to paste entries
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
const { mutateAsync: pasteEntriesMutation } = useMutation({
|
|
||||||
mutationFn: ([rundownId, data]: [string, PastePayload]) => postPasteEntries(rundownId, data),
|
|
||||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
|
||||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paste entries from copy store into the active rundown
|
|
||||||
*/
|
|
||||||
const pasteEntries = useCallback(
|
|
||||||
async (data: PastePayload) => {
|
|
||||||
try {
|
|
||||||
const rundownId = getCurrentRundownData()?.id;
|
|
||||||
if (!rundownId) {
|
|
||||||
throw new Error('Rundown not initialised');
|
|
||||||
}
|
|
||||||
|
|
||||||
await pasteEntriesMutation([rundownId, data]);
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error pasting entries', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[pasteEntriesMutation, getCurrentRundownData],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to update existing entry
|
* Calls mutation to update existing entry
|
||||||
* @private
|
* @private
|
||||||
@@ -950,7 +919,6 @@ export const useEntryActions = () => {
|
|||||||
clone,
|
clone,
|
||||||
deleteEntry,
|
deleteEntry,
|
||||||
deleteAllEntries,
|
deleteAllEntries,
|
||||||
pasteEntries,
|
|
||||||
ungroup,
|
ungroup,
|
||||||
getEntryById,
|
getEntryById,
|
||||||
groupEntries,
|
groupEntries,
|
||||||
@@ -967,7 +935,6 @@ export const useEntryActions = () => {
|
|||||||
clone,
|
clone,
|
||||||
deleteEntry,
|
deleteEntry,
|
||||||
deleteAllEntries,
|
deleteAllEntries,
|
||||||
pasteEntries,
|
|
||||||
ungroup,
|
ungroup,
|
||||||
getEntryById,
|
getEntryById,
|
||||||
groupEntries,
|
groupEntries,
|
||||||
|
|||||||
@@ -202,20 +202,20 @@ export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
|
|||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
mode: state.offset.mode,
|
mode: state.offset.mode,
|
||||||
groupExpectedEnd: state.offset.expectedGroupEnd,
|
groupExpectedEnd: state.offset.expectedGroupEnd,
|
||||||
actualGroupStart: state.rundown.actualGroupStart,
|
// we can force these numbers to 0 for this use case to avoid null checks
|
||||||
|
actualGroupStart: state.rundown.actualGroupStart ?? 0,
|
||||||
currentDay: state.rundown.currentDay ?? 0,
|
currentDay: state.rundown.currentDay ?? 0,
|
||||||
playback: state.timer.playback,
|
playback: state.timer.playback,
|
||||||
phase: state.timer.phase,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
mode: state.offset.mode,
|
mode: state.offset.mode,
|
||||||
actualStart: state.rundown.actualStart,
|
// we can force these numbers to 0 for this use case to avoid null checks
|
||||||
plannedStart: state.rundown.plannedStart,
|
actualStart: state.rundown.actualStart ?? 0,
|
||||||
|
plannedStart: state.rundown.plannedStart ?? 0,
|
||||||
currentDay: state.rundown.currentDay ?? 0,
|
currentDay: state.rundown.currentDay ?? 0,
|
||||||
playback: state.timer.playback,
|
playback: state.timer.playback,
|
||||||
phase: state.timer.phase,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/* ======================= View specific subscriptions ======================= */
|
/* ======================= View specific subscriptions ======================= */
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
type EntryCopyStore = {
|
type EntryCopyStore = {
|
||||||
entryIds: Set<string>;
|
entryCopyId: string | null;
|
||||||
sourceRundownId: string | null;
|
entryCopyMode: 'copy' | 'cut';
|
||||||
setCopyEntries: (ids: string[], rundownId: string) => void;
|
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
|
||||||
clearCopy: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
|
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
|
||||||
entryIds: new Set(),
|
entryCopyId: null,
|
||||||
sourceRundownId: null,
|
entryCopyMode: 'copy',
|
||||||
setCopyEntries: (ids: string[], rundownId: string) =>
|
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
|
||||||
set({ entryIds: new Set(ids), sourceRundownId: rundownId }),
|
set({ entryCopyId, entryCopyMode: mode }),
|
||||||
clearCopy: () => set({ entryIds: new Set(), sourceRundownId: null }),
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { isProduction, websocketUrl } from '../../externals';
|
|||||||
import {
|
import {
|
||||||
APP_SETTINGS,
|
APP_SETTINGS,
|
||||||
CLIENT_LIST,
|
CLIENT_LIST,
|
||||||
CSS_OVERRIDE,
|
|
||||||
CUSTOM_FIELDS,
|
CUSTOM_FIELDS,
|
||||||
PROJECT_DATA,
|
PROJECT_DATA,
|
||||||
REPORT,
|
REPORT,
|
||||||
@@ -200,9 +199,6 @@ export const connectSocket = () => {
|
|||||||
case RefetchKey.ViewSettings:
|
case RefetchKey.ViewSettings:
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
|
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
|
||||||
break;
|
break;
|
||||||
case RefetchKey.CssOverride:
|
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: CSS_OVERRIDE });
|
|
||||||
break;
|
|
||||||
case RefetchKey.Translation:
|
case RefetchKey.Translation:
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
|
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
|
||||||
break;
|
break;
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import Prism from 'virtual:prismjs';
|
import Prism from 'prismjs/components/prism-core';
|
||||||
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
|
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
|
||||||
import Editor from 'react-simple-code-editor';
|
import Editor from 'react-simple-code-editor';
|
||||||
import 'prismjs/components/prism-css';
|
import 'prismjs/components/prism-css';
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
declare module 'prismjs/components/prism-core' {
|
||||||
|
export * from 'prismjs';
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'prismjs/components/prism-css';
|
||||||
@@ -208,28 +208,26 @@ export function MetadataTimes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GroupTimes() {
|
function GroupTimes() {
|
||||||
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
|
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView();
|
||||||
const currentGroupId = useCurrentGroupId();
|
const currentGroupId = useCurrentGroupId();
|
||||||
const group = useEntry(currentGroupId) as OntimeGroup | null;
|
const group = useEntry(currentGroupId) as OntimeGroup | null;
|
||||||
|
|
||||||
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback) ;
|
const active = isPlaybackActive(playback);
|
||||||
|
|
||||||
// the group end time does not encode any day offsets so it is calculated with group start time and duration
|
// the group end time dose not encode any day offsets so it is calculated with group start time and duration
|
||||||
const plannedGroupEnd = (() => {
|
const plannedGroupEnd = (() => {
|
||||||
if (!hasRunningTimer) return null;
|
if (!active) return null;
|
||||||
if (!group || group.timeStart === null) return null;
|
if (!group || group.timeStart === null) return null;
|
||||||
const normalizedClock = clock + currentDay * dayInMs;
|
const normalizedClock = clock + currentDay * dayInMs;
|
||||||
|
|
||||||
if (mode === OffsetMode.Absolute) {
|
return mode === OffsetMode.Absolute
|
||||||
return group.timeStart + group.duration - normalizedClock;
|
? group.timeStart + group.duration - normalizedClock
|
||||||
}
|
: actualGroupStart + group.duration - normalizedClock;
|
||||||
if (actualGroupStart === null) return null;
|
|
||||||
return actualGroupStart + group.duration - normalizedClock;
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const plannedTimeUntilGroupEnd = formatDueTime(plannedGroupEnd, 3, TimerType.CountDown);
|
const plannedTimeUntilGroupEnd = formatDueTime(plannedGroupEnd, 3, TimerType.CountDown);
|
||||||
|
|
||||||
const expectedGroupEnd = hasRunningTimer && groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
|
const expectedGroupEnd = groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
|
||||||
const expectedTimeUntilGroupEnd = formatDueTime(expectedGroupEnd, 3, TimerType.CountDown);
|
const expectedTimeUntilGroupEnd = formatDueTime(expectedGroupEnd, 3, TimerType.CountDown);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -240,7 +238,7 @@ function GroupTimes() {
|
|||||||
<span
|
<span
|
||||||
className={cx([
|
className={cx([
|
||||||
style.time,
|
style.time,
|
||||||
(!group || !hasRunningTimer) && style.muted,
|
(!group || !active) && style.muted,
|
||||||
plannedTimeUntilGroupEnd === 'due' && style.dueTime,
|
plannedTimeUntilGroupEnd === 'due' && style.dueTime,
|
||||||
])}
|
])}
|
||||||
>
|
>
|
||||||
@@ -252,7 +250,7 @@ function GroupTimes() {
|
|||||||
<span
|
<span
|
||||||
className={cx([
|
className={cx([
|
||||||
style.time,
|
style.time,
|
||||||
expectedGroupEnd === null && style.muted,
|
!groupExpectedEnd && style.muted,
|
||||||
expectedTimeUntilGroupEnd === 'due' && style.dueTime,
|
expectedTimeUntilGroupEnd === 'due' && style.dueTime,
|
||||||
])}
|
])}
|
||||||
>
|
>
|
||||||
@@ -264,27 +262,25 @@ function GroupTimes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FlagTimes() {
|
function FlagTimes() {
|
||||||
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
|
const { clock, mode, actualStart, plannedStart, playback, currentDay } = useFlagTimerOverView();
|
||||||
const { id, expectedStart } = useNextFlag();
|
const { id, expectedStart } = useNextFlag();
|
||||||
const entry = useEntry(id) as OntimeEvent | null;
|
const entry = useEntry(id) as OntimeEvent | null;
|
||||||
|
|
||||||
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
|
const active = isPlaybackActive(playback);
|
||||||
|
|
||||||
const plannedFlagStart = (() => {
|
const plannedFlagStart = (() => {
|
||||||
if (!hasRunningTimer) return null;
|
if (!active) return null;
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
const normalizedTimeStart = entry.timeStart + entry.dayOffset * dayInMs;
|
const normalizedTimeStart = entry.timeStart + entry.dayOffset * dayInMs;
|
||||||
const normalizedClock = clock + currentDay * dayInMs;
|
const normalizedClock = clock + currentDay * dayInMs;
|
||||||
if (mode === OffsetMode.Absolute) {
|
return mode === OffsetMode.Absolute
|
||||||
return normalizedTimeStart - normalizedClock;
|
? normalizedTimeStart - normalizedClock
|
||||||
}
|
: normalizedTimeStart + actualStart - plannedStart - normalizedClock;
|
||||||
if (actualStart === null || plannedStart === null) return null;
|
|
||||||
return normalizedTimeStart + actualStart - plannedStart - normalizedClock;
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const plannedTimeUntilDisplay = formatDueTime(plannedFlagStart, 3, TimerType.CountDown);
|
const plannedTimeUntilDisplay = formatDueTime(plannedFlagStart, 3, TimerType.CountDown);
|
||||||
|
|
||||||
const expectedTimeUntil = hasRunningTimer && expectedStart !== null ? expectedStart - clock : null;
|
const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null;
|
||||||
const expectedTimeUntilDisplay = formatDueTime(expectedTimeUntil, 3, TimerType.CountDown);
|
const expectedTimeUntilDisplay = formatDueTime(expectedTimeUntil, 3, TimerType.CountDown);
|
||||||
|
|
||||||
const title = entry?.title ?? null;
|
const title = entry?.title ?? null;
|
||||||
@@ -298,7 +294,7 @@ function FlagTimes() {
|
|||||||
data-testid='flag-plannedStart'
|
data-testid='flag-plannedStart'
|
||||||
className={cx([
|
className={cx([
|
||||||
style.time,
|
style.time,
|
||||||
(!entry || !hasRunningTimer) && style.muted,
|
(!entry || !active) && style.muted,
|
||||||
plannedTimeUntilDisplay === 'due' && style.dueTime,
|
plannedTimeUntilDisplay === 'due' && style.dueTime,
|
||||||
])}
|
])}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { TbFlagFilled } from 'react-icons/tb';
|
|||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
|
||||||
|
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
||||||
import { RundownMetadataObject, lastMetadataKey } from '../../common/utils/rundownMetadata';
|
import { RundownMetadataObject, lastMetadataKey } from '../../common/utils/rundownMetadata';
|
||||||
import { AppMode } from '../../ontimeConfig';
|
import { AppMode } from '../../ontimeConfig';
|
||||||
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
||||||
@@ -63,7 +64,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
|
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
|
||||||
|
|
||||||
const entryActions = useEntryActionsContext();
|
const entryActions = useEntryActionsContext();
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
|
||||||
|
|
||||||
// cursor
|
// cursor
|
||||||
const { editorMode } = useEditorFollowMode();
|
const { editorMode } = useEditorFollowMode();
|
||||||
@@ -104,10 +105,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useRundownKeyboard({
|
useRundownKeyboard({
|
||||||
cursor,
|
cursor,
|
||||||
rundownId: id,
|
|
||||||
selectedEvents,
|
|
||||||
commands,
|
commands,
|
||||||
clearSelectedEvents,
|
clearSelectedEvents,
|
||||||
|
setEntryCopyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// DND handlers
|
// DND handlers
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ function EventEditorEmpty() {
|
|||||||
<Kbd>C</Kbd>
|
<Kbd>C</Kbd>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Cut selected entry</td>
|
||||||
|
<td>
|
||||||
|
<Kbd>{deviceMod}</Kbd>
|
||||||
|
<AuxKey>+</AuxKey>
|
||||||
|
<Kbd>X</Kbd>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Paste above</td>
|
<td>Paste above</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function useRundownCommands({
|
|||||||
selectEntry: applySelection,
|
selectEntry: applySelection,
|
||||||
handleCollapseGroup,
|
handleCollapseGroup,
|
||||||
}: UseRundownCommandsOptions) {
|
}: UseRundownCommandsOptions) {
|
||||||
const { addEntry, clone, deleteEntry, move, pasteEntries } = entryActions;
|
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
|
||||||
|
|
||||||
const deleteAtCursor = useCallback(
|
const deleteAtCursor = useCallback(
|
||||||
(cursor: string | null) => {
|
(cursor: string | null) => {
|
||||||
@@ -40,21 +40,52 @@ export function useRundownCommands({
|
|||||||
[entries, flatOrder, deleteEntry, applySelection],
|
[entries, flatOrder, deleteEntry, applySelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pasteAtCursor = useCallback(
|
const insertCopyAtId = useCallback(
|
||||||
(cursor: EntryId | null, above = false) => {
|
(atId: EntryId | null, above = false) => {
|
||||||
const { entryIds, sourceRundownId } = useEntryCopy.getState();
|
// lazily get the value from the store
|
||||||
if (entryIds.size === 0 || !sourceRundownId) {
|
const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
|
||||||
|
if (entryCopyId === null || !entries[entryCopyId]) {
|
||||||
|
// we cant clone without selection
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pasteEntries({
|
let normalisedAtId = atId;
|
||||||
entryIds: Array.from(entryIds),
|
|
||||||
sourceRundownId,
|
const elementToCopy = entries[entryCopyId];
|
||||||
afterId: above ? undefined : (cursor ?? undefined),
|
const refElement = atId ? entries[atId] : undefined;
|
||||||
beforeId: above ? (cursor ?? undefined) : undefined,
|
|
||||||
|
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
|
||||||
|
normalisedAtId = refElement.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entryCopyMode === 'cut') {
|
||||||
|
if (!normalisedAtId) {
|
||||||
|
const firstId = flatOrder[0];
|
||||||
|
if (!firstId || firstId === entryCopyId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reorderEntry(entryCopyId, firstId, 'before')
|
||||||
|
.then(() => setEntryCopyId(null))
|
||||||
|
.catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (normalisedAtId === entryCopyId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const placement = above ? 'before' : 'after';
|
||||||
|
reorderEntry(entryCopyId, normalisedAtId, placement)
|
||||||
|
.then(() => setEntryCopyId(null))
|
||||||
|
.catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(entryCopyId, {
|
||||||
|
after: above ? undefined : (normalisedAtId ?? undefined),
|
||||||
|
// if we don't have a cursor add the new event on top
|
||||||
|
before: above ? (normalisedAtId ?? undefined) : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[pasteEntries],
|
[entries, flatOrder, clone, reorderEntry],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,7 +217,7 @@ export function useRundownCommands({
|
|||||||
return {
|
return {
|
||||||
cloneEntry,
|
cloneEntry,
|
||||||
deleteAtCursor,
|
deleteAtCursor,
|
||||||
pasteAtCursor,
|
insertCopyAtId,
|
||||||
insertAtId,
|
insertAtId,
|
||||||
selectGroup,
|
selectGroup,
|
||||||
selectEntry,
|
selectEntry,
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { useEventSelection } from '../useEventSelection';
|
|||||||
|
|
||||||
interface UseRundownKeyboardOptions {
|
interface UseRundownKeyboardOptions {
|
||||||
cursor: EntryId | null;
|
cursor: EntryId | null;
|
||||||
rundownId: string | undefined;
|
|
||||||
selectedEvents: Set<EntryId>;
|
|
||||||
commands: {
|
commands: {
|
||||||
selectEntry: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
|
selectEntry: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
|
||||||
selectGroup: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
|
selectGroup: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
|
||||||
@@ -17,9 +15,10 @@ interface UseRundownKeyboardOptions {
|
|||||||
moveEntry: (cursor: EntryId | null, direction: 'up' | 'down') => void;
|
moveEntry: (cursor: EntryId | null, direction: 'up' | 'down') => void;
|
||||||
deleteAtCursor: (cursor: EntryId | null) => void;
|
deleteAtCursor: (cursor: EntryId | null) => void;
|
||||||
insertAtId: (patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: EntryId | null, above?: boolean) => void;
|
insertAtId: (patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: EntryId | null, above?: boolean) => void;
|
||||||
pasteAtCursor: (cursor: EntryId | null, above?: boolean) => void;
|
insertCopyAtId: (atId: EntryId | null, above?: boolean) => void;
|
||||||
};
|
};
|
||||||
clearSelectedEvents: () => void;
|
clearSelectedEvents: () => void;
|
||||||
|
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,10 +37,9 @@ function isEditableElement(target: EventTarget | null): boolean {
|
|||||||
|
|
||||||
export function useRundownKeyboard({
|
export function useRundownKeyboard({
|
||||||
cursor,
|
cursor,
|
||||||
rundownId,
|
|
||||||
selectedEvents,
|
|
||||||
commands,
|
commands,
|
||||||
clearSelectedEvents,
|
clearSelectedEvents,
|
||||||
|
setEntryCopyId,
|
||||||
}: UseRundownKeyboardOptions) {
|
}: UseRundownKeyboardOptions) {
|
||||||
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
||||||
|
|
||||||
@@ -140,7 +138,7 @@ export function useRundownKeyboard({
|
|||||||
'Escape',
|
'Escape',
|
||||||
() => {
|
() => {
|
||||||
clearSelectedEvents();
|
clearSelectedEvents();
|
||||||
useEntryCopy.getState().clearCopy();
|
setEntryCopyId(null);
|
||||||
},
|
},
|
||||||
{ preventDefault: true, usePhysicalKeys: true },
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
],
|
],
|
||||||
@@ -194,25 +192,33 @@ export function useRundownKeyboard({
|
|||||||
[
|
[
|
||||||
'mod + C',
|
'mod + C',
|
||||||
(event) => {
|
(event) => {
|
||||||
if (cursor === null || isEditableElement(event.target) || !rundownId) {
|
if (cursor === null || isEditableElement(event.target)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setEntryCopyId(cursor);
|
||||||
// if multiple events are selected, copy all of them; otherwise copy the cursor entry
|
},
|
||||||
const ids = selectedEvents.size > 1 ? Array.from(selectedEvents) : [cursor];
|
{ usePhysicalKeys: true },
|
||||||
useEntryCopy.getState().setCopyEntries(ids, rundownId);
|
],
|
||||||
|
[
|
||||||
|
'mod + X',
|
||||||
|
(event) => {
|
||||||
|
if (cursor === null || isEditableElement(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
setEntryCopyId(cursor, 'cut');
|
||||||
},
|
},
|
||||||
{ usePhysicalKeys: true },
|
{ usePhysicalKeys: true },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'mod + V',
|
'mod + V',
|
||||||
(event) => {
|
(event) => {
|
||||||
if (isEditableElement(event.target) || useEntryCopy.getState().entryIds.size === 0) {
|
if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
commands.pasteAtCursor(cursor);
|
commands.insertCopyAtId(cursor);
|
||||||
},
|
},
|
||||||
{ usePhysicalKeys: true },
|
{ usePhysicalKeys: true },
|
||||||
],
|
],
|
||||||
@@ -220,11 +226,11 @@ export function useRundownKeyboard({
|
|||||||
[
|
[
|
||||||
'mod + shift + V',
|
'mod + shift + V',
|
||||||
(event) => {
|
(event) => {
|
||||||
if (isEditableElement(event.target) || useEntryCopy.getState().entryIds.size === 0) {
|
if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
commands.pasteAtCursor(cursor, true);
|
commands.insertCopyAtId(cursor, true);
|
||||||
},
|
},
|
||||||
{ usePhysicalKeys: true },
|
{ usePhysicalKeys: true },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
outline: 1px solid $block-cursor-color;
|
outline: 1px solid $block-cursor-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.copyTarget {
|
||||||
|
outline: 1px dashed $blue-500;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag {
|
.drag {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IoCheckmarkDone, IoClose, IoReorderTwo } from 'react-icons/io5';
|
|||||||
|
|
||||||
import Button from '../../../common/components/buttons/Button';
|
import Button from '../../../common/components/buttons/Button';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import DelayInput from './DelayInput';
|
import DelayInput from './DelayInput';
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
|
|||||||
|
|
||||||
const { applyDelay, deleteEntry } = useEntryActionsContext();
|
const { applyDelay, deleteEntry } = useEntryActionsContext();
|
||||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||||
|
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attributes: dragAttributes,
|
attributes: dragAttributes,
|
||||||
@@ -59,7 +61,7 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx([style.delay, hasCursor && style.hasCursor])}
|
className={cx([style.delay, hasCursor && style.hasCursor, entryCopyId === data.id && style.copyTarget])}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={dragStyle}
|
style={dragStyle}
|
||||||
data-testid='rundown-delay'
|
data-testid='rundown-delay'
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ $skip-opacity: 0.2;
|
|||||||
outline: 1px solid $block-cursor-color;
|
outline: 1px solid $block-cursor-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.copyTarget {
|
||||||
|
outline: 2px dashed $block-cursor-color;
|
||||||
|
}
|
||||||
|
|
||||||
&.past:not(.skip) {
|
&.past:not(.skip) {
|
||||||
.timerNote,
|
.timerNote,
|
||||||
.statusElements,
|
.statusElements,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { TbFlagFilled } from 'react-icons/tb';
|
|||||||
|
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||||
@@ -105,6 +106,7 @@ export default function RundownEvent({
|
|||||||
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
|
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
|
||||||
|
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
|
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
||||||
|
|
||||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||||
|
|
||||||
@@ -246,6 +248,7 @@ export default function RundownEvent({
|
|||||||
playback && style[playback],
|
playback && style[playback],
|
||||||
isSelected && style.selected,
|
isSelected && style.selected,
|
||||||
hasCursor && style.hasCursor,
|
hasCursor && style.hasCursor,
|
||||||
|
entryCopyId === eventId && style.copyTarget,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleFocusClick = (event: MouseEvent) => {
|
const handleFocusClick = (event: MouseEvent) => {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
outline: 1px solid $block-cursor-color;
|
outline: 1px solid $block-cursor-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.copyTarget {
|
||||||
|
outline: 2px dashed $block-cursor-color;
|
||||||
|
}
|
||||||
|
|
||||||
&.expanded {
|
&.expanded {
|
||||||
margin-block: 0.5rem 0;
|
margin-block: 0.5rem 0;
|
||||||
border-radius: $block-border-radius $block-border-radius 0 0;
|
border-radius: $block-border-radius $block-border-radius 0 0;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import IconButton from '../../../common/components/buttons/IconButton';
|
|||||||
import Tag from '../../../common/components/tag/Tag';
|
import Tag from '../../../common/components/tag/Tag';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
import { getOffsetState } from '../../../common/utils/offset';
|
import { getOffsetState } from '../../../common/utils/offset';
|
||||||
import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||||
@@ -42,6 +43,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
|
|
||||||
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
|
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
||||||
|
|
||||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
||||||
{
|
{
|
||||||
@@ -131,6 +133,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
style.group,
|
style.group,
|
||||||
hasCursor && style.hasCursor,
|
hasCursor && style.hasCursor,
|
||||||
!collapsed && style.expanded,
|
!collapsed && style.expanded,
|
||||||
|
entryCopyId === data.id && style.copyTarget,
|
||||||
])}
|
])}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
onClick={handleFocusClick}
|
onClick={handleFocusClick}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
outline: 1px solid $block-cursor-color;
|
outline: 1px solid $block-cursor-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.copyTarget {
|
||||||
|
outline: 2px dashed $block-cursor-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.binder {
|
.binder {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Input from '../../../common/components/input/input/Input';
|
|||||||
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { useEventSelection } from '../useEventSelection';
|
import { useEventSelection } from '../useEventSelection';
|
||||||
@@ -30,6 +31,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
|||||||
|
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
||||||
|
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
||||||
|
|
||||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
||||||
{
|
{
|
||||||
@@ -87,6 +89,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
|||||||
className={cx([
|
className={cx([
|
||||||
style.milestone,
|
style.milestone,
|
||||||
hasCursor ? style.hasCursor : null,
|
hasCursor ? style.hasCursor : null,
|
||||||
|
entryCopyId === entryId ? style.copyTarget : null,
|
||||||
])}
|
])}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
onClick={handleFocusClick}
|
onClick={handleFocusClick}
|
||||||
|
|||||||
@@ -1,39 +1,35 @@
|
|||||||
import { PropsWithChildren, Suspense } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import useCssOverride from '../common/hooks-query/useCssOverride';
|
import { overrideStylesURL } from '../common/api/constants';
|
||||||
import useViewSettings from '../common/hooks-query/useViewSettings';
|
import useViewSettings from '../common/hooks-query/useViewSettings';
|
||||||
|
import { useRuntimeStylesheet } from '../common/hooks/useRuntimeStylesheet';
|
||||||
import Loader from './common/loader/Loader';
|
import Loader from './common/loader/Loader';
|
||||||
|
|
||||||
const scriptTagId = 'ontime-stylesheet-override';
|
|
||||||
|
|
||||||
function OverrideStyles() {
|
|
||||||
'use memo';
|
|
||||||
const { data: settings } = useViewSettings();
|
|
||||||
const { overrideStyles } = settings;
|
|
||||||
const { data: css } = useCssOverride(overrideStyles);
|
|
||||||
const cssBlob = URL.createObjectURL(new Blob([css], { type: 'text/css' }));
|
|
||||||
|
|
||||||
//@ts-expect-error disabled exists on link when rel='stylesheet' https://react.dev/reference/react-dom/components/link#props
|
|
||||||
return <link id={scriptTagId} rel='stylesheet' href={cssBlob} precedence='high' disabled={!overrideStyles} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ViewLoader({ children }: PropsWithChildren) {
|
export default function ViewLoader({ children }: PropsWithChildren) {
|
||||||
'use memo';
|
const { data } = useViewSettings();
|
||||||
|
const { shouldRender } = useRuntimeStylesheet(data.overrideStyles ? overrideStylesURL : undefined);
|
||||||
|
|
||||||
// we need to be able to override the background colour with the key param
|
// we need to be able to override the background colour with the key param
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const colourFromParams = searchParams.get('keyColour') ?? '#101010';
|
const colourFromParams = searchParams.get('keyColour') ?? '#101010';
|
||||||
|
|
||||||
|
// eventually we would want to leverage suspense here
|
||||||
|
// while the feature is not ready, we simply trigger a loader
|
||||||
|
// suspense would have the advantage of being triggered also by react-query
|
||||||
|
|
||||||
|
if (!shouldRender) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
|
||||||
|
<Loader />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<>
|
||||||
fallback={
|
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
|
||||||
<>
|
|
||||||
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
|
|
||||||
<Loader />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<OverrideStyles />
|
|
||||||
{children}
|
{children}
|
||||||
</Suspense>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -4,7 +4,6 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-radius: $component-border-radius-md;
|
border-radius: $component-border-radius-md;
|
||||||
text-wrap: nowrap;
|
text-wrap: nowrap;
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
],
|
],
|
||||||
"types": [
|
"types": [
|
||||||
"vite/client",
|
"vite/client",
|
||||||
"vite-plugin-svgr/client",
|
"vite-plugin-svgr/client"
|
||||||
"vite-plugin-prismjs-plus/client",
|
|
||||||
],
|
],
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { sentryVitePlugin } from '@sentry/vite-plugin';
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { compression } from 'vite-plugin-compression2';
|
import { compression } from 'vite-plugin-compression2';
|
||||||
import prismjsPlugin from 'vite-plugin-prismjs-plus';
|
|
||||||
import svgrPlugin from 'vite-plugin-svgr';
|
import svgrPlugin from 'vite-plugin-svgr';
|
||||||
|
|
||||||
import { ONTIME_VERSION } from './src/ONTIME_VERSION';
|
import { ONTIME_VERSION } from './src/ONTIME_VERSION';
|
||||||
@@ -49,11 +48,6 @@ export default defineConfig({
|
|||||||
algorithm: 'brotliCompress',
|
algorithm: 'brotliCompress',
|
||||||
exclude: /\.(html)$/, // Ontime cloud: Exclude HTML files from compression so we can change the base property at runtime
|
exclude: /\.(html)$/, // Ontime cloud: Exclude HTML files from compression so we can change the base property at runtime
|
||||||
}),
|
}),
|
||||||
prismjsPlugin({
|
|
||||||
manual: true,
|
|
||||||
languages: ['css',],
|
|
||||||
css: true
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
@@ -94,6 +88,16 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: './build',
|
outDir: './build',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
// Split vendor code
|
||||||
|
if (id.includes('node_modules')) {
|
||||||
|
return 'vendor';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export interface IAdapter {
|
export interface IAdapter {
|
||||||
shutdown: () => Promise<void>;
|
shutdown: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,17 +72,10 @@ class OscServer implements IAdapter {
|
|||||||
});
|
});
|
||||||
this.udpSocket.bind(port);
|
this.udpSocket.bind(port);
|
||||||
}
|
}
|
||||||
shutdown(): Promise<void> {
|
shutdown() {
|
||||||
logger.info(LogOrigin.Rx, 'OSC: Closing server');
|
logger.info(LogOrigin.Rx, 'OSC: Closing server');
|
||||||
const socket = this.udpSocket;
|
this.udpSocket?.close();
|
||||||
this.udpSocket = null;
|
this.udpSocket = null;
|
||||||
if (!socket) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
socket.close(() => resolve());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,25 +235,8 @@ class SocketServer implements IAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shutdown(): Promise<void> {
|
shutdown() {
|
||||||
const wss = this.wss;
|
this.wss?.close();
|
||||||
if (!wss) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
// Notify clients first so they can reconnect gracefully
|
|
||||||
for (const client of wss.clients) {
|
|
||||||
if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) {
|
|
||||||
client.close(1001, 'Server shutting down');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wss.close(() => {
|
|
||||||
this.wss = null;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ router.post('/css', validatePostCss, async (req: Request, res: Response<never |
|
|||||||
const { css } = req.body;
|
const { css } = req.body;
|
||||||
try {
|
try {
|
||||||
await writeCssFile(css);
|
await writeCssFile(css);
|
||||||
sendRefetch(RefetchKey.CssOverride);
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
@@ -35,7 +34,6 @@ router.post('/css', validatePostCss, async (req: Request, res: Response<never |
|
|||||||
router.post('/css/restore', async (_req: Request, res: Response<string | ErrorResponse>) => {
|
router.post('/css/restore', async (_req: Request, res: Response<string | ErrorResponse>) => {
|
||||||
try {
|
try {
|
||||||
await writeCssFile(defaultCss);
|
await writeCssFile(defaultCss);
|
||||||
sendRefetch(RefetchKey.CssOverride);
|
|
||||||
res.status(200).send(defaultCss);
|
res.status(200).send(defaultCss);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
|
|||||||
@@ -126,4 +126,15 @@ describe('sanitiseCustomFields()', () => {
|
|||||||
const sanitationResult = sanitiseCustomFields(customFields);
|
const sanitationResult = sanitiseCustomFields(customFields);
|
||||||
expect(sanitationResult).toStrictEqual(expectedCustomFields);
|
expect(sanitationResult).toStrictEqual(expectedCustomFields);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters keys that collide with Object prototype properties', () => {
|
||||||
|
const customFields: CustomFields = {
|
||||||
|
toString: { label: 'toString', type: 'text', colour: 'red' },
|
||||||
|
normalField: { label: 'normalField', type: 'text', colour: 'green' },
|
||||||
|
};
|
||||||
|
const sanitationResult = sanitiseCustomFields(customFields);
|
||||||
|
expect(sanitationResult).toStrictEqual({
|
||||||
|
normalField: { label: 'normalField', type: 'text', colour: 'green' },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CustomField, CustomFields, DatabaseModel } from 'ontime-types';
|
import { CustomField, CustomFields, DatabaseModel } from 'ontime-types';
|
||||||
import { checkRegex, customFieldLabelToKey } from 'ontime-utils';
|
import { checkRegex, customFieldLabelToKey, isObjectPrototypeKey } from 'ontime-utils';
|
||||||
|
|
||||||
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||||
|
|
||||||
@@ -45,6 +45,7 @@ export function sanitiseCustomFields(data: object): CustomFields {
|
|||||||
'type' in data &&
|
'type' in data &&
|
||||||
(data.type === 'text' || data.type === 'image') &&
|
(data.type === 'text' || data.type === 'image') &&
|
||||||
checkRegex.isAlphanumericWithSpace(data.label) &&
|
checkRegex.isAlphanumericWithSpace(data.label) &&
|
||||||
|
!isObjectPrototypeKey(key) &&
|
||||||
key === customFieldLabelToKey(data.label)
|
key === customFieldLabelToKey(data.label)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { body, param } from 'express-validator';
|
import { body, param } from 'express-validator';
|
||||||
import { checkRegex } from 'ontime-utils';
|
import { checkRegex, customFieldLabelToKey, isObjectPrototypeKey } from 'ontime-utils';
|
||||||
|
|
||||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ export const validateCustomField = [
|
|||||||
.trim()
|
.trim()
|
||||||
.notEmpty()
|
.notEmpty()
|
||||||
.custom((value) => {
|
.custom((value) => {
|
||||||
return checkRegex.isAlphanumericWithSpace(value);
|
return checkRegex.isAlphanumericWithSpace(value) && !isObjectPrototypeKey(customFieldLabelToKey(value));
|
||||||
}),
|
}),
|
||||||
body('type').isIn(['text', 'image']),
|
body('type').isIn(['text', 'image']),
|
||||||
body('colour').isString().trim(),
|
body('colour').isString().trim(),
|
||||||
@@ -24,7 +24,7 @@ export const validateEditCustomField = [
|
|||||||
.trim()
|
.trim()
|
||||||
.notEmpty()
|
.notEmpty()
|
||||||
.custom((value) => {
|
.custom((value) => {
|
||||||
return checkRegex.isAlphanumericWithSpace(value);
|
return checkRegex.isAlphanumericWithSpace(value) && !isObjectPrototypeKey(customFieldLabelToKey(value));
|
||||||
}),
|
}),
|
||||||
body('type').isIn(['text', 'image']),
|
body('type').isIn(['text', 'image']),
|
||||||
body('colour').isString().trim(),
|
body('colour').isString().trim(),
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import xlsx from 'xlsx';
|
|
||||||
|
|
||||||
import { demoDb } from '../../../models/demoProject.js';
|
|
||||||
import { generateExcelFile } from '../excel.service.js';
|
|
||||||
|
|
||||||
describe('generateExcelFile()', () => {
|
|
||||||
it('sanitises long worksheet names to an Excel-compatible value', () => {
|
|
||||||
const buffer = generateExcelFile(
|
|
||||||
{
|
|
||||||
...demoDb.rundowns.default,
|
|
||||||
title: 'This is a very long name with many characters and weird things: like [Main]/?*',
|
|
||||||
},
|
|
||||||
demoDb.customFields,
|
|
||||||
);
|
|
||||||
|
|
||||||
const workbook = xlsx.read(buffer, { type: 'buffer' });
|
|
||||||
const worksheetName = workbook.SheetNames[0];
|
|
||||||
|
|
||||||
expect(worksheetName).toBeDefined();
|
|
||||||
expect(worksheetName.length).toBeLessThanOrEqual(31);
|
|
||||||
expect(worksheetName).not.toMatch(/[:\\/?*3[\]]/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to default worksheet name when title is fully invalid', () => {
|
|
||||||
const buffer = generateExcelFile(
|
|
||||||
{
|
|
||||||
...demoDb.rundowns.default,
|
|
||||||
title: '[]:*?/\\',
|
|
||||||
},
|
|
||||||
demoDb.customFields,
|
|
||||||
);
|
|
||||||
|
|
||||||
const workbook = xlsx.read(buffer, { type: 'buffer' });
|
|
||||||
|
|
||||||
expect(workbook.SheetNames[0]).toBe('Rundown');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -21,18 +21,6 @@ import { rundownToTabular } from './excel.utils.js';
|
|||||||
// we keep the excel data in memory to allow the flow upload -> preview
|
// we keep the excel data in memory to allow the flow upload -> preview
|
||||||
let excelData: WorkBook = xlsx.utils.book_new();
|
let excelData: WorkBook = xlsx.utils.book_new();
|
||||||
|
|
||||||
const maxWorksheetNameLength = 31;
|
|
||||||
const invalidWorksheetCharsRegex = /[:\\/?*[\]]/g;
|
|
||||||
|
|
||||||
function getValidWorksheetName(title: string): string {
|
|
||||||
const sanitisedTitle = title.replaceAll(invalidWorksheetCharsRegex, ' ').trim().replace(/\s+/g, ' ');
|
|
||||||
|
|
||||||
const withFallback = sanitisedTitle.length > 0 ? sanitisedTitle : 'Rundown';
|
|
||||||
const truncatedTitle = withFallback.slice(0, maxWorksheetNameLength).trim();
|
|
||||||
|
|
||||||
return truncatedTitle.length > 0 ? truncatedTitle : 'Rundown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Receives and parses an excel file
|
* Receives and parses an excel file
|
||||||
* The file is deleted after being read
|
* The file is deleted after being read
|
||||||
@@ -105,8 +93,7 @@ export function generateExcelFile(rundown: Rundown, customFields: CustomFields):
|
|||||||
|
|
||||||
const workbook = xlsx.utils.book_new();
|
const workbook = xlsx.utils.book_new();
|
||||||
const worksheet = xlsx.utils.aoa_to_sheet(rundownToTabular(rundown, customFields));
|
const worksheet = xlsx.utils.aoa_to_sheet(rundownToTabular(rundown, customFields));
|
||||||
const worksheetName = getValidWorksheetName(rundown.title || 'Rundown');
|
xlsx.utils.book_append_sheet(workbook, worksheet, rundown.title || 'Rundown');
|
||||||
xlsx.utils.book_append_sheet(workbook, worksheet, worksheetName);
|
|
||||||
|
|
||||||
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export function parseRundown(
|
|||||||
*/
|
*/
|
||||||
export function sanitiseCustomFields(customFields: CustomFields, entry: OntimeEvent | OntimeMilestone | OntimeGroup) {
|
export function sanitiseCustomFields(customFields: CustomFields, entry: OntimeEvent | OntimeMilestone | OntimeGroup) {
|
||||||
for (const field in entry.custom) {
|
for (const field in entry.custom) {
|
||||||
if (field in customFields) continue;
|
if (Object.hasOwn(customFields, field)) continue;
|
||||||
delete entry.custom[field];
|
delete entry.custom[field];
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
groupEntries,
|
groupEntries,
|
||||||
initRundown,
|
initRundown,
|
||||||
loadRundown,
|
loadRundown,
|
||||||
pasteEntries,
|
|
||||||
reorderEntry,
|
reorderEntry,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
ungroupEntries,
|
ungroupEntries,
|
||||||
@@ -31,7 +30,6 @@ import {
|
|||||||
entryPutValidator,
|
entryPutValidator,
|
||||||
entryReorderValidator,
|
entryReorderValidator,
|
||||||
entrySwapValidator,
|
entrySwapValidator,
|
||||||
pastePostValidator,
|
|
||||||
rundownArrayOfIds,
|
rundownArrayOfIds,
|
||||||
rundownPostValidator,
|
rundownPostValidator,
|
||||||
validateRundownMutation,
|
validateRundownMutation,
|
||||||
@@ -304,29 +302,6 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Pastes entries from a source rundown into the active rundown
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
'/:rundownId/paste',
|
|
||||||
pastePostValidator,
|
|
||||||
validateRundownMutation,
|
|
||||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
|
||||||
try {
|
|
||||||
const rundown = await pasteEntries({
|
|
||||||
entryIds: req.body.entryIds,
|
|
||||||
sourceRundownId: req.body.sourceRundownId,
|
|
||||||
afterId: req.body.afterId,
|
|
||||||
beforeId: req.body.beforeId,
|
|
||||||
});
|
|
||||||
res.status(200).send(rundown);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a group out of a list of entries
|
* Creates a group out of a list of entries
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { customFieldLabelToKey, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
|
import { customFieldLabelToKey, getInsertAfterId, isObjectPrototypeKey, resolveInsertParent } from 'ontime-utils';
|
||||||
|
|
||||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
@@ -334,102 +334,6 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
|
|||||||
return rundownResult;
|
return rundownResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pastes a list of entries into the active rundown
|
|
||||||
* Supports cross-rundown paste within the same instance
|
|
||||||
* @throws if any entry is not found in the source rundown
|
|
||||||
*/
|
|
||||||
export async function pasteEntries(payload: {
|
|
||||||
entryIds: EntryId[];
|
|
||||||
sourceRundownId: string;
|
|
||||||
afterId?: EntryId;
|
|
||||||
beforeId?: EntryId;
|
|
||||||
}): Promise<Rundown> {
|
|
||||||
const { entryIds, sourceRundownId, afterId, beforeId } = payload;
|
|
||||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
|
||||||
|
|
||||||
// resolve source rundown
|
|
||||||
const sourceRundown =
|
|
||||||
sourceRundownId === rundown.id ? rundown : getDataProvider().getRundown(sourceRundownId);
|
|
||||||
|
|
||||||
// validate all entry IDs exist in source
|
|
||||||
for (const entryId of entryIds) {
|
|
||||||
if (!sourceRundown.entries[entryId]) {
|
|
||||||
throw new Error(`Entry with ID ${entryId} not found in source rundown`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolve insertion position
|
|
||||||
let currentAfterId: EntryId | undefined;
|
|
||||||
|
|
||||||
if (afterId) {
|
|
||||||
currentAfterId = afterId;
|
|
||||||
} else if (beforeId) {
|
|
||||||
// for "before" positioning, pass it to the first clone and then chain after
|
|
||||||
currentAfterId = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEntryIds: EntryId[] = [];
|
|
||||||
|
|
||||||
// sort entry IDs by source rundown flatOrder so paste preserves rundown order
|
|
||||||
// regardless of the order they were selected (e.g. ctrl-click order)
|
|
||||||
const validIds = entryIds.filter((id) => sourceRundown.entries[id]);
|
|
||||||
const orderedIds = validIds.sort((a, b) => {
|
|
||||||
const idxA = sourceRundown.flatOrder.indexOf(a);
|
|
||||||
const idxB = sourceRundown.flatOrder.indexOf(b);
|
|
||||||
return idxA - idxB;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < orderedIds.length; i++) {
|
|
||||||
const entryId = orderedIds[i];
|
|
||||||
const sourceEntry = sourceRundown.entries[entryId];
|
|
||||||
|
|
||||||
// resolve position reference for this entry
|
|
||||||
// when pasting a group and the reference is a child inside another group,
|
|
||||||
// normalise to the parent group so the pasted group lands at the top level
|
|
||||||
const options: InsertOptions = {};
|
|
||||||
if (i === 0 && beforeId && !afterId) {
|
|
||||||
options.before = normaliseGroupPosition(rundown, sourceEntry, beforeId);
|
|
||||||
} else if (currentAfterId) {
|
|
||||||
options.after = normaliseGroupPosition(rundown, sourceEntry, currentAfterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEntry = rundownMutation.clone(rundown, sourceEntry, options);
|
|
||||||
newEntryIds.push(newEntry.id);
|
|
||||||
|
|
||||||
// chain: each new entry becomes the afterId for the next
|
|
||||||
currentAfterId = newEntry.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
|
||||||
|
|
||||||
// schedule the side effects
|
|
||||||
setImmediate(() => {
|
|
||||||
updateRuntimeOnChange(rundownMetadata);
|
|
||||||
notifyChanges(rundownMetadata, revision, { timer: newEntryIds, external: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
return rundownResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When pasting a group, the position reference must be a top-level entry.
|
|
||||||
* If the reference is a child inside another group, resolve to the parent
|
|
||||||
* so the pasted group ends up at the correct position in rundown.order.
|
|
||||||
*/
|
|
||||||
function normaliseGroupPosition(rundown: Rundown, sourceEntry: OntimeEntry, referenceId: EntryId): EntryId {
|
|
||||||
if (!isOntimeGroup(sourceEntry)) {
|
|
||||||
return referenceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const referenceEntry = rundown.entries[referenceId];
|
|
||||||
if (referenceEntry && 'parent' in referenceEntry && referenceEntry.parent) {
|
|
||||||
return referenceEntry.parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return referenceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clones an entry, ensuring that all dependencies are preserved
|
* Clones an entry, ensuring that all dependencies are preserved
|
||||||
* Handles cloning children if the entry is a group
|
* Handles cloning children if the entry is a group
|
||||||
@@ -521,6 +425,9 @@ export async function createCustomField(customField: CustomField): Promise<Custo
|
|||||||
if (!key) {
|
if (!key) {
|
||||||
throw new Error('Unable to convert label to a valid key');
|
throw new Error('Unable to convert label to a valid key');
|
||||||
}
|
}
|
||||||
|
if (isObjectPrototypeKey(key)) {
|
||||||
|
throw new Error('Label conflicts with a reserved field name');
|
||||||
|
}
|
||||||
|
|
||||||
const { customFields, commit } = createTransaction({ mutableRundown: false, mutableCustomFields: true });
|
const { customFields, commit } = createTransaction({ mutableRundown: false, mutableCustomFields: true });
|
||||||
|
|
||||||
@@ -559,7 +466,7 @@ export async function editCustomField(
|
|||||||
mutableCustomFields: true,
|
mutableCustomFields: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!(key in customFields)) {
|
if (!Object.hasOwn(customFields, key)) {
|
||||||
throw new Error('Could not find label');
|
throw new Error('Could not find label');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +475,9 @@ export async function editCustomField(
|
|||||||
if (newField.type && existingField.type !== newField.type) {
|
if (newField.type && existingField.type !== newField.type) {
|
||||||
throw new Error('Change of field type is not allowed');
|
throw new Error('Change of field type is not allowed');
|
||||||
}
|
}
|
||||||
|
if (newField.label && isObjectPrototypeKey(customFieldLabelToKey(newField.label))) {
|
||||||
|
throw new Error('Label conflicts with a reserved field name');
|
||||||
|
}
|
||||||
|
|
||||||
const { oldKey, newKey } = customFieldMutation.edit(customFields, key, existingField, newField);
|
const { oldKey, newKey } = customFieldMutation.edit(customFields, key, existingField, newField);
|
||||||
|
|
||||||
@@ -609,7 +519,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
|
|||||||
mutableRundown: true,
|
mutableRundown: true,
|
||||||
mutableCustomFields: true,
|
mutableCustomFields: true,
|
||||||
});
|
});
|
||||||
if (!(key in customFields)) {
|
if (!Object.hasOwn(customFields, key)) {
|
||||||
return customFields;
|
return customFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,14 +83,4 @@ export const rundownArrayOfIds = [
|
|||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const pastePostValidator = [
|
|
||||||
body('entryIds').isArray().notEmpty(),
|
|
||||||
body('entryIds.*').isString(),
|
|
||||||
body('sourceRundownId').isString().notEmpty(),
|
|
||||||
body('afterId').optional().isString(),
|
|
||||||
body('beforeId').optional().isString(),
|
|
||||||
|
|
||||||
requestValidationFunction,
|
|
||||||
];
|
|
||||||
|
|
||||||
// #endregion operations on rundown entries =======================
|
// #endregion operations on rundown entries =======================
|
||||||
|
|||||||
+23
-89
@@ -42,7 +42,6 @@ import { consoleError, consoleHighlight, consoleSuccess } from './utils/console.
|
|||||||
import { generateCrashReport } from './utils/generateCrashReport.js';
|
import { generateCrashReport } from './utils/generateCrashReport.js';
|
||||||
import { getNetworkInterfaces } from './utils/network.js';
|
import { getNetworkInterfaces } from './utils/network.js';
|
||||||
import { clearUploadfolder } from './utils/upload.js';
|
import { clearUploadfolder } from './utils/upload.js';
|
||||||
import { withTimeout } from './utils/withTimeout.js';
|
|
||||||
|
|
||||||
console.log('\n');
|
console.log('\n');
|
||||||
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||||
@@ -65,7 +64,6 @@ const prefix = updateRouterPrefix();
|
|||||||
|
|
||||||
// Create express APP
|
// Create express APP
|
||||||
const app = express();
|
const app = express();
|
||||||
let isShuttingDown = false;
|
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
// log server timings to requests
|
// log server timings to requests
|
||||||
app.use(serverTiming());
|
app.use(serverTiming());
|
||||||
@@ -87,15 +85,6 @@ app.get(`${prefix}/health`, (_req, res) => {
|
|||||||
res.status(200).send('OK');
|
res.status(200).send('OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
// readiness probe route for orchestrators (e.g. kubernetes)
|
|
||||||
app.get(`${prefix}/ready`, (_req, res) => {
|
|
||||||
if (isShuttingDown) {
|
|
||||||
res.status(503).send('SHUTTING_DOWN');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(200).send('READY');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Implement route endpoints
|
// Implement route endpoints
|
||||||
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||||
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||||
@@ -147,7 +136,6 @@ enum OntimeStartOrder {
|
|||||||
|
|
||||||
let step = OntimeStartOrder.InitAssets;
|
let step = OntimeStartOrder.InitAssets;
|
||||||
let expressServer: Server | null = null;
|
let expressServer: Server | null = null;
|
||||||
let shutdownPromise: Promise<void> | null = null;
|
|
||||||
|
|
||||||
const checkStart = (currentState: OntimeStartOrder) => {
|
const checkStart = (currentState: OntimeStartOrder) => {
|
||||||
if (step !== currentState) {
|
if (step !== currentState) {
|
||||||
@@ -262,88 +250,34 @@ export const startIntegrations = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean shutdown app services
|
* @description clean shutdown app services
|
||||||
* - it avoid concurrency issues with deduplication of request to shutdown
|
* @param {number} exitCode
|
||||||
* - extracts exit code to modify cleanup behaviour
|
* @return {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function shutdown(exitCode = 0): Promise<void> {
|
export const shutdown = async (exitCode = 0) => {
|
||||||
if (shutdownPromise) {
|
|
||||||
return shutdownPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
shutdownPromise = performShutdown(exitCode);
|
|
||||||
return shutdownPromise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeHttpServer = async (server: Server | null): Promise<void> => {
|
|
||||||
if (!server) return;
|
|
||||||
|
|
||||||
const closePromise = new Promise<void>((resolve, reject) => {
|
|
||||||
server.close((error) => {
|
|
||||||
if (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ERR_SERVER_NOT_RUNNING') {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reject(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
server.closeIdleConnections();
|
|
||||||
server.closeAllConnections();
|
|
||||||
|
|
||||||
await closePromise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const shutdownGlobalTimeout = 10_000; // 10 seconds
|
|
||||||
const shutdownTimeout = 4_000; // 4 seconds
|
|
||||||
|
|
||||||
async function performShutdown(exitCode: number): Promise<void> {
|
|
||||||
isShuttingDown = true;
|
|
||||||
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
|
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
|
||||||
|
|
||||||
// if shutdown takes longer than 10 seconds, force exit to avoid hanging processes
|
await flushPendingWrites().catch((_error) => {
|
||||||
const forceExitTimer = setTimeout(() => {
|
/** nothing do to here */
|
||||||
consoleError('Forced shutdown after timeout');
|
});
|
||||||
process.exit(exitCode);
|
|
||||||
}, shutdownGlobalTimeout);
|
|
||||||
|
|
||||||
try {
|
// clear the restore file if it was a normal exit
|
||||||
runtimeService.shutdown();
|
// 0 means it was a SIGNAL
|
||||||
|
// 1 means crash -> keep the file
|
||||||
// Block for at most 4 seconds on each shutdown segment
|
// 2 means dev crash -> do nothing
|
||||||
await withTimeout(
|
// 3 means container shutdown -> keep the file
|
||||||
flushPendingWrites().catch((_error) => {
|
// 99 means there was a shutdown request from the UI
|
||||||
/** nothing do to here */
|
if (exitCode === 0 || exitCode === 99) {
|
||||||
}),
|
await restoreService.clear();
|
||||||
shutdownTimeout,
|
await portManager.shutdown();
|
||||||
);
|
|
||||||
|
|
||||||
// clear the restore file if it was a normal exit
|
|
||||||
// 0 means it was a SIGNAL
|
|
||||||
// 1 means crash -> keep the file
|
|
||||||
// 2 means dev crash -> do nothing
|
|
||||||
// 3 means container shutdown -> keep the file
|
|
||||||
// 99 means there was a shutdown request from the UI
|
|
||||||
if (exitCode === 0 || exitCode === 99) {
|
|
||||||
await withTimeout(restoreService.clear(), shutdownTimeout);
|
|
||||||
await withTimeout(portManager.shutdown(), shutdownTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
await withTimeout(
|
|
||||||
Promise.all([closeHttpServer(expressServer), socket.shutdown(), oscServer.shutdown()]),
|
|
||||||
shutdownTimeout,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(LogOrigin.Server, `Shutdown error: ${error}`, false);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(forceExitTimer);
|
|
||||||
logger.shutdown();
|
|
||||||
process.exit(exitCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expressServer?.close();
|
||||||
|
runtimeService.shutdown();
|
||||||
|
logger.shutdown();
|
||||||
|
oscServer.shutdown();
|
||||||
|
socket.shutdown();
|
||||||
|
process.exit(exitCode);
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
|
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* Resolves or rejects with the provided promise, but fails if it does not settle within `timeoutMs`.
|
|
||||||
*/
|
|
||||||
export const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> => {
|
|
||||||
let timer: NodeJS.Timeout | null = null;
|
|
||||||
const timeout = new Promise<T>((_, reject) => {
|
|
||||||
timer = setTimeout(() => reject(new Error('Operation timed out')), timeoutMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Promise.race([promise, timeout]).finally(() => {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -31,7 +31,7 @@ test('Copy-paste', async ({ page }) => {
|
|||||||
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('4');
|
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('4');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Multi-event copy-paste', async ({ page }) => {
|
test('Cut-paste', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/rundown');
|
await page.goto('http://localhost:4001/rundown');
|
||||||
await page.getByRole('button', { name: 'Edit' }).click();
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
|
||||||
@@ -40,39 +40,27 @@ test('Multi-event copy-paste', async ({ page }) => {
|
|||||||
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
|
||||||
// create three events with distinct titles
|
// create events
|
||||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||||
await page.getByTestId('entry-1').getByTestId('entry__title').click();
|
await page.getByTestId('entry-1').getByTestId('entry__title').click();
|
||||||
await page.getByTestId('entry-1').getByTestId('entry__title').fill('alpha');
|
await page.getByTestId('entry-1').getByTestId('entry__title').fill('first');
|
||||||
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
|
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||||
await page.getByTestId('entry-2').getByTestId('entry__title').click();
|
await page.getByTestId('entry-2').getByTestId('entry__title').click();
|
||||||
await page.getByTestId('entry-2').getByTestId('entry__title').fill('beta');
|
await page.getByTestId('entry-2').getByTestId('entry__title').fill('second');
|
||||||
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
|
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
// cut first event, paste below second
|
||||||
await page.getByTestId('entry-3').getByTestId('entry__title').click();
|
await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click();
|
||||||
await page.getByTestId('entry-3').getByTestId('entry__title').fill('gamma');
|
await page.getByTestId('entry-1').getByTestId('rundown-event').filter({ hasText: '1' }).press('ControlOrMeta+x');
|
||||||
await page.getByTestId('entry-3').getByTestId('entry__title').press('Enter');
|
await page.getByTestId('entry-2').getByTestId('rundown-event').getByText('2').click();
|
||||||
|
await page.getByTestId('entry-2').getByTestId('rundown-event').filter({ hasText: '2' }).press('ControlOrMeta+v');
|
||||||
|
|
||||||
// multi-select first and third events via ctrl-click
|
// we can verify that the entries have swapped places
|
||||||
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
|
const events = await page.getByTestId('entry__title').all();
|
||||||
await page.getByTestId('entry-3').getByTestId('rundown-event').click({ modifiers: ['Control'] });
|
await expect(events[0]).toHaveValue('second');
|
||||||
|
await expect(events[1]).toHaveValue('first');
|
||||||
// copy and paste after the last event
|
|
||||||
await page.keyboard.press('ControlOrMeta+c');
|
|
||||||
await page.getByTestId('entry-3').getByTestId('rundown-event').click();
|
|
||||||
await page.keyboard.press('ControlOrMeta+v');
|
|
||||||
|
|
||||||
// should now have 5 events: alpha, beta, gamma, alpha-copy, gamma-copy
|
|
||||||
await expect(page.getByTestId('rundown-event')).toHaveCount(5);
|
|
||||||
const titles = await page.getByTestId('entry__title').all();
|
|
||||||
await expect(titles[0]).toHaveValue('alpha');
|
|
||||||
await expect(titles[1]).toHaveValue('beta');
|
|
||||||
await expect(titles[2]).toHaveValue('gamma');
|
|
||||||
await expect(titles[3]).toHaveValue('alpha');
|
|
||||||
await expect(titles[4]).toHaveValue('gamma');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Move', async ({ page }) => {
|
test('Move', async ({ page }) => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export enum RefetchKey {
|
|||||||
Rundown = 'rundown',
|
Rundown = 'rundown',
|
||||||
UrlPresets = 'url-presets',
|
UrlPresets = 'url-presets',
|
||||||
ViewSettings = 'view-settings',
|
ViewSettings = 'view-settings',
|
||||||
CssOverride = 'css-override',
|
|
||||||
Translation = 'translation',
|
Translation = 'translation',
|
||||||
Settings = 'settings',
|
Settings = 'settings',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export { checkRegex, regex } from './src/regex-utils/checkRegex.js';
|
|||||||
export { isColourHex } from './src/regex-utils/isColourHex.js';
|
export { isColourHex } from './src/regex-utils/isColourHex.js';
|
||||||
export { splitWhitespace } from './src/regex-utils/splitWhitespace.js';
|
export { splitWhitespace } from './src/regex-utils/splitWhitespace.js';
|
||||||
|
|
||||||
export { customFieldLabelToKey, customKeyFromLabel } from './src/customField-utils/customFieldUtils.js';
|
export { customFieldLabelToKey, customKeyFromLabel, isObjectPrototypeKey } from './src/customField-utils/customFieldUtils.js';
|
||||||
|
|
||||||
// helpers from externals
|
// helpers from externals
|
||||||
export { deepmerge } from './src/externals/deepmerge.js';
|
export { deepmerge } from './src/externals/deepmerge.js';
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { CustomFields } from 'ontime-types';
|
import type { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
|
const objectPrototypeKeys = new Set(Object.getOwnPropertyNames(Object.prototype));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforms an alphanumeric label with spaces into a valid key
|
* Transforms an alphanumeric label with spaces into a valid key
|
||||||
*/
|
*/
|
||||||
@@ -7,6 +9,13 @@ export function customFieldLabelToKey(label: string): string {
|
|||||||
return label.trim().replaceAll(' ', '_');
|
return label.trim().replaceAll(' ', '_');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects keys that collide with Object prototype properties or methods
|
||||||
|
*/
|
||||||
|
export function isObjectPrototypeKey(key: string): boolean {
|
||||||
|
return objectPrototypeKeys.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds an object key in the CustomFields object that matches the given label
|
* Finds an object key in the CustomFields object that matches the given label
|
||||||
*/
|
*/
|
||||||
|
|||||||
Generated
+813
-1024
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user