Compare commits

...

53 Commits

Author SHA1 Message Date
arc-alex d89309a120 lock cusheet edits 2025-05-27 15:33:39 +02:00
Shobhit Nagpal 0dcea4f2d7 fix: pass empty string fallback when parsing fields for export (#1616) 2025-05-25 19:58:41 +02:00
Shobhit Nagpal 696c016c90 feat: custom data for projects (#1571) 2025-05-23 09:04:57 +02:00
Shobhit Nagpal eed6373dbf refactor: change offset classes depending on playback state (#1611)
* refactor: assign offsetClasses based on playback state
2025-05-23 09:04:57 +02:00
Shobhit-Nagpal ac61f257c0 fix: import preview enable after error 2025-05-13 21:24:14 +02:00
Carlos Valente 8d8625aa77 bump version to 3.15.2 2025-05-13 21:24:14 +02:00
Alex Christoffer Rasmussen 1e1aa4bca5 Fix: sheet export (#1606)
* add util to get keys from labels

* look for existing keys first then create new

* save ontime key to rundown metadata

* update test
2025-05-12 13:56:25 +02:00
Alex Christoffer Rasmussen 1f0cde6d0d fix: relative offset with count to end (#1602)
* refactor merge absolute and relative offset calculation

* update test
2025-05-07 22:34:25 +02:00
Carlos Valente c4b66a9ed3 bump version to 3.15.1 2025-04-30 20:28:42 +02:00
Carlos Valente 133b3aa819 refactor: create hash of table state 2025-04-30 20:23:49 +02:00
Carlos Valente 9566fadbbb chore: upgrade table dependencies 2025-04-30 20:23:49 +02:00
Carlos Valente 62570899e8 fix: issue when user hides all columns 2025-04-30 20:23:49 +02:00
Carlos Valente c1f25f8dba bump version to 3.15.0 2025-04-20 13:52:43 +02:00
Alex Christoffer Rasmussen 37a91026d8 FIX: editing event data clears delay value (#1591)
* leave data in place in case it is not regenerated

* update comments
2025-04-20 13:51:25 +02:00
Bastian Widmer c62ce213f5 fix - fix studio clock sizing when timer seconds are hidden !1585 2025-04-20 13:28:22 +02:00
Alex Christoffer Rasmussen 529751578d refactor: lower third (#1507)
* refactor

* separate in and out time

* keep element on screen

* animate on play

* spelling

* explain animation decisions

* guard numbers from url params

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* rename safeParseNumber

* use view data

* refactor getOptionsFromParams

* rebalance animation timing

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-04-20 12:50:16 +02:00
Alex Christoffer Rasmussen dce87c3a81 Relative run mode (#1512)
* inti relative mode

* send imediat update on mode change

* add relative to test

* don't persist offset mode

* add test relative and update calc function

* pass data from ssocket

* add dev guard

* spelling and comments

* show relative in offset overview

* remove comments

* refactor: move totalDelay to _rundown

* update  clear naming and comments

* remove old testing values
2025-04-19 21:36:54 +02:00
Alex Christoffer Rasmussen a94b10cb0f Event trigger (#1557)
* crude UI

* add server functionalety

* better event trigger list

* fix default value

* remove log

* add trigger to recalculate whitelist

* use DTO type

* fix rebase

* one callback for delete and submit

* refactor

* add invalid description

* clean up commits

* cleanup

* refactor: find event triggers in `triggerAutomations`

* refactor: switch to flat array for event triggers

* prevent deleting a automation that is in use

* refactor

* refactor: extract EventEditorCustom

* move trigger options to separate file

* move trigger edit down a file level

* cleanup

* spelling

* refactor: ui review proposal

* change default value

* fix delete filter

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-04-19 21:27:07 +02:00
Shobhit Nagpal 6817263123 feat: css editor (#1542)
* feat: allow editing CSS from settings

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

refactor: style tweaks
2025-04-19 20:40:18 +02:00
Alex Christoffer Rasmussen ddd629d7db in cloud force port 4001 (#1575)
* force 4001

* only log if there is anything to change

* skip port retry in cloud
2025-04-15 21:36:59 +02:00
Carlos Valente 9d2a3d5a12 bump version to 3.14.5 2025-04-15 21:25:46 +02:00
Carlos Valente 4bc271208e refactor: improve synchronisation of external text 2025-04-15 21:25:33 +02:00
Carlos Valente dbd535dd6c refactor: timeout for offline status 2025-04-15 21:25:33 +02:00
Carlos Valente 57b7fe214a refactor: avoid old language features 2025-04-15 21:25:33 +02:00
cv b136e2856a refactor: simplify logic 2025-04-15 21:25:33 +02:00
Carlos Valente 03e68c03a9 refactor: allow cloud in iframe 2025-04-10 21:28:43 +02:00
Carlos Valente 44048a889d bump version to 3.14.4 2025-04-10 21:28:27 +02:00
arc-alex f72db62e08 add comment 2025-04-10 21:21:33 +02:00
arc-alex 333f2623a5 Fix: timeUntil past coutToEnd events 2025-04-10 21:21:33 +02:00
Alex Christoffer Rasmussen 3ebb3451e4 better browser compatibility for view params (#1566) 2025-04-09 12:39:59 +02:00
Carlos Valente 73afbacdd7 refactor: swap does not copy time data 2025-03-28 19:42:43 +01:00
Carlos Valente d08452d613 refactor: clone events fully copies data 2025-03-28 19:42:43 +01:00
Carlos Valente 4ebe6ede59 refactor: consistent overview layout 2025-03-28 19:42:43 +01:00
Carlos Valente 09a32e7612 bump version to 3.14.3 2025-03-28 19:31:31 +01:00
asharonbaltazar d81f5fcfe1 chore(ui): update icons package (#1556)
* chore: add new icons package and remove the old one

* chore: update and consolidate imports

* chore: missed import
2025-03-25 16:36:47 -04:00
Carlos Valente 0a6542df76 fix: link generation should add prefix 2025-03-18 21:56:10 +01:00
Carlos Valente b2eb140ee2 fix: allow copying link 2025-03-18 21:56:10 +01:00
Carlos Valente e28f692d3d fix: cloud should not give IP links 2025-03-18 21:56:10 +01:00
Carlos Valente dc73e7e637 bump version to 3.14.2 2025-03-18 21:48:22 +01:00
Carlos Valente b81e3a4ba5 fix: link to variables 2025-03-17 21:48:40 +01:00
Carlos Valente 6fc7b15d23 refactor: compact event block size 2025-03-17 19:48:04 +01:00
Alex Christoffer Rasmussen 99dd4b4d5c Extract client timeuntil logic (#1521)
* extract

* add to timeline

* translate Due

* fix timeline page

* begin adding some tests

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* move file

* refactor

* create hook

* refactor

* update test

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* remove translation

* explain isLinkedToLoaded

* add delay to calculation

* add delay value to test

* spelling

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-03-10 22:47:02 +01:00
Carlos Valente b60b1beeec bump version to 3.14.1 2025-03-09 22:06:40 +01:00
Alex Christoffer Rasmussen eab916f410 fix clock drift (#1531) 2025-03-08 18:13:02 +01:00
Carlos Valente 069a003787 refactor: do not send sentry errors if offline 2025-03-08 08:58:04 +01:00
Carlos Valente 9b08080b7a fix: pending card 2025-03-08 08:57:57 +01:00
Carlos Valente 8e7ab54d39 refactor: defer table while loading 2025-03-04 22:01:21 +01:00
arc-alex 4a16378786 refactor: pad block row with extra columns 2025-03-04 22:01:21 +01:00
Shobhit Nagpal e5b30770ce feat: add table navigation to cuesheet table (#1483)
* refactor: cuesheet table body
* feat: add table navigation
2025-03-04 22:01:21 +01:00
Carlos Valente 8465b04c89 bump version to 3.14.0 2025-03-04 21:54:29 +01:00
Carlos Valente 718a6ad2b7 refactor: improve editor overflow 2025-03-04 21:54:29 +01:00
Carlos Valente 6ca4bfc363 refactor: allow overriding styles of cuesheet 2025-03-03 15:58:52 +01:00
Carlos Valente 4859026989 refactor: op cannot subscribe to image fields 2025-03-03 15:58:52 +01:00
191 changed files with 2837 additions and 924 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.13.1",
"version": "3.15.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.13.1",
"version": "3.15.2",
"private": true,
"type": "module",
"dependencies": {
@@ -13,23 +13,26 @@
"@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",
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"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",
"react-simple-code-editor": "^0.14.1",
"web-vitals": "^3.1.1",
"zustand": "^5.0.3"
},
@@ -64,6 +67,7 @@
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@types/color": "^3.0.3",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "catalog:",
+8 -1
View File
@@ -158,7 +158,14 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<PCuesheet />} />
<Route path='/op' element={<POperator />} />
<Route
path='/op'
element={
<ViewLoader>
<POperator />
</ViewLoader>
}
/>
{/*/!* Protected Routes - Elements *!/*/}
<Route
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios';
import { apiEntryUrl } from './constants';
const assetsPath = `${apiEntryUrl}/assets`;
/**
* HTTP request to get css contents
*/
export async function getCSSContents(): Promise<string> {
const res = await axios.get(`${assetsPath}/css`);
return res.data;
}
/**
* HTTP request to post css contents
*/
export async function postCSSContents(css: string): Promise<void> {
await axios.post(`${assetsPath}/css`, {
css,
});
}
/**
* HTTP request to restore default css
*/
export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`);
return res.data;
}
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5';
import {
IconButton,
Input,
@@ -10,7 +11,6 @@ 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';
@@ -1,7 +1,7 @@
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';
@@ -1,6 +1,5 @@
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';
@@ -26,17 +26,22 @@ 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: runtimeStore.getState(),
store: appState,
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/all-files/io5/IoAlertCircle';
import { IoAlertCircle } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils';
@@ -25,4 +25,4 @@ export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknow
{...props}
/>
);
};
};
@@ -1,4 +1,4 @@
import { IoBan } from '@react-icons/all-files/io5/IoBan';
import { IoBan } from 'react-icons/io5';
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/all-files/io5/IoEyedrop';
import { IoEyedrop } from 'react-icons/io5';
import { ViewSettings } from 'ontime-types';
import { debounce } from '../../../utils/debounce';
@@ -17,6 +17,7 @@ export default function useReactiveTextInput(
submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean;
},
): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText);
@@ -58,7 +59,14 @@ export default function useReactiveTextInput(
setText(cleanVal);
}
}
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
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
},
[initialText, options, ref, submitCallback],
);
@@ -70,12 +78,25 @@ 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 bluring
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
}, [initialText, options, ref]);
const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
const hotKeys: HotkeyItem[] = [
[
'Escape',
(event) => {
event.preventDefault();
handleEscape();
},
{ preventDefault: true },
],
];
if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => handleSubmit(text)]);
@@ -84,7 +105,22 @@ export default function useReactiveTextInput(
if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
}
return getHotkeyHandler(hotKeys);
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);
};
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return {
@@ -3,8 +3,9 @@
font-size: $text-body-size;
text-decoration: underline;
text-underline-offset: 2px;
color: $blue-400;
&:hover {
color: $blue-400;
color: $blue-500;
}
}
@@ -1,5 +1,5 @@
import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { IoOpenOutline } from 'react-icons/io5';
import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils';
@@ -1,5 +1,5 @@
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { IoApps } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
@@ -1,5 +1,6 @@
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,
@@ -11,11 +12,6 @@ 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';
@@ -1,4 +1,4 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoLockClosedOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
@@ -1,7 +1,5 @@
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,4 +1,5 @@
import { useState } from 'react';
import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom';
import {
Button,
@@ -13,7 +14,6 @@ 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 } = option;
const { value, label, colour } = option;
return (
<MenuItemOption value={value} key={value}>
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
{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,13 +1,13 @@
import { CustomFields } from 'ontime-types';
import type { ParamField } from './types';
import type { MultiselectOptions, ParamField } from './types';
export const makeOptionsFromCustomFields = (
customFields: CustomFields,
additionalOptions: Record<string, string> = {},
additionalOptions: Readonly<Record<string, string>> = {},
filterImageType = true,
) => {
const options = structuredClone(additionalOptions);
const options = { ...additionalOptions };
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
@@ -18,6 +18,17 @@ 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,9 +12,11 @@ type OptionsField = {
defaultValue?: string;
};
export type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>;
type MultiOptionsField = {
type: 'multi-option';
values: Record<string, { value: string; label: string; colour: string }>;
values: MultiselectOptions;
defaultValue?: string;
};
+17 -2
View File
@@ -1,4 +1,4 @@
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket';
@@ -160,7 +160,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
currentBlock: state.currentBlock,
}));
@@ -170,6 +170,15 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset,
}));
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart,
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset,
}));
@@ -183,6 +192,12 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
isOnline: state.ping > 0,
}));
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
offsetMode: state.runtime.offsetMode,
}));
export const setOffsetMode = (payload: OffsetMode) => socketSendJson('offsetmode', payload);
export const usePlayback = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
@@ -8,4 +8,5 @@ export const projectDataPlaceholder: ProjectData = {
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
custom: [],
};
@@ -1,10 +1,10 @@
import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../eventsManager';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original = {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEvent.Event,
title: 'title',
@@ -14,6 +14,9 @@ describe('cloneEvent()', () => {
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
countToEnd: false,
endAction: EndAction.None,
isPublic: false,
skip: false,
@@ -21,29 +24,39 @@ describe('cloneEvent()', () => {
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
custom: {
lighting: '3',
} as EventCustomFields,
} as OntimeEvent;
};
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
// @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({});
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,
});
});
});
@@ -1,4 +1,7 @@
import { formatTime, nowInMillis } from '../time';
import { OffsetMode } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => {
@@ -38,3 +41,257 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('-01:00');
});
});
describe('calculateTimeUntilStart()', () => {
describe('Absolute offset mode', () => {
test('ontime', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: 0,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
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,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
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,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
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,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
test('running behind with too little gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: 0,
plannedStart: 0,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
});
describe('Relative offset mode', () => {
test('basic function', () => {
const test = {
timeStart: 0,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
const timeStartEvent2 = 10;
const timeStartEvent3 = 20;
//event 1 is the currently running event
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
});
test('gaps', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
});
test('added/remove time', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
test.offset = 5; // remove 5 with addtime - we are ahead of time
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
test.offset = -5; // add 5 with addtime - we are behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
});
test('next day', () => {
const test = {
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
// this event will start the current day
expect(
calculateTimeUntilStart({
...test,
timeStart: 10,
dayOffset: 0,
totalGap: 0,
isLinkedToLoaded: false,
}),
).toBe(10);
// this event will start the next day
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs);
// advancing 100ms
test.clock = 200;
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs - 100);
});
});
test('overlap with negative total gap', () => {
const test = {
dayOffset: 0,
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
isLinkedToLoaded: false,
};
// the overlap will be pushed out to the expected available time
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
test.clock = 105;
});
});
@@ -24,11 +24,11 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
skip: event.skip,
colour: event.colour,
revision: 0,
delay: 0,
dayOffset: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
custom: {},
custom: { ...event.custom },
};
};
@@ -0,0 +1,12 @@
/**
* 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;
};
}
@@ -0,0 +1,8 @@
import { MaybeString } from 'ontime-types';
export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number {
if (!value) return defaultValue;
const number = Number(value);
if (isNaN(number)) return defaultValue;
return number;
}
+3 -1
View File
@@ -48,10 +48,12 @@ export const connectSocket = () => {
websocket.onclose = () => {
console.warn('WebSocket disconnected');
setOnlineStatus(false);
if (shouldReconnect) {
reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) {
setOnlineStatus(false);
}
console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
+89 -2
View File
@@ -1,8 +1,9 @@
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import { dayInMs, 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';
/**
@@ -125,3 +126,89 @@ 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, offsetMode, actualStart, plannedStart } = useTimeUntilData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
}
/**
*
* @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;
offsetMode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
},
): number {
const {
timeStart,
dayOffset,
currentDay,
totalGap,
isLinkedToLoaded,
clock,
offset,
delay,
offsetMode,
actualStart,
plannedStart,
} = 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;
let relativeStartOffset = 0;
if (offsetMode === OffsetMode.Relative) {
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
}
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
const offsetTimeUntil = scheduledTimeUntil - offset;
if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset
return offsetTimeUntil;
}
const gapsCanCompensateForOffset = totalGap + offset >= 0;
if (gapsCanCompensateForOffset) {
// 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;
}
+1
View File
@@ -31,6 +31,7 @@ declare module '@tanstack/react-table' {
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
allowEdits: boolean;
};
}
}
+4 -3
View File
@@ -19,9 +19,10 @@ 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 isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
export const currentHostName = window.location.hostname;
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no');
// resolve entrypoint URLs
@@ -75,4 +76,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';
@@ -207,6 +207,9 @@ $inner-padding: 1rem;
&.end {
justify-content: flex-end;
}
&.apart {
justify-content: space-between;
}
}
@keyframes animloader {
@@ -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';
@@ -113,8 +113,8 @@ export function BlockQuote({ children }: { children: ReactNode }) {
return <blockquote className={style.blockquote}>{children}</blockquote>;
}
export function Error({ children }: { children: ReactNode }) {
return <div className={style.fieldError}>{children}</div>;
export function Error({ children, className }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
return <div className={cx([style.fieldError, className])}>{children}</div>;
}
export function Divider() {
@@ -136,7 +136,7 @@ type AllowedInlineTags = 'div' | 'td';
type InlineProps<C extends AllowedInlineTags> = {
as?: C;
relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end';
align?: 'start' | 'end' | 'apart';
className?: string;
};
@@ -1,8 +1,7 @@
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,
@@ -31,7 +30,7 @@ import OntimeActionForm from './OntimeActionForm';
import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/integrations/#using-variables-in-integrations';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
interface AutomationFormProps {
automation: Automation | AutomationDTO;
@@ -1,8 +1,6 @@
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,8 +1,6 @@
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';
@@ -1,9 +1,7 @@
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,7 +1,6 @@
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,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';
@@ -0,0 +1,4 @@
.wrapper {
max-height: 500px;
overflow-y: auto;
}
@@ -0,0 +1,72 @@
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
import Editor from 'react-simple-code-editor';
import Prism from 'prismjs/components/prism-core';
import 'prismjs/components/prism-css';
import 'prismjs/themes/prism-tomorrow.min.css';
import style from './StyleEditor.module.scss';
interface CodeEditorProps {
language: string;
initialValue: string;
isDirty: boolean;
setIsDirty: (value: boolean) => void;
}
const CodeEditor = forwardRef((props: CodeEditorProps, cssRef) => {
const { language, initialValue, isDirty, setIsDirty } = props;
const [code, setCode] = useState(initialValue);
const highlight = (code: string) => {
const grammar = Prism.languages[language];
return grammar ? Prism.highlight(code, grammar, language) : code;
};
const handleChange = (newCode: string) => {
setCode(newCode);
};
useImperativeHandle(cssRef, () => {
return {
getCss: () => code,
};
});
// add contents to editor on mount and any change in initialValue
useEffect(() => {
setCode(initialValue);
}, [initialValue]);
// handle dirty state on change
useEffect(() => {
if (initialValue.trim() !== code.trim() && !isDirty && code.length !== 0) {
setIsDirty(true);
}
if (initialValue.trim() === code.trim() && isDirty) {
setIsDirty(false);
}
}, [initialValue, code, isDirty, setIsDirty]);
return (
<div className={style.wrapper}>
<Editor
value={code}
padding={15}
onValueChange={handleChange}
highlight={highlight}
style={{
fontFamily: 'monospace',
fontSize: 12,
minHeight: 500,
background: '#2d2d2d', // Background of tomorrow theme
}}
/>
</div>
);
});
CodeEditor.displayName = 'StyleEditor';
export default memo(CodeEditor);
@@ -0,0 +1,14 @@
.editorActions {
width: 100%;
}
.right {
align-self: end;
text-align: right
}
.column {
align-items: start;
display: flex;
flex-direction: column;
}
@@ -0,0 +1,129 @@
import { lazy, useEffect, useRef, useState } from 'react';
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../common/api/assets';
import Info from '../../../../common/components/info/Info';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './StyleEditorModal.module.scss';
const CodeEditor = lazy(() => import('./StyleEditor'));
interface CodeEditorModalProps {
isOpen: boolean;
onClose: () => void;
}
interface CSSRef {
getCss: () => string;
}
export default function CodeEditorModal(props: CodeEditorModalProps) {
const { isOpen, onClose } = props;
const [css, setCSS] = useState('');
const [isDirty, setIsDirty] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [resetLoading, setResetLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const cssRef = useRef<CSSRef>(null);
const handleRestore = async () => {
try {
setResetLoading(true);
const defaultCss = await restoreCSSContents();
setCSS(defaultCss);
} catch (_error) {
/** no error handling for now */
} finally {
setResetLoading(false);
}
};
const handleSave = async () => {
try {
setSaveLoading(true);
if (cssRef.current) {
await postCSSContents(cssRef.current.getCss());
setCSS(cssRef.current.getCss());
setIsDirty(false);
}
} catch (_error) {
/** no error handling for now */
} finally {
setSaveLoading(false);
}
};
const clear = () => setCSS('');
useEffect(() => {
async function fetchServerCSS() {
// check for isOpen to fetch recent css
if (isOpen) {
try {
const css = await getCSSContents();
setCSS(css);
} catch (_error) {
setError('Failed to load CSS from server');
/** no error handling for now */
}
}
}
fetchServerCSS();
}, [isOpen]);
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime' isCentered>
<ModalOverlay />
<ModalContent maxWidth='max(800px, 40vw)'>
<ModalHeader>Edit CSS override</ModalHeader>
<ModalCloseButton />
<ModalBody>
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
</ModalBody>
<ModalFooter className={style.column}>
<Info>Invalid CSS will be refused by the browser</Info>
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
<Panel.InlineElements align='apart' className={style.editorActions}>
<Button
variant='ontime-ghosted'
onClick={handleRestore}
isDisabled={saveLoading || resetLoading}
isLoading={resetLoading}
>
Reset to example
</Button>
<Panel.InlineElements>
<Button variant='ontime-ghosted' onClick={clear}>
Clear
</Button>
<Button variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button
variant='ontime-filled'
onClick={handleSave}
isDisabled={saveLoading || resetLoading || !isDirty}
isLoading={saveLoading}
>
Save changes
</Button>
</Panel.InlineElements>
</Panel.InlineElements>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Button, Input, Switch } from '@chakra-ui/react';
import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import { maybeAxiosError } from '../../../../common/api/utils';
@@ -14,11 +14,14 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import CodeEditorModal from './StyleEditorModal';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettingsForm() {
const { data, status, refetch } = useViewSettings();
const { data: info, status: infoStatus } = useInfo();
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
const {
control,
@@ -103,6 +106,7 @@ export default function ViewSettingsForm() {
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<Panel.ListGroup>
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
<Panel.ListItem>
<Panel.Field
title='Override CSS styles'
@@ -115,6 +119,15 @@ export default function ViewSettingsForm() {
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
<Button
onClick={onCodeEditorOpen}
variant='ontime-subtle'
size='sm'
isDisabled={!data.overrideStyles}
width='fit-content'
>
Edit CSS override
</Button>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
@@ -0,0 +1,5 @@
declare module 'prismjs/components/prism-core' {
export * from 'prismjs';
}
declare module 'prismjs/components/prism-css';
@@ -3,3 +3,12 @@
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,13 +6,12 @@ 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 { serverURL } from '../../../../externals';
import { currentHostName, isOntimeCloud, serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './GenerateLinkForm.module.scss';
@@ -39,6 +38,12 @@ export default function GenerateLinkForm() {
formState: { errors },
} = useForm<GenerateLinkFormOptions>({
mode: 'onChange',
defaultValues: {
baseUrl: currentHostName,
path: '',
lock: false,
authenticate: false,
},
resetOptions: {
keepDirtyValues: true,
},
@@ -72,8 +77,11 @@ export default function GenerateLinkForm() {
</Info>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Host IP' description='Which IP address will be used' />
<Select variant='ontime' size='sm' {...register('baseUrl')}>
<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')}>
{infoData.networkInterfaces.map((nif) => {
return (
<option key={nif.name} value={nif.address}>
@@ -128,9 +136,9 @@ export default function GenerateLinkForm() {
>
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
</Button>
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div className={style.column}>
<QRCode size={172} value={url} className={style.qrCode} />
<ExternalLink href={url}>{url}</ExternalLink>
<div>{url}</div>
</div>
</Panel.ListItem>
</Panel.ListGroup>
@@ -1,4 +1,4 @@
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoArrowUp } from 'react-icons/io5';
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 } from '../../../../externals';
import { isDockerImage, isOntimeCloud } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
@@ -30,8 +30,12 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
<Panel.Card>
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
<Panel.Divider />
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
<InfoNif />
{!isOntimeCloud && (
<>
<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,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
@@ -23,6 +24,7 @@ type ProjectCreateFormValues = {
publicUrl?: string;
backstageInfo?: string;
backstageUrl?: string;
custom?: { title: string; value: string }[];
};
export default function ProjectCreateForm(props: ProjectCreateFromProps) {
@@ -34,6 +36,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const {
handleSubmit,
register,
control,
formState: { isSubmitting, isValid },
setFocus,
} = useForm<ProjectCreateFormValues>({
@@ -44,6 +47,11 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// set focus to first field
useEffect(() => {
setFocus('title');
@@ -59,6 +67,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
...values,
filename,
});
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
onClose();
} catch (error) {
@@ -66,6 +75,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
}
};
const handleAddCustom = () => {
append({ title: '', value: '' });
};
return (
<Panel.Section
as='form'
@@ -151,6 +164,42 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')}
/>
</label>
<Panel.Section>
<Panel.ListItem>
<Panel.Field title='Custom data' description='Add custom data for your project' />
<Button variant='ontime-subtle' onClick={handleAddCustom}>
+
</Button>
</Panel.ListItem>
{fields.map((field, idx) => (
<div key={field.id} className={style.customDataItem}>
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
placeholder={field.title}
autoComplete='off'
{...register(`custom.${idx}.title` as const)}
/>
</label>
<label>
Value
<Input
variant='ontime-filled'
size='sm'
placeholder={field.value}
autoComplete='off'
{...register(`custom.${idx}.value` as const)}
/>
</label>
<Button variant='ontime-ghosted' onClick={() => remove(idx)}>
<IoTrash />
</Button>
</div>
))}
</Panel.Section>
</Panel.Section>
</Panel.Section>
);
@@ -1,8 +1,7 @@
import { ChangeEvent, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, 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';
@@ -26,6 +25,7 @@ export default function ProjectData() {
formState: { isSubmitting, isValid, isDirty, errors },
setError,
watch,
control,
setValue,
} = useForm({
defaultValues: data,
@@ -33,6 +33,12 @@ export default function ProjectData() {
resetOptions: {
keepDirtyValues: true,
},
mode: 'onChange',
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// reset form values if data changes
@@ -78,6 +84,10 @@ export default function ProjectData() {
});
};
const handleAddCustom = () => {
append({ title: '', value: '' });
};
const onSubmit = async (formData: ProjectData) => {
try {
await postProjectData(formData);
@@ -97,7 +107,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
@@ -232,6 +242,69 @@ export default function ProjectData() {
{...register('backstageUrl')}
/>
</label>
<Panel.Section style={{ marginTop: 0 }}>
<Panel.ListItem>
<Panel.Field title='Custom data' description='' />
<Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
Add
</Button>
</Panel.ListItem>
{fields.length > 0 &&
fields.map((field, idx) => {
const rowErrors = errors.custom?.[idx] as
| {
title?: { message?: string };
value?: { message?: string };
}
| undefined;
return (
<div key={field.id} className={style.customDataItem}>
<div>
<div className={style.titleRow}>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
defaultValue={field.title}
placeholder='Title of your custom data'
autoComplete='off'
{...register(`custom.${idx}.title`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
</label>
<Button
size='sm'
variant='ontime-subtle'
color='#FA5656' // $red-500
onClick={() => remove(idx)}
leftIcon={<IoTrash />}
>
Delete Entry
</Button>
</div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
</div>
<label>
Value
<Textarea
variant='ontime-filled'
resize='none'
size='sm'
defaultValue={field.value}
autoComplete='off'
placeholder='Text of your custom data'
{...register(`custom.${idx}.value`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
</label>
</div>
);
})}
</Panel.Section>
</Panel.Section>
</Panel.Card>
</Panel.Section>
@@ -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,
@@ -57,3 +57,18 @@
height: auto;
}
}
.customDataItem {
display: contents;
width: 100%;
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
@@ -1,7 +1,6 @@
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,7 +1,6 @@
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 {
@@ -104,6 +103,7 @@ export default function SourcesPanel() {
};
const handleSubmitImportPreview = async (importMap: ImportMap) => {
setError(''); // to clear previous error
if (importFlow === 'excel') {
try {
const previewData = await importRundownPreviewExcel(importMap);
@@ -1,8 +1,7 @@
import { useState } from 'react';
import { useEffect, 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';
@@ -29,6 +28,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
control,
handleSubmit,
register,
setValue,
formState: { errors, isValid },
} = useForm<NamedImportMap>({
mode: 'onChange',
@@ -46,6 +46,14 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
// Set first sheet as default worksheet when 'event schedule' sheet is not there
useEffect(() => {
if (!worksheetNames || worksheetNames.length === 0) return;
if (!worksheetNames.includes(namedImportMap.Worksheet)) {
setValue('Worksheet', worksheetNames[0], { shouldValidate: true, shouldDirty: true });
}
}, [worksheetNames, setValue, namedImportMap.Worksheet]);
const handleExport = async (values: NamedImportMap) => {
setLoading('export');
const importMap = convertToImportMap(values);
@@ -1,5 +1,5 @@
import { Fragment } from 'react';
import { IoLink } from '@react-icons/all-files/io5/IoLink';
import { IoLink } from 'react-icons/io5';
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types';
import { millisToString } from 'ontime-utils';
@@ -1,10 +1,7 @@
import { useEffect, useRef } from 'react';
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
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';
import { cx } from '../../../common/utils/styleUtils';
import style from './InputRow.module.scss';
@@ -13,50 +10,53 @@ interface InputRowProps {
placeholder: string;
text: string;
visible: boolean;
actionHandler: () => void;
changeHandler: (newValue: string) => void;
}
export default function InputRow(props: InputRowProps) {
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
export default function InputRow(props: PropsWithChildren<InputRowProps>) {
const { label, placeholder, text, visible, changeHandler, children } = props;
const [value, setValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef(0);
// sync cursor position with text
useEffect(() => {
if (inputRef.current) {
if (inputRef.current && inputRef.current !== document.activeElement) {
inputRef.current.selectionStart = cursorPositionRef.current;
inputRef.current.selectionEnd = cursorPositionRef.current;
}
}, [text]);
// synchronise external text
useEffect(() => {
if (inputRef.current !== document.activeElement) {
setValue(text);
}
}, [text]);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
cursorPositionRef.current = event.target.selectionStart ?? 0;
setValue(event.target.value);
changeHandler(event.target.value);
};
return (
<div className={style.inputRow}>
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label>
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
{label}
</label>
<div className={style.inputItems}>
<Input
id={label}
ref={inputRef}
size='sm'
variant='ontime-filled'
value={text}
value={value}
onChange={handleInputChange}
placeholder={placeholder}
/>
<TooltipActionBtn
clickHandler={actionHandler}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
{children}
</div>
</div>
);
@@ -1,4 +1,8 @@
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../ontimeConfig';
import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl';
@@ -23,8 +27,17 @@ function TimerMessageInput() {
text={text}
visible={visible}
changeHandler={(newValue) => setMessage.timerText(newValue)}
actionHandler={() => setMessage.timerVisible(!visible)}
/>
>
<TooltipActionBtn
clickHandler={() => setMessage.timerVisible(!visible)}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label='Toggle timer message visibility'
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</InputRow>
);
}
@@ -46,7 +59,16 @@ function ExternalInput() {
text={text}
visible={visible}
changeHandler={(newValue) => setMessage.externalText(newValue)}
actionHandler={toggleExternal}
/>
>
<TooltipActionBtn
clickHandler={toggleExternal}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label='Toggle external message visibility'
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</InputRow>
);
}
@@ -1,9 +1,5 @@
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';
@@ -1,7 +1,6 @@
import { IoAdd, IoRemove } from 'react-icons/io5';
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';
@@ -1,8 +1,4 @@
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 { IoArrowDown, IoArrowUp, IoPause, IoPlay, IoStop } from 'react-icons/io5';
import { Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
@@ -1,12 +1,6 @@
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 -3
View File
@@ -1,9 +1,7 @@
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 { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { type IconBaseProps } from '@react-icons/all-files/lib';
import { IconBaseProps } from 'react-icons';
import { IoArrowUp } from 'react-icons/io5';
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;
const canEdit = shouldEdit && subscriptions.length;
const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary');
@@ -1,4 +1,4 @@
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { IoLocate } from 'react-icons/io5';
import { cx } from '../../../common/utils/styleUtils';
@@ -2,6 +2,7 @@ import { CustomFields } from 'ontime-types';
import {
getTimeOption,
makeCustomFieldSelectOptions,
makeOptionsFromCustomFields,
OptionTitle,
} from '../../common/components/view-params-editor/constants';
@@ -9,13 +10,7 @@ import { ViewOption } from '../../common/components/view-params-editor/types';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.entries(customFields).reduce<
Record<string, { value: string; label: string; colour: string }>
>((acc, [key, field]) => {
acc[key] = { value: key, label: field.label, colour: field.colour };
return acc;
}, {});
const customFieldSelect = makeCustomFieldSelectOptions(customFields);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -46,6 +41,13 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
type: 'multi-option',
values: customFieldSelect,
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an highlighted custom field by long pressing',
type: 'boolean',
defaultValue: false,
},
],
},
{
@@ -59,13 +61,6 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
type: 'boolean',
defaultValue: false,
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
],
},
];
@@ -45,6 +45,10 @@
@include ellipsis-overflow;
}
.offset {
color: $muted-gray;
}
.ahead {
color: $playback-ahead;
}
@@ -1,4 +1,5 @@
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
@@ -163,14 +164,14 @@ function ProgressOverview() {
}
function RuntimeOverview() {
const { clock, offset } = useRuntimePlaybackOverview();
const { clock, offset, playback } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset);
const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead;
const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
return (
<>
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} />
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />
<TimeColumn label='Time now' value={formatedTime(clock)} />
</>
);
@@ -10,13 +10,16 @@ interface TimeLayoutProps {
muted?: boolean;
daySpan?: number;
className?: string;
testId?: string;
}
export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) {
export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) {
return (
<div className={style.column}>
<span className={style.label}>{label}</span>
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
<span className={cx([style.clock, muted && style.muted, className])} data-testid={testId}>
{value}
</span>
</div>
);
}
+7 -5
View File
@@ -7,7 +7,6 @@ import {
isOntimeEvent,
isPlayableEvent,
MaybeString,
OntimeEvent,
PlayableEvent,
Playback,
RundownCached,
@@ -272,7 +271,7 @@ export default function Rundown({ data }: RundownProps) {
let isNextDay = false;
let totalGap = 0;
const isEditMode = appMode === AppMode.Edit;
let currentDay = 0;
let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
@@ -299,7 +298,11 @@ export default function Rundown({ data }: RundownProps) {
if (isPlayableEvent(entry)) {
isNextDay = checkIsNextDay(entry, lastEvent);
totalGap += !isPast ? entry.gap : 0;
if (!isPast) {
totalGap += entry.gap;
// We also include countToEnd in this test as the behavior of a linked event coming after a countToEnd is simelar to an unlinked event
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null && !lastEvent?.countToEnd;
}
if (isNewLatest(entry, lastEvent)) {
// populate previous entry
thisEvent = entry;
@@ -313,7 +316,6 @@ export default function Rundown({ data }: RundownProps) {
const hasCursor = entry.id === cursor;
if (isLoaded) {
isPast = false;
currentDay = (entry as OntimeEvent).dayOffset;
}
return (
@@ -336,7 +338,7 @@ export default function Rundown({ data }: RundownProps) {
isRolling={featureData.playback === Playback.Roll}
isNextDay={isNextDay}
totalGap={totalGap}
currentDay={currentDay}
isLinkedToLoaded={isLinkedToLoaded}
/>
</div>
</div>
@@ -1,5 +1,5 @@
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import Empty from '../../common/components/state/Empty';
@@ -39,7 +39,7 @@ interface RundownEntryProps {
playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event
totalGap: number;
currentDay: number;
isLinkedToLoaded: boolean;
}
export default function RundownEntry(props: RundownEntryProps) {
@@ -56,7 +56,7 @@ export default function RundownEntry(props: RundownEntryProps) {
eventIndex,
isNextDay,
totalGap,
currentDay,
isLinkedToLoaded,
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
@@ -178,8 +178,9 @@ export default function RundownEntry(props: RundownEntryProps) {
isRolling={isRolling}
gap={data.gap}
isNextDay={isNextDay}
dayOffset={data.dayOffset - currentDay}
dayOffset={data.dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
actionHandler={actionHandler}
/>
);
@@ -1,7 +1,7 @@
import { useRef } from 'react';
import { IoReorderTwo } from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { OntimeBlock } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
@@ -1,5 +1,5 @@
import { IoTrash } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
@@ -51,6 +51,8 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
fontWeight='600'
letterSpacing='0.25px'
paddingLeft='0'
size='sm'
fontSize='1rem'
/>
);
}
@@ -1,10 +1,8 @@
import { useEffect, useRef } from 'react';
import { IoCheckmarkDone, IoClose, IoReorderTwo } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoCheckmarkDone } from '@react-icons/all-files/io5/IoCheckmarkDone';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { OntimeDelay } from 'ontime-types';
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
@@ -15,10 +15,11 @@ $skip-opacity: 0.2;
'binder ... ... ...';
grid-template-columns: $block-binder-width 3rem 1fr auto;
grid-template-rows: 0.25rem 2.25rem 2.25rem 2.25rem 0.25rem;
grid-template-rows: 0.125rem 2rem 2rem 2rem 0.125rem;
align-items: center;
padding-right: $block-clearance;
gap: 2px;
row-gap: 0.25rem;
transition-property: background-color;
transition-duration: $transition-time-feedback;
@@ -120,7 +121,7 @@ $skip-opacity: 0.2;
display: flex;
flex-direction: column;
margin: 0 0.5rem;
gap: 0.5rem;
gap: 0.25rem;
}
.eventTimers {
@@ -1,15 +1,17 @@
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
IoAdd,
IoDuplicateOutline,
IoLink,
IoPeople,
IoPeopleOutline,
IoReorderTwo,
IoSwapVertical,
IoTrash,
IoUnlink,
} from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
import { IoLink } from '@react-icons/all-files/io5/IoLink';
import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
@@ -51,6 +53,7 @@ interface EventBlockProps {
isNextDay: boolean;
dayOffset: number;
totalGap: number;
isLinkedToLoaded: boolean;
actionHandler: (
action: EventItemActions,
payload?:
@@ -91,6 +94,7 @@ export default function EventBlock(props: EventBlockProps) {
isNextDay,
dayOffset,
totalGap,
isLinkedToLoaded,
actionHandler,
} = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
@@ -310,6 +314,7 @@ export default function EventBlock(props: EventBlockProps) {
dayOffset={dayOffset}
isPast={isPast}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
/>
)}
</div>
@@ -1,17 +1,18 @@
import { memo, useEffect, useState } from 'react';
import {
IoArrowDown,
IoArrowUp,
IoBan,
IoFlag,
IoPeople,
IoPlay,
IoPlayForward,
IoPlaySkipForward,
IoStop,
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 { IoPeople } from '@react-icons/all-files/io5/IoPeople';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoPlayForward } from '@react-icons/all-files/io5/IoPlayForward';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
@@ -47,6 +48,7 @@ interface EventBlockInnerProps {
dayOffset: number;
isPast: boolean;
totalGap: number;
isLinkedToLoaded: boolean;
}
function EventBlockInner(props: EventBlockInnerProps) {
@@ -72,6 +74,7 @@ function EventBlockInner(props: EventBlockInnerProps) {
dayOffset,
isPast,
totalGap,
isLinkedToLoaded,
} = props;
const [renderInner, setRenderInner] = useState(false);
@@ -120,11 +123,13 @@ function EventBlockInner(props: EventBlockInnerProps) {
<EventBlockChip
className={style.chipSection}
id={eventId}
trueTimeStart={timeStart + dayOffset * dayInMs}
timeStart={timeStart}
delay={delay}
dayOffset={dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isPast={isPast}
isLoaded={loaded}
totalGap={totalGap}
isLinkedAndNext={isNext && linkStart !== null}
duration={duration}
/>
)}
@@ -1,29 +1,31 @@
import { useMemo } from 'react';
import { IoCheckmarkCircle } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { usePlayback, useTimelineStatus } from '../../../../common/hooks/useSocket';
import { usePlayback } from '../../../../common/hooks/useSocket';
import useReport from '../../../../common/hooks-query/useReport';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { formatDuration, formatTime, useTimeUntilStart } from '../../../../common/utils/time';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import style from './EventBlockChip.module.scss';
interface EventBlockChipProps {
id: string;
trueTimeStart: number;
timeStart: number;
delay: number;
dayOffset: number;
isPast: boolean;
isLoaded: boolean;
className: string;
totalGap: number;
isLinkedAndNext: boolean;
duration: number;
isLinkedToLoaded: boolean;
}
export default function EventBlockChip(props: EventBlockChipProps) {
const { trueTimeStart, isPast, isLoaded, className, totalGap, isLinkedAndNext, id, duration } = props;
const { timeStart, delay, dayOffset, isPast, isLoaded, className, totalGap, id, duration, isLinkedToLoaded } = props;
const { playback } = usePlayback();
if (isLoaded) {
@@ -41,7 +43,13 @@ export default function EventBlockChip(props: EventBlockChipProps) {
return (
<Tooltip label='Expected time until start' openDelay={tooltipDelayFast}>
<div className={className}>
<EventUntil trueTimeStart={trueTimeStart} totalGap={totalGap} isLinkedAndNext={isLinkedAndNext} />
<EventUntil
timeStart={timeStart}
delay={delay}
dayOffset={dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
/>
</div>
</Tooltip>
);
@@ -51,18 +59,17 @@ export default function EventBlockChip(props: EventBlockChipProps) {
}
interface EventUntilProps {
trueTimeStart: number;
timeStart: number;
delay: number;
dayOffset: number;
totalGap: number;
isLinkedAndNext: boolean;
isLinkedToLoaded: boolean;
}
function EventUntil(props: EventUntilProps) {
const { trueTimeStart, totalGap, isLinkedAndNext } = props;
const { clock, offset } = useTimelineStatus();
const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props;
const consumedOffset = isLinkedAndNext ? offset : Math.min(offset + totalGap, 0);
const offsetTimestart = trueTimeStart - consumedOffset;
const timeUntil = offsetTimestart - clock;
const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded });
const isDue = timeUntil < MILLIS_PER_SECOND;
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
@@ -1,9 +1,5 @@
import { memo, MouseEvent } from 'react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoRemoveCircle } from '@react-icons/all-files/io5/IoRemoveCircle';
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from 'react-icons/io5';
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
import { useEventAction } from '../../../../common/hooks/useEventAction';
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown';
import { cx } from '../../../common/utils/styleUtils';
import EventEditor from './EventEditor';
@@ -37,7 +38,7 @@ export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
}
return (
<div className={style.eventEditor} data-testid='editor-container'>
<div className={cx([style.eventEditor, style.inModal])} data-testid='editor-container'>
<EventEditor event={event} />
</div>
);
@@ -1,8 +1,12 @@
.eventEditor {
max-height: 80vh;
max-height: 100%;
display: flex;
flex-direction: column;
overflow-x: auto;
&.inModal {
max-height: 80vh;
}
}
.content {
@@ -1,17 +1,15 @@
import { CSSProperties, useCallback } from 'react';
import { useCallback } from 'react';
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEventAction } from '../../../common/hooks/useEventAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import * as Editor from '../../editors/editor-utils/EditorUtils';
import EventEditorImage from './composite/EventEditorImage';
import EventCustom from './composite/EventEditorCustom';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import EventTextArea from './composite/EventTextArea';
import EventTextInput from './composite/EventTextInput';
import EventEditorTriggers from './composite/EventEditorTriggers';
import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss';
@@ -77,52 +75,16 @@ export default function EventEditor(props: EventEditorProps) {
<div className={style.column}>
<Editor.Title>
Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage</AppLink>}
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
{Object.keys(customFields).map((fieldKey) => {
const key = `${event.id}-${fieldKey}`;
const fieldName = `custom-${fieldKey}`;
const initialValue = event.custom[fieldKey] ?? '';
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label;
if (customFields[fieldKey].type === 'string') {
return (
<EventTextArea
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
);
}
if (customFields[fieldKey].type === 'image') {
return (
<div key={key} className={style.customImage}>
<EventTextInput
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder='Paste image URL'
submitHandler={handleSubmit}
className={style.decorated}
maxLength={255}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
<EventEditorImage src={initialValue} />
</div>
);
}
// we should have exhausted all types by now
return null;
})}
<EventCustom fields={customFields} handleSubmit={handleSubmit} event={event} />
</div>
<div className={style.column}>
<Editor.Title>
Automations
{isEditor && <AppLink search='settings=automation__automations'>Manage Automations</AppLink>}
</Editor.Title>
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
</div>
</div>
);
@@ -0,0 +1,68 @@
import { CSSProperties, Fragment } from 'react';
import { CustomFields, OntimeEvent } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { EditorUpdateFields } from '../EventEditor';
import EventEditorImage from './EventEditorImage';
import EventTextArea from './EventTextArea';
import EventTextInput from './EventTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorCustomProps {
fields: CustomFields;
event: OntimeEvent;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
export default function EventEditorCustom(props: EventEditorCustomProps) {
const { fields: customFields, handleSubmit, event } = props;
return (
<Fragment>
{Object.keys(customFields).map((fieldKey) => {
const key = `${event.id}-${fieldKey}`;
const fieldName = `custom-${fieldKey}`;
const initialValue = event.custom[fieldKey] ?? '';
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label;
if (customFields[fieldKey].type === 'string') {
return (
<EventTextArea
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
);
}
if (customFields[fieldKey].type === 'image') {
return (
<div key={key} className={style.customImage}>
<EventTextInput
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder='Paste image URL'
submitHandler={handleSubmit}
className={style.decorated}
maxLength={255}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
<EventEditorImage src={initialValue} />
</div>
);
}
// we should have exhausted all types by now
return null;
})}
</Fragment>
);
}
@@ -1,6 +1,6 @@
import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5';
import { Select, Switch, Tooltip } from '@chakra-ui/react';
import { IoInformationCircle } from '@react-icons/all-files/io5/IoInformationCircle';
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
@@ -0,0 +1,30 @@
.triggerForm {
padding-block: 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr auto auto;
gap: 0.5rem;
align-items: center;
}
.trigger {
padding: 0.25rem 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr auto;
align-items: center;
&:nth-child(even) {
background-color: $white-1;
}
& > span {
width: fit-content;
}
}
.errorLabel {
color: $red-500;
}
.success {
color: $green-500;
}

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