mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c460988f1 | |||
| 00dfdb6d9f | |||
| 0ff5f1b79b | |||
| 549c291f56 | |||
| d89309a120 | |||
| 0dcea4f2d7 | |||
| 696c016c90 | |||
| eed6373dbf | |||
| ac61f257c0 | |||
| 8d8625aa77 | |||
| 1e1aa4bca5 | |||
| 1f0cde6d0d | |||
| c4b66a9ed3 | |||
| 133b3aa819 | |||
| 9566fadbbb | |||
| 62570899e8 | |||
| c1f25f8dba | |||
| 37a91026d8 | |||
| c62ce213f5 | |||
| 529751578d | |||
| dce87c3a81 | |||
| a94b10cb0f | |||
| 6817263123 | |||
| ddd629d7db | |||
| 9d2a3d5a12 | |||
| 4bc271208e | |||
| dbd535dd6c | |||
| 57b7fe214a | |||
| b136e2856a | |||
| 03e68c03a9 | |||
| 44048a889d | |||
| f72db62e08 | |||
| 333f2623a5 | |||
| 3ebb3451e4 | |||
| 73afbacdd7 | |||
| d08452d613 | |||
| 4ebe6ede59 | |||
| 09a32e7612 | |||
| d81f5fcfe1 | |||
| 0a6542df76 | |||
| b2eb140ee2 | |||
| e28f692d3d | |||
| dc73e7e637 | |||
| b81e3a4ba5 | |||
| 6fc7b15d23 | |||
| 99dd4b4d5c | |||
| b60b1beeec | |||
| eab916f410 | |||
| 069a003787 | |||
| 9b08080b7a | |||
| 8e7ab54d39 | |||
| 4a16378786 | |||
| e5b30770ce | |||
| 8465b04c89 | |||
| 718a6ad2b7 | |||
| 6ca4bfc363 | |||
| 4859026989 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.13.1",
|
||||
"version": "3.16.1-alpha.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.13.1",
|
||||
"version": "3.16.1-alpha.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:",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -20,8 +20,17 @@ export async function generateUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
lock: boolean,
|
||||
lockMainFields: boolean,
|
||||
lockCustomFields: boolean,
|
||||
authenticate: boolean,
|
||||
): Promise<string> {
|
||||
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
|
||||
const res = await axios.post(`${sessionPath}/url`, {
|
||||
baseUrl,
|
||||
path,
|
||||
lock,
|
||||
lockMainFields,
|
||||
lockCustomFields,
|
||||
authenticate,
|
||||
});
|
||||
return res.data.url;
|
||||
}
|
||||
|
||||
@@ -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,12 +1,9 @@
|
||||
import { type PropsWithChildren } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
import style from './AppLink.module.scss';
|
||||
|
||||
interface AppLinkProps {
|
||||
className?: string;
|
||||
search: string;
|
||||
}
|
||||
|
||||
@@ -15,13 +12,13 @@ interface AppLinkProps {
|
||||
* Handles the path to respect Ontime Clouds base URL
|
||||
*/
|
||||
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
|
||||
const { className, search, children } = props;
|
||||
const { search, children } = props;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => navigate({ search });
|
||||
|
||||
return (
|
||||
<button onClick={handleClick} className={cx([style.link, className])}>
|
||||
<button onClick={handleClick} className={style.link}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -41,17 +41,6 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
|
||||
visible: state.message.timer.secondarySource === 'external',
|
||||
}));
|
||||
|
||||
export const useTimerSchedule = createSelector((state: RuntimeStore) => ({
|
||||
startedAt: state.timer.startedAt,
|
||||
expectedFinish: state.timer.expectedFinish,
|
||||
phase: state.timer.phase,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
export const useTimerCurrent = createSelector((state: RuntimeStore) => ({
|
||||
current: state.timer.current,
|
||||
}));
|
||||
|
||||
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
|
||||
blink: state.message.timer.blink,
|
||||
blackout: state.message.timer.blackout,
|
||||
@@ -125,15 +114,6 @@ export const setAuxTimer = {
|
||||
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
||||
};
|
||||
|
||||
export const useTimerSpeed = createSelector((state: RuntimeStore) => ({
|
||||
speed: state.timer.speed,
|
||||
}));
|
||||
|
||||
export const setTimerSpeed = {
|
||||
getSpeed: () => socketSendJson('get-speed'),
|
||||
setSpeed: (speed: number) => socketSendJson('set-speed', speed),
|
||||
};
|
||||
|
||||
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
}));
|
||||
@@ -180,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,
|
||||
}));
|
||||
@@ -190,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,
|
||||
}));
|
||||
@@ -203,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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,13 +64,13 @@ describe('getRouteFromPreset()', () => {
|
||||
describe('handle url sharing edge cases', () => {
|
||||
it('finds the correct preset when the url contains extra arguments', () => {
|
||||
const location = resolvePath('/demopage?locked=true&token=123');
|
||||
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy()
|
||||
})
|
||||
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('appends the feature params to the alias', () => {
|
||||
const location = resolvePath('/demopage?locked=true&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123')
|
||||
})
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,29 +79,30 @@ describe('generatePathFromPreset()', () => {
|
||||
['timer?user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
|
||||
['timer?user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
|
||||
])('generates a path from a preset: %s', (path, alias, expected) => {
|
||||
expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected);
|
||||
expect(generatePathFromPreset(path, alias, null, null, null, null)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('appends the feature params to the alias', () => {
|
||||
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
|
||||
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123', null, null)).toBe(
|
||||
'timer?user=guest&alias=demopage&locked=true&token=123',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('arePathsEquivalent()', () => {
|
||||
it("checks whether the paths match", () => {
|
||||
it('checks whether the paths match', () => {
|
||||
expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy();
|
||||
expect(arePathsEquivalent('timer', 'timer')).toBeTruthy();
|
||||
expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy();
|
||||
})
|
||||
});
|
||||
|
||||
it("checks whether the params match", () => {
|
||||
it('checks whether the params match', () => {
|
||||
expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy();
|
||||
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
|
||||
})
|
||||
});
|
||||
|
||||
it("considers edge cases for the url sharing feature", () => {
|
||||
it('considers edge cases for the url sharing feature', () => {
|
||||
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
|
||||
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -45,11 +45,14 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
||||
const locked = searchParams.get('locked');
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const lmain = searchParams.get('lmain');
|
||||
const lcustom = searchParams.get('lcustom');
|
||||
|
||||
// we need to check if the whole url is an alias
|
||||
const foundPreset = urlPresets.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
|
||||
if (foundPreset) {
|
||||
// if so, we can redirect to the preset path
|
||||
return generatePathFromPreset(foundPreset.pathAndParams, foundPreset.alias, locked, token);
|
||||
return generatePathFromPreset(foundPreset.pathAndParams, foundPreset.alias, locked, token, lmain, lcustom);
|
||||
}
|
||||
|
||||
// if the current url is not an alias, we check if the alias is in the search parameters
|
||||
@@ -63,7 +66,7 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
||||
for (const preset of urlPresets) {
|
||||
// if the page has a known enabled alias, we check if we need to redirect
|
||||
if (preset.alias === presetOnPage && preset.enabled) {
|
||||
const newPath = generatePathFromPreset(preset.pathAndParams, preset.alias, locked, token);
|
||||
const newPath = generatePathFromPreset(preset.pathAndParams, preset.alias, locked, token, lmain, lcustom);
|
||||
if (!arePathsEquivalent(currentPath, newPath)) {
|
||||
// if current path is out of date
|
||||
// return new path so we can redirect
|
||||
@@ -77,7 +80,14 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
||||
/**
|
||||
* Handles generating a path and search parameters from a preset
|
||||
*/
|
||||
export function generatePathFromPreset(pathAndParams: string, alias: string, locked: string | null, token: string | null ): string {
|
||||
export function generatePathFromPreset(
|
||||
pathAndParams: string,
|
||||
alias: string,
|
||||
locked: string | null,
|
||||
token: string | null,
|
||||
lmain: string | null,
|
||||
lcustom: string | null,
|
||||
): string {
|
||||
const path = resolvePath(pathAndParams);
|
||||
const searchParams = new URLSearchParams(path.search);
|
||||
|
||||
@@ -93,6 +103,14 @@ export function generatePathFromPreset(pathAndParams: string, alias: string, loc
|
||||
searchParams.set('token', token);
|
||||
}
|
||||
|
||||
if (lmain) {
|
||||
searchParams.set('lmain', lmain);
|
||||
}
|
||||
|
||||
if (lcustom) {
|
||||
searchParams.set('lcustom', lcustom);
|
||||
}
|
||||
|
||||
// return path concatenated without the leading slash
|
||||
return `${path.pathname}?${searchParams}`.substring(1);
|
||||
}
|
||||
@@ -106,16 +124,16 @@ export function generatePathFromPreset(pathAndParams: string, alias: string, loc
|
||||
export function arePathsEquivalent(currentPath: string, newPath: string): boolean {
|
||||
const currentUrl = new URL(currentPath, document.location.origin);
|
||||
const newUrl = new URL(newPath, document.location.origin);
|
||||
|
||||
|
||||
// check path
|
||||
if (currentUrl.pathname !== newUrl.pathname) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// check search params
|
||||
// if the params match, we dont need further checks
|
||||
if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
// if there is no match, we check the edge cases for the url sharing feature
|
||||
|
||||
@@ -31,6 +31,8 @@ declare module '@tanstack/react-table' {
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
allowMainEdits: boolean;
|
||||
allowCustomEdits: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
-1
@@ -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
-3
@@ -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
-2
@@ -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
-1
@@ -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);
|
||||
+14
@@ -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';
|
||||
@@ -21,6 +20,8 @@ interface GenerateLinkFormOptions {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
lock: boolean;
|
||||
lockMainFields: boolean;
|
||||
lockCustomFields: boolean;
|
||||
authenticate: boolean;
|
||||
}
|
||||
|
||||
@@ -36,9 +37,17 @@ export default function GenerateLinkForm() {
|
||||
handleSubmit,
|
||||
register,
|
||||
setError,
|
||||
formState: { errors },
|
||||
formState: { errors, dirtyFields },
|
||||
} = useForm<GenerateLinkFormOptions>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
baseUrl: currentHostName,
|
||||
path: '',
|
||||
lock: false,
|
||||
lockMainFields: false,
|
||||
lockCustomFields: false,
|
||||
authenticate: false,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
@@ -48,7 +57,14 @@ export default function GenerateLinkForm() {
|
||||
try {
|
||||
setFormState('loading');
|
||||
const baseUrl = linkToOtherHost(options.baseUrl);
|
||||
const url = await generateUrl(baseUrl, options.path, options.lock, options.authenticate);
|
||||
const url = await generateUrl(
|
||||
baseUrl,
|
||||
options.path,
|
||||
options.lock,
|
||||
options.lockMainFields,
|
||||
options.lockCustomFields,
|
||||
options.authenticate,
|
||||
);
|
||||
await copyToClipboard(url);
|
||||
setUrl(url);
|
||||
setFormState('success');
|
||||
@@ -72,8 +88,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}>
|
||||
@@ -111,6 +130,18 @@ export default function GenerateLinkForm() {
|
||||
/>
|
||||
<Switch variant='ontime' size='lg' {...register('lock')} />
|
||||
</Panel.ListItem>
|
||||
{dirtyFields.lock && (
|
||||
<>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Lock main field edits' description='Prevent edits to main fields' />
|
||||
<Switch variant='ontime' size='lg' {...register('lockMainFields')} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Lock custom field edits' description='Prevent edits to custom fields' />
|
||||
<Switch variant='ontime' size='lg' {...register('lockCustomFields')} />
|
||||
</Panel.ListItem>
|
||||
</>
|
||||
)}
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
|
||||
<Switch variant='ontime' size='lg' {...register('authenticate')} />
|
||||
@@ -128,9 +159,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);
|
||||
|
||||
+11
-3
@@ -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
-1
@@ -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';
|
||||
|
||||
@@ -6,7 +6,6 @@ import AddTime from './add-time/AddTime';
|
||||
import { AuxTimer } from './aux-timer/AuxTimer';
|
||||
import PlaybackButtons from './playback-buttons/PlaybackButtons';
|
||||
import PlaybackTimer from './playback-timer/PlaybackTimer';
|
||||
import TimerSpeed from './timer-speed/TimerSpeed';
|
||||
|
||||
import style from './PlaybackControl.module.scss';
|
||||
|
||||
@@ -15,7 +14,7 @@ export default function PlaybackControl() {
|
||||
|
||||
return (
|
||||
<div className={style.mainContainer}>
|
||||
<PlaybackTimer playback={data.playback}>
|
||||
<PlaybackTimer playback={data.playback as Playback}>
|
||||
<AddTime playback={data.playback} />
|
||||
</PlaybackTimer>
|
||||
<PlaybackButtons
|
||||
@@ -24,7 +23,6 @@ export default function PlaybackControl() {
|
||||
selectedEventIndex={data.selectedEventIndex}
|
||||
timerPhase={data.timerPhase}
|
||||
/>
|
||||
<TimerSpeed isPlaying={data.playback === Playback.Play} eventIndex={data.selectedEventIndex} />
|
||||
<AuxTimer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
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';
|
||||
|
||||
@@ -17,9 +15,7 @@ interface AddTimeProps {
|
||||
playback: Playback;
|
||||
}
|
||||
|
||||
export default memo(AddTime);
|
||||
|
||||
function AddTime(props: AddTimeProps) {
|
||||
export default function AddTime(props: AddTimeProps) {
|
||||
const { playback } = props;
|
||||
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
|
||||
|
||||
|
||||
@@ -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,6 +1,9 @@
|
||||
.timeContainer {
|
||||
display: grid;
|
||||
grid-template-areas: 'indicators timer addtime';
|
||||
grid-template-areas:
|
||||
'indicators timer addtime'
|
||||
'status status addtime';
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-columns: 1.25rem 1fr 6.5rem;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
@@ -47,3 +50,29 @@
|
||||
background-color: $playback-negative;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------> LABELS
|
||||
|
||||
.status {
|
||||
grid-area: status;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
gap: $section-spacing;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
color: $label-gray;
|
||||
font-size: calc(1rem - 2px);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: $section-white;
|
||||
font-size: $text-body-size;
|
||||
}
|
||||
|
||||
.rolltag {
|
||||
color: $ontime-roll;
|
||||
font-size: $text-body-size;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { Playback, TimerPhase } from 'ontime-types';
|
||||
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
|
||||
import { dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import useReport from '../../../../common/hooks-query/useReport';
|
||||
import { formatDuration } from '../../../../common/utils/time';
|
||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||
|
||||
@@ -33,16 +36,7 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||
const hasAddedTime = Boolean(timer.addedTime);
|
||||
|
||||
const rollLabel = (() => {
|
||||
if (!isRolling) {
|
||||
return '';
|
||||
}
|
||||
if (isWaiting) {
|
||||
return 'Roll: Countdown to start';
|
||||
}
|
||||
|
||||
return 'Roll mode active';
|
||||
})();
|
||||
const rollLabel = isRolling ? 'Roll mode active' : '';
|
||||
|
||||
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
|
||||
|
||||
@@ -58,7 +52,55 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
||||
</Tooltip>
|
||||
</div>
|
||||
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
|
||||
<div className={style.status}>
|
||||
{isWaiting ? (
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
) : (
|
||||
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={playback} />
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RunningStatusProps {
|
||||
startedAt: MaybeNumber;
|
||||
expectedFinish: MaybeNumber;
|
||||
playback: Playback;
|
||||
}
|
||||
function RunningStatus(props: RunningStatusProps) {
|
||||
const { startedAt, expectedFinish, playback } = props;
|
||||
|
||||
if (playback === Playback.Stop) {
|
||||
return <StoppedStatus />;
|
||||
}
|
||||
|
||||
const started = millisToString(startedAt);
|
||||
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
|
||||
const finish = millisToString(finishedMs);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={style.start}>
|
||||
<span className={style.tag}>Started at</span>
|
||||
<span className={style.time}>{started}</span>
|
||||
</span>
|
||||
<span className={style.finish}>
|
||||
<span className={style.tag}>Expect end</span>
|
||||
<span className={style.time}>{finish}</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StoppedStatus() {
|
||||
const { data } = useReport();
|
||||
const hasReport = Object.keys(data).length > 0;
|
||||
|
||||
if (hasReport) {
|
||||
return <AppLink search='settings=feature_settings__report'>Go to report management</AppLink>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
color: $timer-color;
|
||||
line-height: 0.9em;
|
||||
text-align: center;
|
||||
align-self: center;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
font-size: 3.5rem;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Playback, TimerPhase } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
||||
import { useClock, useTimerCurrent, useTimerSchedule } from '../../../../common/hooks/useSocket';
|
||||
import useReport from '../../../../common/hooks-query/useReport';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../../common/utils/time';
|
||||
|
||||
import style from './TimerSpeed.module.scss';
|
||||
|
||||
interface MeetScheduleProps {
|
||||
speed: number;
|
||||
newSpeed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* We isolate this component since it has the potential to re-render on every second
|
||||
*/
|
||||
export default function TimerSchedule(props: MeetScheduleProps) {
|
||||
const { speed, newSpeed } = props;
|
||||
const { startedAt, expectedFinish, phase, playback } = useTimerSchedule();
|
||||
|
||||
const started = formatTime(startedAt);
|
||||
const normalisedExpectedEnd = expectedFinish !== null ? expectedFinish % dayInMs : null;
|
||||
const endTime = formatTime(normalisedExpectedEnd);
|
||||
|
||||
const isWaiting = phase === TimerPhase.Pending;
|
||||
const hasNewSpeed = speed !== newSpeed;
|
||||
|
||||
if (isWaiting) {
|
||||
return <div className={cx([style.entry, style.roll])}>Roll: Countdown to start</div>;
|
||||
}
|
||||
|
||||
if (playback === Playback.Stop) {
|
||||
return <StoppedStatus />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.timers}>
|
||||
<div className={style.entry}>
|
||||
<div className={style.timerLabel}>Started at</div>
|
||||
<div>{started}</div>
|
||||
</div>
|
||||
<div className={style.entry}>
|
||||
<div className={style.timerLabel}>Expected end</div>
|
||||
{hasNewSpeed ? <SpeedFinish newSpeed={newSpeed} /> : <div>{endTime}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: extract and test
|
||||
// calculate the new finish time
|
||||
function useExpectedTime(remainingTimeMs: number, speedFactor: number): number {
|
||||
const { clock } = useClock();
|
||||
const adjustedRemainingTimeMs = remainingTimeMs / speedFactor;
|
||||
const newFinishTimeMs = clock + adjustedRemainingTimeMs;
|
||||
return newFinishTimeMs;
|
||||
}
|
||||
|
||||
function StoppedStatus() {
|
||||
const { data } = useReport();
|
||||
const hasReport = Object.keys(data).length > 0;
|
||||
|
||||
if (hasReport) {
|
||||
return (
|
||||
<AppLink className={style.entry} search='settings=feature_settings__report'>
|
||||
Go to report management
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={style.entry}>No running playback</div>;
|
||||
}
|
||||
|
||||
interface SpeedFinishProps {
|
||||
newSpeed: number;
|
||||
}
|
||||
|
||||
function SpeedFinish(props: SpeedFinishProps) {
|
||||
const { newSpeed } = props;
|
||||
const { current } = useTimerCurrent();
|
||||
|
||||
const newFinish = formatTime(useExpectedTime(current ?? 0, newSpeed));
|
||||
|
||||
return <div className={style.highlight}>{newFinish}</div>;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
.panelContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.inlineApart {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.inlineSiblings {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.timers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
height: 1.2rem;
|
||||
align-items: end;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.timerLabel {
|
||||
color: $label-gray;
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
.roll {
|
||||
color: $ontime-roll;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: $highlight-orange;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Button, Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack } from '@chakra-ui/react';
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { setTimerSpeed, useTimerSpeed } from '../../../../common/hooks/useSocket';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
|
||||
import TimerSchedule from './TimerSchedule';
|
||||
|
||||
import style from './TimerSpeed.module.scss';
|
||||
|
||||
interface TimerSpeedProps {
|
||||
isPlaying: boolean;
|
||||
eventIndex: MaybeNumber;
|
||||
}
|
||||
|
||||
export default memo(TimerSpeed);
|
||||
|
||||
function TimerSpeed(props: TimerSpeedProps) {
|
||||
const { isPlaying, eventIndex } = props;
|
||||
const { speed } = useTimerSpeed();
|
||||
const [newSpeed, setNewSpeed] = useState(1);
|
||||
const { setSpeed } = setTimerSpeed;
|
||||
|
||||
// when a new timer is set, we want to reset the speed
|
||||
useEffect(() => {
|
||||
setNewSpeed(1);
|
||||
}, [eventIndex]);
|
||||
|
||||
const handleApply = () => setSpeed(newSpeed);
|
||||
const handleReset = () => {
|
||||
setNewSpeed(1.0);
|
||||
setSpeed(1.0);
|
||||
};
|
||||
|
||||
const canReset = isPlaying && speed === newSpeed && newSpeed !== 1;
|
||||
const canApply = isPlaying && speed !== newSpeed;
|
||||
const willChangeSpeed = speed === 1 && !canApply;
|
||||
|
||||
return (
|
||||
<div className={style.panelContainer}>
|
||||
<TimerSchedule speed={speed} newSpeed={newSpeed} />
|
||||
<div className={style.inlineApart}>
|
||||
<div className={style.entry}>
|
||||
<span className={cx([willChangeSpeed && style.disabled])}>{`${speed}x`}</span>
|
||||
{newSpeed !== speed && <span className={style.highlight}>{` ⇢ ${newSpeed}x`}</span>}
|
||||
</div>
|
||||
<div className={style.inlineSiblings}>
|
||||
<Button size='sm' variant='ontime-subtle-white' onClick={handleReset} isDisabled={!canReset}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button size='sm' variant='ontime-subtle-white' onClick={handleApply} isDisabled={!canApply}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
variant={newSpeed === 1 ? 'ontime' : 'ontime-highlight'}
|
||||
defaultValue={newSpeed}
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.01}
|
||||
onChange={(v) => setNewSpeed(v)}
|
||||
value={newSpeed}
|
||||
isDisabled={!isPlaying}
|
||||
>
|
||||
<SliderMark value={0.5}>0.5x</SliderMark>
|
||||
<SliderMark value={1.0}>1.0x</SliderMark>
|
||||
<SliderMark value={1.5}>1.5x</SliderMark>
|
||||
<SliderMark value={2.0}>2.0x</SliderMark>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,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';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user