Compare commits

..

3 Commits

Author SHA1 Message Date
Carlos Valente b13d5792f3 fixup! feat: implement speed controls 2025-03-02 14:45:40 +01:00
Carlos Valente d0027d4016 fixup! feat: implement speed controls 2025-03-02 14:45:32 +01:00
Carlos Valente ae1639802c feat: implement speed controls 2025-03-02 14:13:53 +01:00
277 changed files with 6795 additions and 5670 deletions
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+51
View File
@@ -0,0 +1,51 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myCustomPropertiesEnabled" value="true" />
<option name="myIgnoreVendorSpecificProperties" value="false" />
<option name="myCustomPropertiesList">
<value>
<list size="3">
<item index="0" class="java.lang.String" itemvalue="scrollbar-color" />
<item index="1" class="java.lang.String" itemvalue="scrollbar-width" />
<item index="2" class="java.lang.String" itemvalue="app-region" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://localhost" />
<option value="http://127.0.0.1" />
<option value="http://0.0.0.0" />
<option value="http://www.w3.org/" />
<option value="http://json-schema.org/draft" />
<option value="http://java.sun.com/" />
<option value="http://xmlns.jcp.org/" />
<option value="http://javafx.com/javafx/" />
<option value="http://javafx.com/fxml" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://maven.apache.org/POM/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://primefaces.org/ui" />
<option value="http://tiles.apache.org/" />
<option value="http://__IP__" />
</list>
</option>
</inspection_tool>
<inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
</profile>
</component>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.14.3",
"version": "3.13.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.14.3",
"version": "3.13.1",
"private": true,
"type": "module",
"dependencies": {
@@ -13,8 +13,8 @@
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.13.3",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.20.5",
@@ -28,7 +28,6 @@
"react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1",
"react-icons": "5.4.0",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1",
+1 -8
View File
@@ -158,14 +158,7 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<PCuesheet />} />
<Route
path='/op'
element={
<ViewLoader>
<POperator />
</ViewLoader>
}
/>
<Route path='/op' element={<POperator />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
+6 -2
View File
@@ -10,6 +10,7 @@ export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList'];
@@ -18,8 +19,11 @@ export const REPORT = ['report'];
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user';
const cssOverridePath = 'styles/override.css';
export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
export const userAssetsPath = 'user';
export const cssOverridePath = 'styles/override.css';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
+3 -5
View File
@@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -40,11 +40,9 @@ export async function downloadProject(fileName: string) {
export async function downloadCSV(fileName: string = 'rundown') {
try {
const { data, name } = await fileDownload(fileName);
const { project, rundowns, customFields } = data;
const flatRundowns = aggregateRundowns(rundowns);
const sheetData = makeTable(project, flatRundowns, customFields);
const { project, rundown, customFields } = data;
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
+6 -5
View File
@@ -1,11 +1,16 @@
import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown } from 'ontime-types';
import { CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`;
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/**
* upload Excel file to server
* @return string - file ID op the uploaded file
@@ -29,10 +34,6 @@ export async function getWorksheetNames(): Promise<string[]> {
return response.data;
}
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options,
+6 -21
View File
@@ -1,44 +1,29 @@
import axios, { AxiosResponse } from 'axios';
import {
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types';
import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`;
/**
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(`${rundownPath}/`);
return res.data;
}
/**
* HTTP request to fetch all events
*/
export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
export async function fetchNormalisedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownPath}/normalised`);
return res.data;
}
/**
* HTTP request to post new event
*/
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data);
}
/**
* HTTP request to put new event
*/
export async function requestPutEvent(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.put(rundownPath, data);
}
@@ -63,7 +48,7 @@ export type ReorderEntry = {
/**
* HTTP request to reorder events
*/
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeEntry>> {
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.patch(`${rundownPath}/reorder`, data);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
@@ -54,7 +54,7 @@ export const previewRundown = async (
sheetId: string,
options: ImportMap,
): Promise<{
rundown: Rundown;
rundown: OntimeRundown;
customFields: CustomFields;
}> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
@@ -1,5 +1,4 @@
import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5';
import {
IconButton,
Input,
@@ -11,6 +10,7 @@ import {
ModalOverlay,
Select,
} from '@chakra-ui/react';
import { IoArrowForward } from '@react-icons/all-files/io5/IoArrowForward';
import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket';
@@ -2,8 +2,8 @@
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { ReactElement } from 'react';
import { IconType } from 'react-icons';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
@@ -23,7 +23,7 @@ export type OptionWithoutGroup = {
withDivider?: boolean;
};
type OptionWithGroup = {
export type OptionWithGroup = {
label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[];
};
@@ -1,15 +1,16 @@
import { PropsWithChildren, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { Size } from '../../models/Util.type';
import copyToClipboard from '../../utils/copyToClipboard';
interface CopyTagProps {
copyValue: string;
label: string;
size?: 'xs' | 'sm' | 'md' | 'lg';
size?: Size;
disabled?: boolean;
onClick?: () => void;
}
@@ -1,5 +1,6 @@
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig';
@@ -3,8 +3,8 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react';
import { hasConnected, reconnectAttempts } from '../../../common/utils/socket';
import { runtimeStore } from '../../stores/runtime';
import { runtimeStore } from '@/common/stores/runtime';
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
import style from './ErrorBoundary.module.scss';
@@ -26,22 +26,17 @@ class ErrorBoundary extends React.Component {
error,
errorInfo: info,
});
this.reportContent = `${error} ${info.componentStack}`;
const appState = runtimeStore.getState();
if (appState.ping < 0) {
return;
}
Sentry.withScope((scope) => {
scope.setExtras({
error,
store: appState,
hasSocket: { hasConnected, reconnectAttempts },
store: runtimeStore.getState(),
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
});
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
});
this.reportContent = `${error} ${info.componentStack}`;
}
render() {
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react';
import { IoAlertCircle } from 'react-icons/io5';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import { cx } from '../../utils/styleUtils';
@@ -1,4 +1,4 @@
import { IoBan } from 'react-icons/io5';
import { IoBan } from '@react-icons/all-files/io5/IoBan';
import { cx } from '../../../utils/styleUtils';
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from 'react-icons/io5';
import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop';
import { ViewSettings } from 'ontime-types';
import { debounce } from '../../../utils/debounce';
@@ -17,7 +17,6 @@ export default function useReactiveTextInput(
submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean;
},
): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText);
@@ -59,14 +58,7 @@ export default function useReactiveTextInput(
setText(cleanVal);
}
}
setTimeout(() => {
if (options?.allowKeyboardNavigation) {
ref.current?.parentElement?.focus(); // Focus on parent element to continue keyboard navigation
} else {
ref.current?.blur();
}
}); // Immediate timeout to ensure text is set before blurring
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
},
[initialText, options, ref, submitCallback],
);
@@ -78,25 +70,12 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => {
// No need to update if it hasn't changed
setText(initialText);
// force the text to be the initial value
if (ref.current) {
ref.current.value = initialText;
}
options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, [initialText, options, ref]);
const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [
[
'Escape',
(event) => {
event.preventDefault();
handleEscape();
},
{ preventDefault: true },
],
];
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => handleSubmit(text)]);
@@ -105,22 +84,7 @@ export default function useReactiveTextInput(
if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
}
const hotKeyHandler = getHotkeyHandler(hotKeys);
return (event: KeyboardEvent<HTMLElement>) => {
// allow moving in input field with arrow keys
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown'
) {
event.stopPropagation();
}
hotKeyHandler(event);
};
return getHotkeyHandler(hotKeys);
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return {
@@ -1,9 +1,12 @@
import { type PropsWithChildren } from 'react';
import { useNavigate } from 'react-router-dom';
import { cx } from '../../../utils/styleUtils';
import style from './AppLink.module.scss';
interface AppLinkProps {
className?: string;
search: string;
}
@@ -12,13 +15,13 @@ interface AppLinkProps {
* Handles the path to respect Ontime Clouds base URL
*/
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
const { search, children } = props;
const { className, search, children } = props;
const navigate = useNavigate();
const handleClick = () => navigate({ search });
return (
<button onClick={handleClick} className={style.link}>
<button onClick={handleClick} className={cx([style.link, className])}>
{children}
</button>
);
@@ -1,5 +1,5 @@
import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from 'react-icons/io5';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils';
@@ -0,0 +1,45 @@
$loader-size: 4rem;
.overlay {
position: absolute;
z-index: 10;
width: 100%;
height: 100%;
display: grid;
place-content: center;
background-color: $black-10;
backdrop-filter: blur(5px);
}
.loader {
width: $loader-size;
height: $loader-size;
background: $blue-500;
display: inline-block;
border-radius: 50%;
box-sizing: border-box;
animation: animloader 1s ease-in infinite;
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -0,0 +1,9 @@
import style from './LoaderOverlay.module.scss';
export default function LoaderOverlay() {
return (
<div className={style.overlay}>
<span className={style.loader} />
</div>
);
}
@@ -1,5 +1,5 @@
import { IoApps } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
@@ -1,6 +1,5 @@
import { memo, PropsWithChildren } from 'react';
import { createPortal } from 'react-dom';
import { IoArrowUp, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { Link, useLocation } from 'react-router-dom';
import {
Drawer,
@@ -12,6 +11,11 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig';
@@ -0,0 +1,16 @@
import { memo } from 'react';
import NavigationMenu from './NavigationMenu';
interface ProductionNavigationMenuProps {
isMenuOpen: boolean;
onMenuClose: () => void;
}
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
const { isMenuOpen, onMenuClose } = props;
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
}
export default memo(ProductionNavigationMenu);
@@ -1,4 +1,4 @@
import { IoLockClosedOutline } from 'react-icons/io5';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
@@ -1,5 +1,7 @@
import { IoPause, IoPlay, IoStop } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback } from 'ontime-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import style from './ProtectRoute.module.scss';
@@ -1,5 +1,4 @@
import { useState } from 'react';
import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom';
import {
Button,
@@ -14,6 +13,7 @@ import {
Select,
Switch,
} from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
@@ -126,9 +126,9 @@ function MultiOption(props: EditFormMultiOptionProps) {
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
>
{Object.values(paramField.values).map((option) => {
const { value, label, colour } = option;
const { value, label } = option;
return (
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
<MenuItemOption value={value} key={value}>
{label}
</MenuItemOption>
);
@@ -1,5 +1,5 @@
import { IoChevronDown } from 'react-icons/io5';
import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { cx } from '../../utils/styleUtils';
@@ -1,6 +1,6 @@
import { CustomFields } from 'ontime-types';
import type { MultiselectOptions, ParamField } from './types';
import type { ParamField } from './types';
export const makeOptionsFromCustomFields = (
customFields: CustomFields,
@@ -18,17 +18,6 @@ export const makeOptionsFromCustomFields = (
return options;
};
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
const options: MultiselectOptions = {};
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[key] = { value: key, label: value.label, colour: value.colour };
}
return options;
}
export const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`;
return {
@@ -12,11 +12,9 @@ type OptionsField = {
defaultValue?: string;
};
type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>;
type MultiOptionsField = {
type: 'multi-option';
values: MultiselectOptions;
values: Record<string, { value: string; label: string; colour: string }>;
defaultValue?: string;
};
@@ -0,0 +1,32 @@
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
import { useCallback, useEffect, useState } from 'react';
function getMatches(query: string): boolean {
return window.matchMedia(query).matches;
}
// TODO: debounce handleChange
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMatches(query));
const handleChange = useCallback(() => {
setMatches(getMatches(query));
}, [query]);
useEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
// Listen matchMedia
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [handleChange, query]);
return matches;
}
@@ -1,9 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { getAutomationSettings } from '../api/automation';
import { editAutomationSettings, getAutomationSettings } from '../api/automation';
import { AUTOMATION } from '../api/constants';
import { logAxiosError } from '../api/utils';
import { automationPlaceholderSettings } from '../models/AutomationSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
@@ -18,3 +20,15 @@ export default function useAutomationSettings() {
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
}
export function useAutomationSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: editAutomationSettings,
onError: (error) => logAxiosError('Error saving Automation settings', error),
onSuccess: (data) => {
ontimeQueryClient.setQueryData(AUTOMATION, data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }),
});
return { isPending, mutateAsync };
}
@@ -11,7 +11,7 @@ const placeholderProjectList: ProjectFileListResponse = {
lastLoadedProject: '',
};
function useProjectList() {
export function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: getProjects,
@@ -1,29 +1,23 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { OntimeEntry, Rundown } from 'ontime-types';
import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown';
import { fetchNormalisedRundown } from '../api/rundown';
import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
entries: {},
revision: -1,
};
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
/**
* Normalised rundown data
*/
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
queryFn: fetchCurrentRundown,
queryFn: fetchNormalisedRundown,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
@@ -43,16 +37,16 @@ export function useFlatRundown() {
const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]);
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.entries[id]);
setFlatRundown(flatRundown);
const flatRundown = data.order.map((id) => data.rundown[id]);
setFlatRunDown(flatRundown);
setPrevRevision(data.revision);
}
}, [data.entries, data.order, data.revision, prevRevision]);
}, [data.order, data.revision, data.rundown, prevRevision]);
// TODO: should we have a project id field?
// invalidate current version if project changes
@@ -63,13 +57,13 @@ export function useFlatRundown() {
}
}, [projectData]);
return { data: flatRundown, status };
return { data: flatRunDown, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*/
export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) {
const { data, status } = useFlatRundown();
const filteredData = useMemo(() => {
return data.filter(cb);
+45 -76
View File
@@ -2,12 +2,11 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
isOntimeEvent,
MaybeString,
OntimeBlock,
OntimeDelay,
OntimeEntry,
OntimeEvent,
Rundown,
OntimeRundownEntry,
RundownCached,
TimeField,
TimeStrategy,
TransientEventPayload,
@@ -32,12 +31,12 @@ import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
// options to any new block (event / delay / block)
after: MaybeString;
before: MaybeString;
after: string;
before: string;
// options to blocks of type OntimeEvent
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: MaybeString;
lastEventId: string;
}>;
/**
@@ -58,11 +57,11 @@ export const useEventAction = () => {
const getEventById = useCallback(
(eventId: string) => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.entries) {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.rundown) {
return;
}
return cachedRundown.entries[eventId];
return cachedRundown.rundown[eventId];
},
[queryClient],
);
@@ -101,8 +100,9 @@ export const useEventAction = () => {
newEvent.linkStart = applicationOptions.lastEventId;
} else if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const { rundown } = rundownData;
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd;
}
@@ -180,21 +180,15 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const eventId = newEvent.id;
if (previousData && eventId) {
// optimistically update object
const newRundown = { ...previousData.entries };
const newRundown = { ...previousData.rundown };
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: previousData.order,
entries: newRundown,
revision: -1,
});
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous and new events
@@ -202,7 +196,7 @@ export const useEventAction = () => {
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
@@ -216,7 +210,7 @@ export const useEventAction = () => {
* Updates existing event
*/
const updateEvent = useCallback(
async (event: Partial<OntimeEntry>) => {
async (event: Partial<OntimeRundownEntry>) => {
try {
await _updateEventMutation.mutateAsync(event);
} catch (error) {
@@ -302,9 +296,9 @@ export const useEventAction = () => {
* Utility function to get the previous event end time
*/
function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.entries) {
if (!cachedRundown?.order || !cachedRundown?.rundown) {
return 0;
}
@@ -314,7 +308,7 @@ export const useEventAction = () => {
}
let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) {
const event = cachedRundown.entries[cachedRundown.order[i]];
const event = cachedRundown.rundown[cachedRundown.order[i]];
if (isOntimeEvent(event)) {
previousEnd = event.timeEnd;
break;
@@ -337,11 +331,11 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousRundown) {
if (previousEvents) {
const eventIds = new Set(ids);
const newRundown = { ...previousRundown.entries };
const newRundown = { ...previousEvents.rundown };
eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) {
@@ -355,22 +349,16 @@ export const useEventAction = () => {
}
});
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousRundown.id,
title: previousRundown.title,
order: previousRundown.order,
entries: newRundown,
revision: -1,
});
queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous rundown
return { previousRundown };
// Return a context with the previous and new events
return { previousEvents };
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
onError: (_error, _newEvent, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
queryClient.setQueryData(RUNDOWN, context?.previousEvents);
},
networkMode: 'always',
});
@@ -398,21 +386,19 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
const newRundown = { ...previousData.entries };
const newRundown = { ...previousData.rundown };
for (const eventId of eventIds) {
delete newRundown[eventId];
}
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
queryClient.setQueryData(RUNDOWN, {
order: newOrder,
entries: newRundown,
rundown: newRundown,
revision: -1,
});
}
@@ -423,7 +409,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
@@ -459,16 +445,10 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
// optimistically update object
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData?.id ?? 'default',
title: previousData?.title ?? '',
entries: {},
order: [],
revision: -1,
});
queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 });
// Return a context with the previous and new events
return { previousData };
@@ -476,7 +456,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undos optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
@@ -536,18 +516,13 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const newOrder = reorderArray(previousData.order, data.from, data.to);
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: newOrder,
entries: previousData.entries,
revision: -1,
});
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 });
}
// Return a context with the previous and new events
@@ -556,7 +531,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
@@ -597,28 +572,22 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const newRundown = { ...previousData.entries };
const eventA = previousData.entries[from];
const eventB = previousData.entries[to];
const newRundown = { ...previousData.rundown };
const eventA = previousData.rundown[from];
const eventB = previousData.rundown[to];
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return;
}
const [newA, newB] = swapEventData(eventA, eventB);
const { newA, newB } = swapEventData(eventA, eventB);
newRundown[from] = newA;
newRundown[to] = newB;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: previousData.order,
entries: newRundown,
revision: -1,
});
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous events
@@ -627,7 +596,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
@@ -12,7 +12,7 @@ type noop = (this: any, ...args: any[]) => any;
type PickFunction<T extends noop> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>;
const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function';
export const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function';
export default function useMemoisedFn<T extends noop>(fn: T) {
if (isDev) {
+30 -6
View File
@@ -41,6 +41,17 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
visible: state.message.timer.secondarySource === 'external',
}));
export const useTimerSchedule = createSelector((state: RuntimeStore) => ({
startedAt: state.timer.startedAt,
expectedFinish: state.timer.expectedFinish,
phase: state.timer.phase,
playback: state.timer.playback,
}));
export const useTimerCurrent = createSelector((state: RuntimeStore) => ({
current: state.timer.current,
}));
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
@@ -91,6 +102,14 @@ export const setPlayback = {
},
};
export const useInfoPanel = createSelector((state: RuntimeStore) => ({
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
}));
export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current);
export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({
@@ -106,6 +125,15 @@ export const setAuxTimer = {
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
};
export const useTimerSpeed = createSelector((state: RuntimeStore) => ({
speed: state.timer.speed,
}));
export const setTimerSpeed = {
getSpeed: () => socketSendJson('get-speed'),
setSpeed: (speed: number) => socketSendJson('set-speed', speed),
};
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null,
}));
@@ -137,6 +165,8 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null,
}));
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart,
actualStart: state.runtime.actualStart,
@@ -160,12 +190,6 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset,
}));
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset,
}));
@@ -0,0 +1 @@
export type Size = 'xs' | 'sm' | 'md' | 'lg';
+1 -1
View File
@@ -11,7 +11,7 @@ type LogStore = {
logs: Log[];
};
const logger = createStore<LogStore>(() => ({
export const logger = createStore<LogStore>(() => ({
logs: [],
}));
@@ -1,6 +1,4 @@
import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv';
import { makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => {
it('joins an array of arrays with commas and newlines', () => {
@@ -13,32 +11,3 @@ after newline,after comma
`);
});
});
describe('aggregateRundowns()', () => {
it('flattens an object of rundowns into a single array', () => {
const rundowns = {
first: {
id: '',
title: '',
revision: 0,
order: ['1', '2'],
entries: {
'1': { id: '1' } as OntimeEntry,
'2': { id: '2' } as OntimeEntry,
},
},
second: {
id: '',
title: '',
revision: 0,
order: ['3', '4'],
entries: {
'3': { id: '3' } as OntimeEntry,
'4': { id: '4' } as OntimeEntry,
},
} as Rundown,
} as ProjectRundowns;
expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]);
});
});
@@ -1,10 +1,10 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types';
import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
import { cloneEvent } from '../eventsManager';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original: OntimeEvent = {
const original = {
id: 'unique',
type: SupportedEvent.Event,
title: 'title',
@@ -14,9 +14,6 @@ describe('cloneEvent()', () => {
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
countToEnd: false,
endAction: EndAction.None,
isPublic: false,
skip: false,
@@ -24,39 +21,29 @@ describe('cloneEvent()', () => {
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
custom: {
lighting: '3',
} as EntryCustomFields,
};
} as EventCustomFields,
} as OntimeEvent;
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned).toMatchObject({
type: SupportedEvent.Event,
title: original.title,
note: original.note,
timeStart: original.timeStart,
duration: original.duration,
timeEnd: original.timeEnd,
timerType: original.timerType,
timeStrategy: original.timeStrategy,
countToEnd: original.countToEnd,
linkStart: original.linkStart,
endAction: original.endAction,
isPublic: original.isPublic,
skip: original.skip,
colour: original.colour,
revision: 0,
delay: original.delay,
dayOffset: original.dayOffset,
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
});
// @ts-expect-error -- safeguarding this
expect(cloned?.id).toBe(undefined);
expect(cloned.title).toBe(original.title);
expect(cloned.note).toBe(original.note);
expect(cloned.endAction).toBe(original.endAction);
expect(cloned.timerType).toBe(original.timerType);
expect(cloned.timeStart).toBe(original.timeStart);
expect(cloned.timeEnd).toBe(original.timeEnd);
expect(cloned.duration).toBe(original.duration);
expect(cloned.isPublic).toBe(original.isPublic);
expect(cloned.skip).toBe(original.skip);
expect(cloned.colour).toBe(original.colour);
expect(cloned.type).toBe(SupportedEvent.Event);
expect(cloned.revision).toBe(0);
expect(cloned.timeWarning).toBe(original.timeWarning);
expect(cloned.timeDanger).toBe(original.timeDanger);
expect(cloned.custom).toStrictEqual({});
});
});
@@ -1,4 +1,4 @@
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
import { formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => {
@@ -38,84 +38,3 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('-01:00');
});
});
describe('calculateTimeUntilStart()', () => {
test('ontime', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: 0,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: -20,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
});
test('running ahead', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 80,
offset: 10,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind with enough gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 20,
clock: 50,
offset: -20,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
test('running behind with to little gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 50,
offset: -20,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
//TODO: more indepth testing,
// including day offset handling
// and more?
});
@@ -64,13 +64,13 @@ describe('getRouteFromPreset()', () => {
describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy();
});
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy()
})
it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
});
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123')
})
});
});
@@ -83,26 +83,25 @@ describe('generatePathFromPreset()', () => {
});
test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe(
'timer?user=guest&alias=demopage&locked=true&token=123',
);
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
});
});
describe('arePathsEquivalent()', () => {
it('checks whether the paths match', () => {
it("checks whether the paths match", () => {
expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy();
expect(arePathsEquivalent('timer', 'timer')).toBeTruthy();
expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy();
});
})
it('checks whether the params match', () => {
it("checks whether the params match", () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
});
})
it('considers edge cases for the url sharing feature', () => {
it("considers edge cases for the url sharing feature", () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
});
})
});
+3 -24
View File
@@ -1,31 +1,10 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
/**
* Converts an array of arrays to a CSV file
* @description Converts an array of arrays to a CSV file
* @param {string[][]} arrayOfArrays
* @return {string}
*/
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays);
}
/**
* Receives an object of rundowns, and flattens them into a single, linear rundown
* Used for CSV export
*/
export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] {
const rundownKeys = Object.keys(rundowns);
if (rundownKeys.length === 0) return [];
const flatRundown: OntimeEntry[] = [];
for (const key of rundownKeys) {
const { order, entries } = rundowns[key];
for (let i = 0; i < order.length; i++) {
const entryId = order[i];
const entry = entries[entryId];
flatRundown.push(entry);
}
}
return flatRundown;
}
@@ -23,13 +23,12 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
isPublic: event.isPublic,
skip: event.skip,
colour: event.colour,
currentBlock: event.currentBlock,
revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
custom: { ...event.custom },
custom: {},
};
};
@@ -1,12 +0,0 @@
/**
* Allows lazy evaluation and caching of a functions result
*/
export function lazyEvaluate<T>(fn: () => T): () => T {
let result: T | undefined;
return function () {
if (result === undefined) {
result = fn();
}
return result;
};
}
+18 -11
View File
@@ -1,4 +1,4 @@
import { Log, Rundown, RuntimeStore } from 'ontime-types';
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
@@ -16,10 +16,11 @@ import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger';
import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime';
let websocket: WebSocket | null = null;
export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000;
export let shouldReconnect = true;
export let hasConnected = false;
export let reconnectAttempts = 0;
@@ -49,14 +50,15 @@ export const connectSocket = () => {
console.warn('WebSocket disconnected');
setOnlineStatus(false);
// we decide to allows reconnect
reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket();
}
}, reconnectInterval);
if (shouldReconnect) {
reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket();
}
}, reconnectInterval);
}
};
websocket.onerror = (error) => {
@@ -201,7 +203,7 @@ export const connectSocket = () => {
invalidateAllCaches();
} else if (target === 'RUNDOWN') {
const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN)?.revision ?? -1;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
@@ -222,6 +224,11 @@ export const connectSocket = () => {
};
};
export const disconnectSocket = () => {
shouldReconnect = false;
websocket?.close();
};
export const socketSend = (message: any) => {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(message);
+3 -72
View File
@@ -1,9 +1,8 @@
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants';
import { useTimeUntilData } from '../hooks/useSocket';
import { ontimeQueryClient } from '../queryClient';
/**
@@ -35,7 +34,7 @@ function getFormatFromParams() {
* Gets the format options from the applicaton settings
* @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss
*/
function getFormatFromSettings(): TimeFormat {
export function getFormatFromSettings(): TimeFormat {
const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS);
return settings?.timeFormat ?? '24';
}
@@ -126,71 +125,3 @@ export function formatDuration(duration: number, hideSeconds = true): string {
}
return result;
}
/**
*
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @returns
*/
export function useTimeUntilStart(
// typed like this to make it very clear what the data is
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
totalGap: number;
isLinkedToLoaded: boolean;
},
): number {
const { offset, clock, currentDay } = useTimeUntilData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset });
}
/**
*
* @param currentDay the day offset of the urrently running event
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @param clock
* @param offset
* @returns
*/
export function calculateTimeUntilStart(
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
currentDay: number;
totalGap: number;
isLinkedToLoaded: boolean;
clock: number;
offset: number;
},
): number {
const { timeStart, dayOffset, currentDay, totalGap, isLinkedToLoaded, clock, offset, delay } = data;
//How many days from the currently running event to this one
const relativeDayOffset = dayOffset - currentDay;
const delayedStart = Math.max(0, timeStart + delay);
//The normalised start time of this event relative to the currently running event
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
const offsetTimestart = normalisedTimeStart - offset;
const offsetTimeUntil = offsetTimestart - clock;
if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset
return offsetTimeUntil;
}
const scheduledTimeUntil = normalisedTimeStart - clock;
const isAheadOfSchedule = offset >= 0;
const gapsCanCompensadeForOffset = totalGap + offset >= 0;
if (isAheadOfSchedule || gapsCanCompensadeForOffset) {
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
return scheduledTimeUntil;
}
// otherwise consume as much of the offset as possible with the gap
const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap;
return offsetTimeUntilBufferedByGaps;
}
+11
View File
@@ -0,0 +1,11 @@
import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
import 'vitest';
// ugly hack because vite and pnpm are not playing ball with jest
// https://github.com/testing-library/jest-dom/issues/123
declare global {
namespace Vi {
type Assertion<T = any> = TestingLibraryMatchers<T, void>;
}
}
+3 -4
View File
@@ -19,10 +19,9 @@ export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
export const currentHostName = window.location.hostname;
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no');
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
// resolve entrypoint URLs
@@ -76,4 +75,4 @@ function resolveBaseURI(): string {
}
return base;
}
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
import { IoClose } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import style from './PanelContent.module.scss';
@@ -1,6 +1,6 @@
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { cx } from '../../../common/utils/styleUtils';
@@ -1,7 +1,8 @@
import { useEffect, useMemo } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import {
Automation,
AutomationDTO,
@@ -30,7 +31,7 @@ import OntimeActionForm from './OntimeActionForm';
import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const integrationsDocsUrl = 'https://docs.getontime.no/api/integrations/#using-variables-in-integrations';
interface AutomationFormProps {
automation: Automation | AutomationDTO;
@@ -1,6 +1,8 @@
import { Fragment, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
import { Button, IconButton } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { deleteAutomation } from '../../../../common/api/automation';
@@ -1,6 +1,6 @@
import { Fragment, useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { NormalisedAutomation, Trigger } from 'ontime-types';
import { deleteTrigger } from '../../../../common/api/automation';
@@ -1,6 +1,8 @@
import { useState } from 'react';
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import Tag from '../../../../common/components/tag/Tag';
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { IoTrashBin } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { IoTrashBin } from '@react-icons/all-files/io5/IoTrashBin';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
@@ -29,8 +29,8 @@ export default function ReportSettings() {
};
const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.entries, data.order);
}, [reportData, data.entries, data.order]);
return getCombinedReport(reportData, data.rundown, data.order);
}, [reportData, data.rundown, data.order]);
return (
<Panel.Section>
@@ -1,7 +1,9 @@
import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoOpenOutline, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { URLPreset } from 'ontime-types';
import { postUrlPresets } from '../../../../common/api/urlPresets';
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
@@ -1,4 +1,4 @@
import { EntryId, isOntimeEvent, MaybeNumber, OntimeReport, RundownEntries } from 'ontime-types';
import { isOntimeEvent, MaybeNumber, NormalisedRundown, OntimeReport } from 'ontime-types';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time';
@@ -16,7 +16,7 @@ export type CombinedReport = {
/**
* Creates a combined report with the rundown data
*/
export function getCombinedReport(report: OntimeReport, rundown: RundownEntries, order: EntryId[]): CombinedReport[] {
export function getCombinedReport(report: OntimeReport, rundown: NormalisedRundown, order: string[]): CombinedReport[] {
if (Object.keys(report).length === 0) return [];
if (order.length === 0) return [];
@@ -13,6 +13,10 @@ import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './GeneralPinInput';
export type GeneralPanelFormValues = {
filename: string;
};
export default function GeneralPanelForm() {
const { data, status, refetch } = useSettings();
const {
@@ -1,7 +1,7 @@
import { PropsWithChildren, useState } from 'react';
import { UseFormRegister } from 'react-hook-form';
import { IoEyeOutline } from 'react-icons/io5';
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
import { Settings } from 'ontime-types';
import { isAlphanumeric } from '../../../../common/utils/regex';
@@ -102,6 +102,7 @@ export default function EditorSettingsForm() {
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
>
<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>
@@ -3,12 +3,3 @@
background: $ui-white;
border-radius: 3px;
}
.column {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
color: $label-gray;
user-select: text;
}
@@ -6,12 +6,13 @@ import { Button, Select, Switch } from '@chakra-ui/react';
import { generateUrl } from '../../../../common/api/session';
import { maybeAxiosError } from '../../../../common/api/utils';
import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useInfo from '../../../../common/hooks-query/useInfo';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import copyToClipboard from '../../../../common/utils/copyToClipboard';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { linkToOtherHost } from '../../../../common/utils/linkUtils';
import { currentHostName, isOntimeCloud, serverURL } from '../../../../externals';
import { serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './GenerateLinkForm.module.scss';
@@ -38,12 +39,6 @@ export default function GenerateLinkForm() {
formState: { errors },
} = useForm<GenerateLinkFormOptions>({
mode: 'onChange',
defaultValues: {
baseUrl: currentHostName,
path: '',
lock: false,
authenticate: false,
},
resetOptions: {
keepDirtyValues: true,
},
@@ -77,11 +72,8 @@ export default function GenerateLinkForm() {
</Info>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Host IP'
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
/>
<Select variant='ontime' isDisabled={isOntimeCloud} size='sm' {...register('baseUrl')}>
<Panel.Field title='Host IP' description='Which IP address will be used' />
<Select variant='ontime' size='sm' {...register('baseUrl')}>
{infoData.networkInterfaces.map((nif) => {
return (
<option key={nif.name} value={nif.address}>
@@ -136,9 +128,9 @@ export default function GenerateLinkForm() {
>
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
</Button>
<div className={style.column}>
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<QRCode size={172} value={url} className={style.qrCode} />
<div>{url}</div>
<ExternalLink href={url}>{url}</ExternalLink>
</div>
</Panel.ListItem>
</Panel.ListGroup>
@@ -1,4 +1,4 @@
import { IoArrowUp } from 'react-icons/io5';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo';
@@ -1,6 +1,6 @@
import { MouseEvent } from 'react';
import { IoArrowUp } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { handleLinks } from '../../../../common/utils/linkUtils';
import Log from '../../../log/Log';
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket';
import { socketSendJson } from '../../../../common/utils/socket';
import { isDockerImage, isOntimeCloud } from '../../../../externals';
import { isDockerImage } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
@@ -30,12 +30,8 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
<Panel.Card>
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
<Panel.Divider />
{!isOntimeCloud && (
<>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
<InfoNif />
</>
)}
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
<InfoNif />
<GenerateLinkForm />
</Panel.Card>
</Panel.Section>
@@ -1,7 +1,7 @@
import { ChangeEvent, useRef, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom';
import { Button, Input } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
@@ -1,7 +1,8 @@
import { ChangeEvent, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { type ProjectData } from 'ontime-types';
import { projectLogoPath } from '../../../../common/api/constants';
@@ -96,7 +97,7 @@ export default function ProjectData() {
const isLoading = status === 'pending';
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}>
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}>
<Panel.Card>
<Panel.SubHeader>
Project data
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
import {
deleteProject,
@@ -6,7 +6,7 @@ export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record<st
for (const key in mergeKeys) {
if (isKeyOfType(key, data) && mergeKeys[key]) {
// if the rundown is merged we also need the custom fields
if (key === 'rundowns') {
if (key === 'rundown') {
patchObject.customFields = data['customFields'];
}
Object.assign(patchObject, { [key]: data[key] });
@@ -1,6 +1,7 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { IoCheckmark, IoShieldCheckmarkOutline } from 'react-icons/io5';
import { Button, Input, Spinner } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils';
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { CustomFields, Rundown } from 'ontime-types';
import { CustomFields, OntimeRundown } from 'ontime-types';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -9,7 +9,7 @@ import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore';
interface ImportReviewProps {
rundown: Rundown;
rundown: OntimeRundown;
customFields: CustomFields;
onFinished: () => void;
onCancel: () => void;
@@ -29,12 +29,7 @@ export default function ImportReview(props: ImportReviewProps) {
const applyImport = async () => {
setLoading(true);
await importRundown(
{
[rundown.id]: rundown,
},
customFields,
);
await importRundown(rundown, customFields);
setLoading(false);
onFinished();
};
@@ -1,6 +1,7 @@
import { ChangeEvent, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { Button, Input } from '@chakra-ui/react';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { getErrorMessage, ImportMap } from 'ontime-utils';
import {
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
import * as Panel from '../../../panel-utils/PanelUtils';
@@ -3,7 +3,7 @@ import { ImportCustom, ImportMap } from 'ontime-utils';
export type NamedImportMap = typeof namedImportMap;
// Record of label and import name
const namedImportMap = {
export const namedImportMap = {
Worksheet: 'event schedule',
Start: 'time start',
'Link start': 'link start',
@@ -1,6 +1,6 @@
import { Fragment } from 'react';
import { IoLink } from 'react-icons/io5';
import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } from 'ontime-types';
import { IoLink } from '@react-icons/all-files/io5/IoLink';
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import Tag from '../../../../../common/components/tag/Tag';
@@ -10,7 +10,7 @@ import * as Panel from '../../../panel-utils/PanelUtils';
import style from './PreviewRundown.module.scss';
interface PreviewRundownProps {
rundown: Rundown;
rundown: OntimeRundown;
customFields: CustomFields;
}
@@ -53,76 +53,75 @@ export default function PreviewRundown(props: PreviewRundownProps) {
</tr>
</thead>
<tbody>
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeBlock(entry)) {
{rundown.map((event) => {
if (isOntimeBlock(event)) {
return (
<tr key={entry.id}>
<tr key={event.id}>
<td className={style.center}>
<Tag>-</Tag>
</td>
<td className={style.center}>
<Tag>{entry.type}</Tag>
<Tag>{event.type}</Tag>
</td>
<td />
<td colSpan={99}>{entry.title}</td>
<td colSpan={99}>{event.title}</td>
</tr>
);
}
if (!isOntimeEvent(entry)) {
if (!isOntimeEvent(event)) {
return null;
}
eventIndex += 1;
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
const countToEnd = booleanToText(entry.countToEnd);
const isPublic = booleanToText(entry.isPublic);
const skip = booleanToText(entry.skip);
const colour = event.colour ? getAccessibleColour(event.colour) : {};
const countToEnd = booleanToText(event.countToEnd);
const isPublic = booleanToText(event.isPublic);
const skip = booleanToText(event.skip);
return (
<Fragment key={entry.id}>
<Fragment key={event.id}>
<tr>
<td className={style.center}>
<Tag>{eventIndex}</Tag>
</td>
<td className={style.center}>
<Tag>{entry.type}</Tag>
<Tag>{event.type}</Tag>
</td>
<td className={style.nowrap}>{entry.cue}</td>
<td>{entry.title}</td>
<td className={style.nowrap}>{event.cue}</td>
<td>{event.title}</td>
<td className={style.flex}>
<span className={entry.linkStart ? style.subdued : undefined}>{millisToString(entry.timeStart)}</span>
{entry.linkStart && <IoLink className={style.linkStartActive} />}
<span className={event.linkStart ? style.subdued : undefined}>{millisToString(event.timeStart)}</span>
{event.linkStart && <IoLink className={style.linkStartActive} />}
</td>
<td>{millisToString(entry.timeEnd)}</td>
<td>{millisToString(entry.duration)}</td>
<td>{millisToString(entry.timeWarning)}</td>
<td>{millisToString(entry.timeDanger)}</td>
<td>{millisToString(event.timeEnd)}</td>
<td>{millisToString(event.duration)}</td>
<td>{millisToString(event.timeWarning)}</td>
<td>{millisToString(event.timeDanger)}</td>
<td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td>
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{entry.colour}</td>
<td style={{ ...colour }}>{event.colour}</td>
<td className={style.center}>
<Tag>{entry.timerType}</Tag>
<Tag>{event.timerType}</Tag>
</td>
<td className={style.center}>
<Tag>{entry.endAction}</Tag>
<Tag>{event.endAction}</Tag>
</td>
{isOntimeEvent(entry) &&
{isOntimeEvent(event) &&
fieldKeys.map((field) => {
let value = '';
if (field in entry.custom) {
value = entry.custom[field];
if (field in event.custom) {
value = event.custom[field];
}
return <td key={field}>{value}</td>;
})}
<td className={style.center}>
<Tag>{entry.id}</Tag>
<Tag>{event.id}</Tag>
</td>
</tr>
{entry.note && (
{event.note && (
<tr>
<td colSpan={99} className={style.secondaryRow}>
Note: {entry.note}
Note: {event.note}
</td>
</tr>
)}
@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query';
import { AuthenticationStatus, CustomFields, ProjectRundowns } from 'ontime-types';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants';
@@ -75,9 +75,9 @@ export default function useGoogleSheet() {
};
/** applies rundown and customFields to current project */
const importRundown = async (rundowns: ProjectRundowns, customFields: CustomFields) => {
const importRundown = async (rundown: OntimeRundown, customFields: CustomFields) => {
try {
await patchData({ rundowns, customFields });
await patchData({ rundown, customFields });
// we are unable to optimistically set the rundown since we need
// it to be normalised
await queryClient.invalidateQueries({
@@ -1,4 +1,4 @@
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils';
import { create } from 'zustand';
@@ -15,8 +15,8 @@ type SheetStore = {
setAuthenticationStatus: (status: AuthenticationStatus) => void;
// we get this from a preview response
rundown: Rundown | null;
setRundown: (rundown: Rundown | null) => void;
rundown: OntimeRundown | null;
setRundown: (rundown: OntimeRundown | null) => void;
// we get this from a preview response
customFields: CustomFields | null;
@@ -60,7 +60,7 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }),
setRundown: (rundown: Rundown | null) => set({ rundown }),
setRundown: (rundown: OntimeRundown | null) => set({ rundown }),
setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react';
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import { Input } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { tooltipDelayMid } from '../../../ontimeConfig';
@@ -1,5 +1,9 @@
import { IoArrowDown, IoArrowUp, IoBan, IoFlag, IoTime } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoBan } from '@react-icons/all-files/io5/IoBan';
import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { TimerPhase, TimerType } from 'ontime-types';
import { useMessagePreview } from '../../../common/hooks/useSocket';
@@ -6,6 +6,7 @@ import AddTime from './add-time/AddTime';
import { AuxTimer } from './aux-timer/AuxTimer';
import PlaybackButtons from './playback-buttons/PlaybackButtons';
import PlaybackTimer from './playback-timer/PlaybackTimer';
import TimerSpeed from './timer-speed/TimerSpeed';
import style from './PlaybackControl.module.scss';
@@ -14,7 +15,7 @@ export default function PlaybackControl() {
return (
<div className={style.mainContainer}>
<PlaybackTimer playback={data.playback as Playback}>
<PlaybackTimer playback={data.playback}>
<AddTime playback={data.playback} />
</PlaybackTimer>
<PlaybackButtons
@@ -23,6 +24,7 @@ export default function PlaybackControl() {
selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase}
/>
<TimerSpeed isPlaying={data.playback === Playback.Play} eventIndex={data.selectedEventIndex} />
<AuxTimer />
</div>
);
@@ -1,6 +1,8 @@
import { IoAdd, IoRemove } from 'react-icons/io5';
import { memo } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { useLocalStorage } from '@mantine/hooks';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { Playback } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
@@ -15,7 +17,9 @@ interface AddTimeProps {
playback: Playback;
}
export default function AddTime(props: AddTimeProps) {
export default memo(AddTime);
function AddTime(props: AddTimeProps) {
const { playback } = props;
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
@@ -1,4 +1,8 @@
import { IoArrowDown, IoArrowUp, IoPause, IoPlay, IoStop } from 'react-icons/io5';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
@@ -1,6 +1,12 @@
import { useMemo } from 'react';
import { IoPause, IoPlay, IoPlaySkipBack, IoPlaySkipForward, IoReload, IoStop, IoTime } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { Playback, TimerPhase } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
@@ -1,9 +1,6 @@
.timeContainer {
display: grid;
grid-template-areas:
'indicators timer addtime'
'status status addtime';
grid-template-rows: 1fr auto;
grid-template-areas: 'indicators timer addtime';
grid-template-columns: 1.25rem 1fr 6.5rem;
gap: $element-inner-spacing;
}
@@ -50,29 +47,3 @@
background-color: $playback-negative;
}
}
// ---------> LABELS
.status {
grid-area: status;
height: 1.5rem;
display: flex;
gap: $section-spacing;
margin-left: 1.5rem;
}
.tag {
color: $label-gray;
font-size: calc(1rem - 2px);
margin-right: 0.25rem;
}
.time {
color: $section-white;
font-size: $text-body-size;
}
.rolltag {
color: $ontime-roll;
font-size: $text-body-size;
}
@@ -1,11 +1,8 @@
import { PropsWithChildren } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToString } from 'ontime-utils';
import { Playback, TimerPhase } from 'ontime-types';
import AppLink from '../../../../common/components/link/app-link/AppLink';
import { useTimer } from '../../../../common/hooks/useSocket';
import useReport from '../../../../common/hooks-query/useReport';
import { formatDuration } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay';
@@ -36,7 +33,16 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : '';
const rollLabel = (() => {
if (!isRolling) {
return '';
}
if (isWaiting) {
return 'Roll: Countdown to start';
}
return 'Roll mode active';
})();
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
@@ -52,55 +58,7 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
</Tooltip>
</div>
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
<div className={style.status}>
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
) : (
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={playback} />
)}
</div>
{children}
</div>
);
}
interface RunningStatusProps {
startedAt: MaybeNumber;
expectedFinish: MaybeNumber;
playback: Playback;
}
function RunningStatus(props: RunningStatusProps) {
const { startedAt, expectedFinish, playback } = props;
if (playback === Playback.Stop) {
return <StoppedStatus />;
}
const started = millisToString(startedAt);
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
const finish = millisToString(finishedMs);
return (
<>
<span className={style.start}>
<span className={style.tag}>Started at</span>
<span className={style.time}>{started}</span>
</span>
<span className={style.finish}>
<span className={style.tag}>Expect end</span>
<span className={style.time}>{finish}</span>
</span>
</>
);
}
function StoppedStatus() {
const { data } = useReport();
const hasReport = Object.keys(data).length > 0;
if (hasReport) {
return <AppLink search='settings=feature_settings__report'>Go to report management</AppLink>;
}
return null;
}
@@ -9,6 +9,7 @@
color: $timer-color;
line-height: 0.9em;
text-align: center;
align-self: center;
letter-spacing: 0.1em;
font-weight: 600;
font-size: 3.5rem;
@@ -0,0 +1,88 @@
import { Playback, TimerPhase } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import AppLink from '../../../../common/components/link/app-link/AppLink';
import { useClock, useTimerCurrent, useTimerSchedule } from '../../../../common/hooks/useSocket';
import useReport from '../../../../common/hooks-query/useReport';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import style from './TimerSpeed.module.scss';
interface MeetScheduleProps {
speed: number;
newSpeed: number;
}
/**
* We isolate this component since it has the potential to re-render on every second
*/
export default function TimerSchedule(props: MeetScheduleProps) {
const { speed, newSpeed } = props;
const { startedAt, expectedFinish, phase, playback } = useTimerSchedule();
const started = formatTime(startedAt);
const normalisedExpectedEnd = expectedFinish !== null ? expectedFinish % dayInMs : null;
const endTime = formatTime(normalisedExpectedEnd);
const isWaiting = phase === TimerPhase.Pending;
const hasNewSpeed = speed !== newSpeed;
if (isWaiting) {
return <div className={cx([style.entry, style.roll])}>Roll: Countdown to start</div>;
}
if (playback === Playback.Stop) {
return <StoppedStatus />;
}
return (
<div className={style.timers}>
<div className={style.entry}>
<div className={style.timerLabel}>Started at</div>
<div>{started}</div>
</div>
<div className={style.entry}>
<div className={style.timerLabel}>Expected end</div>
{hasNewSpeed ? <SpeedFinish newSpeed={newSpeed} /> : <div>{endTime}</div>}
</div>
</div>
);
}
// TODO: extract and test
// calculate the new finish time
function useExpectedTime(remainingTimeMs: number, speedFactor: number): number {
const { clock } = useClock();
const adjustedRemainingTimeMs = remainingTimeMs / speedFactor;
const newFinishTimeMs = clock + adjustedRemainingTimeMs;
return newFinishTimeMs;
}
function StoppedStatus() {
const { data } = useReport();
const hasReport = Object.keys(data).length > 0;
if (hasReport) {
return (
<AppLink className={style.entry} search='settings=feature_settings__report'>
Go to report management
</AppLink>
);
}
return <div className={style.entry}>No running playback</div>;
}
interface SpeedFinishProps {
newSpeed: number;
}
function SpeedFinish(props: SpeedFinishProps) {
const { newSpeed } = props;
const { current } = useTimerCurrent();
const newFinish = formatTime(useExpectedTime(current ?? 0, newSpeed));
return <div className={style.highlight}>{newFinish}</div>;
}
@@ -0,0 +1,48 @@
.panelContainer {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.inlineApart {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.inlineSiblings {
display: flex;
gap: 0.5rem;
}
.timers {
display: grid;
grid-template-columns: 1fr 1fr;
}
.entry {
display: flex;
gap: 0.25rem;
height: 1.2rem;
align-items: end;
line-height: 1em;
}
.timerLabel {
color: $label-gray;
font-size: calc(1rem - 3px);
}
.roll {
color: $ontime-roll;
}
.highlight {
color: $highlight-orange;
}
.disabled {
opacity: $opacity-disabled;
}
@@ -0,0 +1,78 @@
import { memo, useEffect, useState } from 'react';
import { Button, Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack } from '@chakra-ui/react';
import { MaybeNumber } from 'ontime-types';
import { setTimerSpeed, useTimerSpeed } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils';
import TimerSchedule from './TimerSchedule';
import style from './TimerSpeed.module.scss';
interface TimerSpeedProps {
isPlaying: boolean;
eventIndex: MaybeNumber;
}
export default memo(TimerSpeed);
function TimerSpeed(props: TimerSpeedProps) {
const { isPlaying, eventIndex } = props;
const { speed } = useTimerSpeed();
const [newSpeed, setNewSpeed] = useState(1);
const { setSpeed } = setTimerSpeed;
// when a new timer is set, we want to reset the speed
useEffect(() => {
setNewSpeed(1);
}, [eventIndex]);
const handleApply = () => setSpeed(newSpeed);
const handleReset = () => {
setNewSpeed(1.0);
setSpeed(1.0);
};
const canReset = isPlaying && speed === newSpeed && newSpeed !== 1;
const canApply = isPlaying && speed !== newSpeed;
const willChangeSpeed = speed === 1 && !canApply;
return (
<div className={style.panelContainer}>
<TimerSchedule speed={speed} newSpeed={newSpeed} />
<div className={style.inlineApart}>
<div className={style.entry}>
<span className={cx([willChangeSpeed && style.disabled])}>{`${speed}x`}</span>
{newSpeed !== speed && <span className={style.highlight}>{`${newSpeed}x`}</span>}
</div>
<div className={style.inlineSiblings}>
<Button size='sm' variant='ontime-subtle-white' onClick={handleReset} isDisabled={!canReset}>
Reset
</Button>
<Button size='sm' variant='ontime-subtle-white' onClick={handleApply} isDisabled={!canApply}>
Apply
</Button>
</div>
</div>
<Slider
variant={newSpeed === 1 ? 'ontime' : 'ontime-highlight'}
defaultValue={newSpeed}
min={0.5}
max={2.0}
step={0.01}
onChange={(v) => setNewSpeed(v)}
value={newSpeed}
isDisabled={!isPlaying}
>
<SliderMark value={0.5}>0.5x</SliderMark>
<SliderMark value={1.0}>1.0x</SliderMark>
<SliderMark value={1.5}>1.5x</SliderMark>
<SliderMark value={2.0}>2.0x</SliderMark>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</div>
);
}
+3 -1
View File
@@ -1,7 +1,9 @@
import { lazy, useCallback, useEffect } from 'react';
import { IoApps, IoClose, IoSettingsOutline } from 'react-icons/io5';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { useHotkeys } from '@mantine/hooks';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { useElectronListener } from '../../common/hooks/useElectronEvent';
@@ -1,6 +1,6 @@
import type { HTMLAttributes, LabelHTMLAttributes } from 'react';
import { IconBaseProps } from 'react-icons';
import { IoArrowUp } from 'react-icons/io5';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { type IconBaseProps } from '@react-icons/all-files/lib';
import { cx } from '../../../common/utils/styleUtils';
@@ -116,7 +116,7 @@ export default function Operator() {
// subscriptions is a MultiSelect and may have multiple values
const subscriptions = searchParams.getAll('subscribe').filter((value) => Object.hasOwn(customFields, value));
const canEdit = shouldEdit && subscriptions.length;
const canEdit = shouldEdit && subscriptions;
const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary');
@@ -126,8 +126,8 @@ export default function Operator() {
let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast'));
const { firstEvent } = getFirstEventNormal(data.entries, data.order);
const { lastEvent } = getLastEventNormal(data.entries, data.order);
const { firstEvent } = getFirstEventNormal(data.rundown, data.order);
const { lastEvent } = getLastEventNormal(data.rundown, data.order);
return (
<div className={style.operatorContainer}>
@@ -152,7 +152,7 @@ export default function Operator() {
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{data.order.map((eventId) => {
const entry = data.entries[eventId];
const entry = data.rundown[eventId];
if (isOntimeEvent(entry)) {
const isSelected = featureData.selectedEventId === entry.id;
if (isSelected) {
@@ -1,4 +1,4 @@
import { IoLocate } from 'react-icons/io5';
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { cx } from '../../../common/utils/styleUtils';

Some files were not shown because too many files have changed in this diff Show More