mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-01 13:38:03 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e285ee8f3 | |||
| 1ccf2108eb | |||
| db56ac8049 | |||
| 0660f014c8 | |||
| 41c26778cb | |||
| 804e4ad33e | |||
| 5be60ff6c3 | |||
| bf8ebe942e | |||
| bdd216d881 | |||
| 797a0edf57 | |||
| c4886a617c | |||
| a79ae7e0c4 | |||
| afa62a6e38 | |||
| 1b55dbc170 | |||
| 2316bbebac | |||
| 884ab0b67b | |||
| 58239af8bb | |||
| c59e070076 |
@@ -28,19 +28,19 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Run code quality per package
|
||||
- name: React - Run linter
|
||||
- name: React - Run linter + TypeScript checks
|
||||
if: always()
|
||||
run: pnpm lint
|
||||
run: pnpm lint && tsc --noEmit
|
||||
working-directory: ./apps/client
|
||||
|
||||
- name: Server - Run linter
|
||||
- name: Server - Run linter + TypeScript checks
|
||||
if: always()
|
||||
run: pnpm lint
|
||||
run: pnpm lint && tsc --noEmit
|
||||
working-directory: ./apps/server
|
||||
|
||||
- name: Utils - Run linter
|
||||
- name: Utils - Run linter + TypeScript checks
|
||||
if: always()
|
||||
run: pnpm lint
|
||||
run: pnpm lint && tsc --noEmit
|
||||
working-directory: ./packages/utils
|
||||
|
||||
- name: Types - Run linter
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm lint
|
||||
pnpm lint-staged
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ You can generate a distribution for your OS by running the following steps.
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Build the UI and server__ by running `turbo build:local`
|
||||
- __Build the UI and server__ by running `turbo build:electron`
|
||||
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
|
||||
|
||||
The build distribution assets will be at `.apps/electron/dist`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "2.16.2",
|
||||
"version": "2.21.3",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.7.0",
|
||||
@@ -12,8 +12,8 @@
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^7.46.0",
|
||||
"@sentry/tracing": "^7.46.0",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"@tanstack/react-query-devtools": "^4.29.0",
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"@tanstack/react-query-devtools": "^5.8.4",
|
||||
"@tanstack/react-table": "^8.9.2",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.2.0",
|
||||
@@ -38,8 +38,10 @@
|
||||
"dev": "cross-env BROWSER=none vite",
|
||||
"build": "vite build",
|
||||
"build:local": "cross-env NODE_ENV=local vite build",
|
||||
"build:electron": "cross-env NODE_ENV=local vite build",
|
||||
"build:docker": "vite build",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
"test": "vitest",
|
||||
"test:pipeline": "vitest run",
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
|
||||
@@ -58,7 +60,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "^0.4.0",
|
||||
"@tanstack/eslint-plugin-query": "^4.26.2",
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
"@testing-library/user-event": "^14.1.1",
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
export const PROJECT_DATA = ['project'];
|
||||
export const ALIASES = ['aliases'];
|
||||
export const USERFIELDS = ['userFields'];
|
||||
export const RUNDOWN_TABLE_KEY = 'rundown';
|
||||
export const RUNDOWN_TABLE = [RUNDOWN_TABLE_KEY];
|
||||
export const RUNDOWN = ['rundown'];
|
||||
export const APP_INFO = ['appinfo'];
|
||||
export const OSC_SETTINGS = ['oscSettings'];
|
||||
export const APP_SETTINGS = ['appSettings'];
|
||||
|
||||
@@ -42,12 +42,12 @@ export function logAxiosError(prepend: string, error: unknown) {
|
||||
* Utility function invalidates react-query caches
|
||||
*/
|
||||
export async function invalidateAllCaches() {
|
||||
await ontimeQueryClient.invalidateQueries(['project']);
|
||||
await ontimeQueryClient.invalidateQueries(['aliases']);
|
||||
await ontimeQueryClient.invalidateQueries(['userFields']);
|
||||
await ontimeQueryClient.invalidateQueries(['rundown']);
|
||||
await ontimeQueryClient.invalidateQueries(['appinfo']);
|
||||
await ontimeQueryClient.invalidateQueries(['oscSettings']);
|
||||
await ontimeQueryClient.invalidateQueries(['appSettings']);
|
||||
await ontimeQueryClient.invalidateQueries(['viewSettings']);
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||
import { GetRundownCached, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { rundownURL } from './apiConstants';
|
||||
|
||||
@@ -7,6 +7,16 @@ import { rundownURL } from './apiConstants';
|
||||
* @description HTTP request to fetch all events
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function fetchCachedRundown(): Promise<GetRundownCached> {
|
||||
const res = await axios.get(`${rundownURL}/cached`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use fetchCachedRundown instead
|
||||
* @description HTTP request to fetch all events
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function fetchRundown(): Promise<OntimeRundown> {
|
||||
const res = await axios.get(rundownURL);
|
||||
return res.data;
|
||||
|
||||
@@ -19,9 +19,11 @@ interface TextInputProps extends BaseProps {
|
||||
isTextArea?: false;
|
||||
}
|
||||
|
||||
type ResizeOptions = 'horizontal' | 'vertical' | 'none';
|
||||
|
||||
interface TextAreaProps extends BaseProps {
|
||||
isTextArea: true;
|
||||
resize?: 'horizontal' | 'vertical' | 'none';
|
||||
resize?: ResizeOptions;
|
||||
}
|
||||
|
||||
type InputProps = TextInputProps | TextAreaProps;
|
||||
@@ -35,7 +37,7 @@ export default function TextInput(props: InputProps) {
|
||||
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
|
||||
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
|
||||
|
||||
let resize = 'none';
|
||||
let resize: ResizeOptions = 'none';
|
||||
if (isTextArea) {
|
||||
resize = (props as TextAreaProps)?.resize ?? 'none';
|
||||
}
|
||||
|
||||
@@ -212,6 +212,7 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
onKeyDown={onKeyDownHandler}
|
||||
value={value}
|
||||
maxLength={8}
|
||||
autoComplete='off'
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ $progress-bar-br: 3px;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
@@ -31,7 +32,6 @@ $progress-bar-br: 3px;
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
border-radius: $progress-bar-br;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -39,12 +39,10 @@ $progress-bar-br: 3px;
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
border-radius: 0 $progress-bar-br $progress-bar-br 0;
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-danger {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
border-radius: 0 $progress-bar-br $progress-bar-br 0;
|
||||
}
|
||||
+16
-12
@@ -3,7 +3,7 @@ import { clamp } from '../../utils/math';
|
||||
import './MultiPartProgressBar.scss';
|
||||
|
||||
interface MultiPartProgressBar {
|
||||
now: number;
|
||||
now: number | null;
|
||||
complete: number;
|
||||
normalColor: string;
|
||||
warning: number;
|
||||
@@ -17,23 +17,27 @@ interface MultiPartProgressBar {
|
||||
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
|
||||
|
||||
const percentComplete = 100 - clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
|
||||
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
|
||||
|
||||
const dangerWidth = clamp((danger / complete) * 100, 0, 100);
|
||||
const warningWidth = clamp((warning / complete) * 100, 0, 100);
|
||||
|
||||
return (
|
||||
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
|
||||
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
||||
<div
|
||||
className='multiprogress-bar__bg-warning'
|
||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||
/>
|
||||
<div
|
||||
className='multiprogress-bar__bg-danger'
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||
/>
|
||||
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
||||
{now !== null && (
|
||||
<>
|
||||
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
||||
<div
|
||||
className='multiprogress-bar__bg-warning'
|
||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||
/>
|
||||
<div
|
||||
className='multiprogress-bar__bg-danger'
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||
/>
|
||||
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import useFullscreen from '../../hooks/useFullscreen';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
|
||||
import RenameClientModal from './rename-client-modal/RenameClientModal';
|
||||
@@ -28,13 +27,14 @@ function NavigationMenu() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const toggleMenu = () => setShowMenu((prev) => !prev);
|
||||
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen });
|
||||
|
||||
// show on mouse move
|
||||
useEffect(() => {
|
||||
let fadeOut: NodeJS.Timeout | null = null;
|
||||
const setShowMenuTrue = () => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { formatTime } from '../../utils/time';
|
||||
|
||||
import './Schedule.scss';
|
||||
|
||||
const formatOptions = {
|
||||
format: 'hh:mm a',
|
||||
};
|
||||
|
||||
interface ScheduleItemProps {
|
||||
selected: 'past' | 'now' | 'future';
|
||||
timeStart: number;
|
||||
@@ -16,8 +21,8 @@ interface ScheduleItemProps {
|
||||
export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
const { selected, timeStart, timeEnd, title, presenter, backstageEvent, colour, skip } = props;
|
||||
|
||||
const start = formatTime(timeStart, { format: 'hh:mm' });
|
||||
const end = formatTime(timeEnd, { format: 'hh:mm' });
|
||||
const start = formatTime(timeStart, formatOptions);
|
||||
const end = formatTime(timeEnd, formatOptions);
|
||||
const userColour = colour !== '' ? colour : '';
|
||||
const selectStyle = `entry--${selected}`;
|
||||
|
||||
@@ -25,7 +30,12 @@ export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
|
||||
<div className='entry-times'>
|
||||
<span className='entry-colour' style={{ backgroundColor: userColour }} />
|
||||
{`${start} → ${end} ${backstageEvent ? '*' : ''}`}
|
||||
<div style={{ display: 'flex' }}>
|
||||
<SuperscriptTime time={start} />
|
||||
{' → '}
|
||||
<SuperscriptTime time={end} />
|
||||
{backstageEvent ? '*' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
{presenter && <div className='entry-presenter'>{presenter}</div>}
|
||||
|
||||
@@ -51,6 +51,12 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
}
|
||||
}, [searchParams, onOpen]);
|
||||
|
||||
/**
|
||||
* disabling this for now, this feature needs more testing
|
||||
* - we seem to have a bug where this is conflicting with the aliases
|
||||
* - I wonder if the logic below needs to be inside an effect,
|
||||
* both localStorage and searchParams should trigger a component update when they change
|
||||
|
||||
useEffect(() => {
|
||||
const viewParamsObjFromLocalStorage = storedViewParams[pathname];
|
||||
|
||||
@@ -59,10 +65,12 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
setSearchParams(defaultSearchParams);
|
||||
}
|
||||
|
||||
// linter is asking for `setSearchParams` in the useEffect deps
|
||||
// rule is disabled since adding `setSearchParams` results in unnecessary re-renders
|
||||
// linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps
|
||||
// rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storedViewParams, pathname]);
|
||||
}, [pathname]);
|
||||
|
||||
*/
|
||||
|
||||
const onEditDrawerClose = () => {
|
||||
onClose();
|
||||
|
||||
@@ -70,7 +70,39 @@ export const CLOCK_OPTIONS: ParamField[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const TIMER_OPTIONS: ParamField[] = [TIME_FORMAT_OPTION];
|
||||
export const TIMER_OPTIONS: ParamField[] = [
|
||||
TIME_FORMAT_OPTION,
|
||||
{
|
||||
id: 'hideClock',
|
||||
title: 'Hide Time Now',
|
||||
description: 'Hides the Time Now field',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'hideCards',
|
||||
title: 'Hide Cards',
|
||||
description: 'Hides the Now and Next cards',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'hideProgress',
|
||||
title: 'Hide progress bar',
|
||||
description: 'Hides the progress bar',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'hideMessage',
|
||||
title: 'Hide Presenter Message',
|
||||
description: 'Prevents the screen from displaying messages from the presenter',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'hideExternal',
|
||||
title: 'Hide External',
|
||||
description: 'Prevents the screen from displaying the external field',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
|
||||
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
const [operatorAuth, setOperatorAuth] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'loading') return;
|
||||
if (status === 'pending') return;
|
||||
if (!data) return;
|
||||
const previousEditor = sessionStorage.getItem(storageKeys.editor);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-nocheck -- working on it
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
|
||||
@@ -24,20 +26,20 @@ export default function useOscSettings() {
|
||||
}
|
||||
|
||||
export function useOscSettingsMutation() {
|
||||
const { isLoading, mutateAsync } = useMutation({
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: postOSC,
|
||||
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
||||
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
||||
});
|
||||
return { isLoading, mutateAsync };
|
||||
return { isPending, mutateAsync };
|
||||
}
|
||||
|
||||
export function usePostOscSubscriptions() {
|
||||
const { isLoading, mutateAsync } = useMutation({
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: postOscSubscriptions,
|
||||
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
||||
});
|
||||
return { isLoading, mutateAsync };
|
||||
return { isPending, mutateAsync };
|
||||
}
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { GetRundownCached } from 'ontime-types';
|
||||
|
||||
import { queryRefetchInterval } from '../../ontimeConfig';
|
||||
import { RUNDOWN_TABLE } from '../api/apiConstants';
|
||||
import { fetchRundown } from '../api/eventsApi';
|
||||
import { RUNDOWN } from '../api/apiConstants';
|
||||
import { fetchCachedRundown } from '../api/eventsApi';
|
||||
|
||||
const cachedRundownPlaceholder = { rundown: [], revision: -1 };
|
||||
|
||||
// TODO: can we leverage structural sharing to see if data has changed?
|
||||
export default function useRundown() {
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: RUNDOWN_TABLE,
|
||||
queryFn: fetchRundown,
|
||||
placeholderData: [],
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<GetRundownCached>({
|
||||
queryKey: RUNDOWN,
|
||||
queryFn: fetchCachedRundown,
|
||||
placeholderData: cachedRundownPlaceholder,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchInterval,
|
||||
networkMode: 'always',
|
||||
// structuralSharing: (oldData: GetRundownCached | undefined, newData: GetRundownCached) => {
|
||||
// if (oldData === undefined) {
|
||||
// return cachedRundownPlaceholder;
|
||||
// }
|
||||
// const hasDataChanged = oldData?.revision === newData.revision;
|
||||
// return hasDataChanged ? oldData : newData;
|
||||
// },
|
||||
});
|
||||
|
||||
return { data, status, isError, refetch };
|
||||
return { data: data?.rundown ?? [], status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||
import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
|
||||
import { RUNDOWN } from '../api/apiConstants';
|
||||
import { logAxiosError } from '../api/apiUtils';
|
||||
import {
|
||||
ReorderEntry,
|
||||
@@ -36,7 +36,7 @@ export const useEventAction = () => {
|
||||
// Fetch anyway, just to be sure
|
||||
mutationFn: requestPostEvent,
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
@@ -67,8 +67,10 @@ export const useEventAction = () => {
|
||||
after: options?.after,
|
||||
};
|
||||
|
||||
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
|
||||
|
||||
if (newEvent?.cue === undefined) {
|
||||
newEvent.cue = getCueCandidate(queryClient.getQueryData(RUNDOWN_TABLE) || [], options?.after);
|
||||
newEvent.cue = getCueCandidate(rundown, options?.after);
|
||||
}
|
||||
|
||||
// hard coding duration value to be as expected for now
|
||||
@@ -78,7 +80,6 @@ export const useEventAction = () => {
|
||||
}
|
||||
|
||||
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
|
||||
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
|
||||
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
|
||||
if (previousEvent !== undefined && previousEvent.type === 'event') {
|
||||
newEvent.timeStart = previousEvent.timeEnd;
|
||||
@@ -115,25 +116,35 @@ export const useEventAction = () => {
|
||||
// we optimistically update here
|
||||
onMutate: async (newEvent) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, newEvent.id]);
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousEvent = queryClient.getQueryData([RUNDOWN_TABLE_KEY, newEvent.id]);
|
||||
|
||||
// optimistically update object
|
||||
queryClient.setQueryData([RUNDOWN_TABLE_KEY, newEvent.id], newEvent);
|
||||
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||
|
||||
if (previousData) {
|
||||
// optimistically update object
|
||||
const optimisticRundown = [...previousData.rundown];
|
||||
const index = optimisticRundown.findIndex((event) => event.id === newEvent.id);
|
||||
if (index > -1) {
|
||||
// @ts-expect-error -- we expect the event types to match
|
||||
optimisticRundown[index] = { ...optimisticRundown[index], ...newEvent };
|
||||
|
||||
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
|
||||
}
|
||||
}
|
||||
|
||||
// Return a context with the previous and new events
|
||||
return { previousEvent, newEvent };
|
||||
return { previousData, newEvent };
|
||||
},
|
||||
// Mutation fails, rollback undoes optimist update
|
||||
onError: (_error, _newEvent, context) => {
|
||||
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
|
||||
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
||||
},
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries([RUNDOWN_TABLE_KEY]);
|
||||
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
@@ -161,28 +172,37 @@ export const useEventAction = () => {
|
||||
// we optimistically update here
|
||||
onMutate: async (eventId) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, eventId]);
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
|
||||
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||
|
||||
const filtered = [...(previousEvents as OntimeRundown)].filter((e) => e.id !== eventId);
|
||||
if (previousData) {
|
||||
// optimistically update object
|
||||
const optimisticRundown = [...previousData.rundown];
|
||||
const index = optimisticRundown.findIndex((event) => event.id === eventId);
|
||||
if (index > -1) {
|
||||
optimisticRundown.splice(index, 1);
|
||||
|
||||
// optimistically update object
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, filtered);
|
||||
queryClient.setQueryData(RUNDOWN, {
|
||||
rundown: optimisticRundown,
|
||||
revision: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Return a context with the previous and new events
|
||||
return { previousEvents };
|
||||
return { previousData };
|
||||
},
|
||||
|
||||
// Mutation fails, rollback undoes optimist update
|
||||
onError: (_error, _eventId, context) => {
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
|
||||
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
||||
},
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
@@ -210,26 +230,26 @@ export const useEventAction = () => {
|
||||
// we optimistically update here
|
||||
onMutate: async () => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
|
||||
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||
|
||||
// optimistically update object
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, []);
|
||||
queryClient.setQueryData(RUNDOWN, { rundown: [], revision: -1 });
|
||||
|
||||
// Return a context with the previous and new events
|
||||
return { previousEvents };
|
||||
return { previousData };
|
||||
},
|
||||
|
||||
// Mutation fails, rollback undos optimist update
|
||||
onError: (_error, _eventId, context) => {
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
|
||||
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
||||
},
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
@@ -253,7 +273,7 @@ export const useEventAction = () => {
|
||||
mutationFn: requestApplyDelay,
|
||||
// Mutation finished, failed or successful
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
@@ -281,30 +301,32 @@ export const useEventAction = () => {
|
||||
// we optimistically update here
|
||||
onMutate: async (data) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
|
||||
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||
|
||||
const e = [...(previousEvents as OntimeRundown)];
|
||||
const [reorderedItem] = e.splice(data.from, 1);
|
||||
e.splice(data.to, 0, reorderedItem);
|
||||
if (previousData) {
|
||||
// optimistically update object
|
||||
const optimisticRundown = [...previousData.rundown];
|
||||
const [reorderedItem] = optimisticRundown.splice(data.from, 1);
|
||||
optimisticRundown.splice(data.to, 0, reorderedItem);
|
||||
|
||||
// optimistically update object
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, e);
|
||||
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
|
||||
}
|
||||
|
||||
// Return a context with the previous and new events
|
||||
return { previousEvents };
|
||||
return { previousData };
|
||||
},
|
||||
|
||||
// Mutation fails, rollback undoes optimist update
|
||||
onError: (_error, _eventId, context) => {
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
|
||||
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
||||
},
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
@@ -337,31 +359,32 @@ export const useEventAction = () => {
|
||||
// we optimistically update here
|
||||
onMutate: async ({ from, to }) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
|
||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||
|
||||
// Snapshot the previous value
|
||||
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
|
||||
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||
if (previousData) {
|
||||
// optimistically update object
|
||||
const fromEventIndex = previousData.rundown.findIndex((event) => event.id === from);
|
||||
const toEventIndex = previousData.rundown.findIndex((event) => event.id === to);
|
||||
|
||||
const fromEventIndex = rundown.findIndex((event) => event.id === from);
|
||||
const toEventIndex = rundown.findIndex((event) => event.id === to);
|
||||
const optimisticRundown = swapOntimeEvents(previousData.rundown, fromEventIndex, toEventIndex);
|
||||
|
||||
const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
|
||||
|
||||
// optimistically update object
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, previousEvents);
|
||||
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
|
||||
}
|
||||
|
||||
// Return a context with the previous events
|
||||
return { previousEvents };
|
||||
return { previousData };
|
||||
},
|
||||
|
||||
// Mutation fails, rollback undoes optimist update
|
||||
onError: (_error, _eventId, context) => {
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
|
||||
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
||||
},
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ interface UseFollowComponentProps {
|
||||
scrollRef: MutableRefObject<HTMLElement | null>;
|
||||
doFollow: boolean;
|
||||
topOffset?: number;
|
||||
setScrollFlag?: () => void;
|
||||
setScrollFlag?: (newValue: boolean) => void;
|
||||
}
|
||||
|
||||
export default function useFollowComponent(props: UseFollowComponentProps) {
|
||||
@@ -34,14 +34,15 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
|
||||
}
|
||||
|
||||
if (followRef.current && scrollRef.current) {
|
||||
setScrollFlag?.(true);
|
||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||
window.requestAnimationFrame(() => {
|
||||
setScrollFlag?.();
|
||||
scrollToComponent(
|
||||
followRef as MutableRefObject<HTMLElement>,
|
||||
scrollRef as MutableRefObject<HTMLElement>,
|
||||
topOffset,
|
||||
);
|
||||
setScrollFlag?.(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-nocheck -- working on it
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface WebkitDocument extends Document {
|
||||
|
||||
@@ -1,53 +1,46 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* @description utility hook to handle state in local storage
|
||||
* @param key
|
||||
* @param initialValue
|
||||
*/
|
||||
export const useLocalStorage = <T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] => {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(`ontime-${key}`);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
const STORAGE_EVENT = 'ontime-storage';
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (event: StorageEvent) => {
|
||||
if (event.storageArea === window.localStorage && event.key === key) {
|
||||
try {
|
||||
const newValue = event.newValue ? JSON.parse(event.newValue) : initialValue;
|
||||
setStoredValue(newValue);
|
||||
} catch (_) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
};
|
||||
function getSnapshot(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(`ontime-${key}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
function getParsedJson<T>(localStorageValue: string | null, initialValue: T): T {
|
||||
try {
|
||||
return localStorageValue ? JSON.parse(localStorageValue) : initialValue;
|
||||
} catch {
|
||||
return initialValue;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, [initialValue, key]);
|
||||
export const useLocalStorage = <T>(key: string, initialValue: T) => {
|
||||
const localStorageValue = useSyncExternalStore(subscribe, () => getSnapshot(key));
|
||||
const parsedLocalStorageValue = getParsedJson(localStorageValue, initialValue);
|
||||
|
||||
/**
|
||||
* @description Set value to local storage
|
||||
* @param value
|
||||
*/
|
||||
const setValue = (value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
// Allow value to be a function so we have same API as useState
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
const setLocalStorageValue = (value: T | ((val: T) => T)) => {
|
||||
// Allow value to be a function so we have same API as useState
|
||||
const valueToStore = value instanceof Function ? value(parsedLocalStorageValue) : value;
|
||||
|
||||
setStoredValue(valueToStore);
|
||||
window.localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
|
||||
window.dispatchEvent(new StorageEvent(STORAGE_EVENT));
|
||||
};
|
||||
return [storedValue, setValue];
|
||||
|
||||
return [parsedLocalStorageValue, setLocalStorageValue] as const;
|
||||
};
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
window.addEventListener(STORAGE_EVENT, callback);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(STORAGE_EVENT, callback);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { MouseEvent, SyntheticEvent, TouchEvent, useMemo, useRef } from 'react';
|
||||
|
||||
type LongPressOptions = {
|
||||
threshold?: number;
|
||||
onStart?: (e: SyntheticEvent) => void;
|
||||
onFinish?: (e: SyntheticEvent) => void;
|
||||
onCancel?: (e: SyntheticEvent) => void;
|
||||
};
|
||||
|
||||
type LongPressFns = {
|
||||
onMouseDown: (e: MouseEvent) => void;
|
||||
onMouseUp: (e: MouseEvent) => void;
|
||||
onMouseLeave: (e: MouseEvent) => void;
|
||||
onTouchStart: (e: TouchEvent) => void;
|
||||
onTouchEnd: (e: TouchEvent) => void;
|
||||
};
|
||||
|
||||
export default function useLongPress(callback: () => void, options: LongPressOptions = {}): LongPressFns {
|
||||
const { threshold = 400, onStart, onFinish, onCancel } = options;
|
||||
const isLongPressActive = useRef(false);
|
||||
const isPressed = useRef(false);
|
||||
const timerId = useRef<NodeJS.Timer>();
|
||||
|
||||
return useMemo(() => {
|
||||
const start = (event: SyntheticEvent) => {
|
||||
if (onStart) {
|
||||
onStart(event);
|
||||
}
|
||||
|
||||
isPressed.current = true;
|
||||
timerId.current = setTimeout(() => {
|
||||
callback();
|
||||
isLongPressActive.current = true;
|
||||
}, threshold);
|
||||
};
|
||||
|
||||
const cancel = (event: SyntheticEvent) => {
|
||||
if (isLongPressActive.current) {
|
||||
if (onFinish) {
|
||||
onFinish(event);
|
||||
}
|
||||
} else if (isPressed.current) {
|
||||
if (onCancel) {
|
||||
onCancel(event);
|
||||
}
|
||||
}
|
||||
|
||||
isLongPressActive.current = false;
|
||||
isPressed.current = false;
|
||||
|
||||
if (timerId.current) {
|
||||
clearTimeout(timerId.current);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
onMouseDown: start,
|
||||
onMouseUp: cancel,
|
||||
onMouseLeave: cancel,
|
||||
onTouchStart: start,
|
||||
onTouchEnd: cancel,
|
||||
};
|
||||
}, [callback, threshold, onCancel, onFinish, onStart]);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const useMessageControl = () => {
|
||||
timerMessage: state.timerMessage,
|
||||
publicMessage: state.publicMessage,
|
||||
lowerMessage: state.lowerMessage,
|
||||
externalMessage: state.externalMessage,
|
||||
onAir: state.onAir,
|
||||
});
|
||||
|
||||
@@ -40,6 +41,8 @@ export const setMessage = {
|
||||
publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload),
|
||||
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload),
|
||||
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload),
|
||||
externalText: (payload: string) => socketSendJson('set-external-message-text', payload),
|
||||
externalVisible: (payload: boolean) => socketSendJson('set-external-message-visible', payload),
|
||||
onAir: (payload: boolean) => socketSendJson('set-onAir', payload),
|
||||
timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload),
|
||||
timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { QueryClient } from '@tanstack/react-query';
|
||||
export const ontimeQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
cacheTime: 1000 * 60 * 10, // 10 min
|
||||
gcTime: 1000 * 60 * 10, // 10 min
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Playback, RuntimeStore } from 'ontime-types';
|
||||
import { useStore } from 'zustand';
|
||||
import { createStore } from 'zustand/vanilla';
|
||||
|
||||
export const runtimeStorePlaceholder = {
|
||||
export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
timer: {
|
||||
clock: 0,
|
||||
current: null,
|
||||
@@ -33,6 +33,10 @@ export const runtimeStorePlaceholder = {
|
||||
text: '',
|
||||
visible: false,
|
||||
},
|
||||
externalMessage: {
|
||||
text: '',
|
||||
visible: false,
|
||||
},
|
||||
onAir: false,
|
||||
loaded: {
|
||||
numEvents: 0,
|
||||
|
||||
+10
@@ -26,4 +26,14 @@ describe('formatTime()', () => {
|
||||
const time = formatTime(ms);
|
||||
expect(time).toStrictEqual('...');
|
||||
});
|
||||
|
||||
it('shows 12h format without times', () => {
|
||||
const ms = 13 * 60 * 60 * 1000;
|
||||
const options = {
|
||||
showSeconds: false,
|
||||
format: 'hh:mm a',
|
||||
};
|
||||
const time = formatTime(ms, options, () => '12');
|
||||
expect(time).toStrictEqual('01:00 PM');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Input } from '@chakra-ui/react';
|
||||
import { IconButton, Input } from '@chakra-ui/react';
|
||||
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
||||
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
||||
|
||||
@@ -13,13 +13,14 @@ interface InputRowProps {
|
||||
placeholder: string;
|
||||
text: string;
|
||||
visible?: boolean;
|
||||
readonly?: boolean;
|
||||
actionHandler: (action: string, payload: object) => void;
|
||||
changeHandler: (newValue: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function InputRow(props: InputRowProps) {
|
||||
const { label, placeholder, text, visible, actionHandler, changeHandler, className } = props;
|
||||
const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
|
||||
|
||||
const handleInputChange = (newValue: string) => {
|
||||
changeHandler(newValue);
|
||||
@@ -33,19 +34,31 @@ export default function InputRow(props: InputRowProps) {
|
||||
<Input
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
value={text}
|
||||
onChange={(event) => handleInputChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
|
||||
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'
|
||||
/>
|
||||
{readonly ? (
|
||||
<IconButton
|
||||
size='sm'
|
||||
isDisabled
|
||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||
aria-label={`Toggle ${label}`}
|
||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||
/>
|
||||
) : (
|
||||
<TooltipActionBtn
|
||||
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
|
||||
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'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,3 @@
|
||||
color: $action-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.padTop {
|
||||
margin-top: $section-spacing;
|
||||
}
|
||||
|
||||
@@ -34,34 +34,48 @@ export default function MessageControl() {
|
||||
actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)}
|
||||
/>
|
||||
<InputRow
|
||||
label='Timer message'
|
||||
placeholder='Shown in stage timer'
|
||||
label='Timer'
|
||||
placeholder='Message shown in stage timer'
|
||||
text={data.timerMessage.text || ''}
|
||||
visible={data.timerMessage.visible || false}
|
||||
changeHandler={(newValue) => setMessage.presenterText(newValue)}
|
||||
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
|
||||
/>
|
||||
<div className={style.buttonSection}>
|
||||
<label className={style.label}>Timer message blink</label>
|
||||
<label className={style.label}>Blackout timer screens</label>
|
||||
<Button
|
||||
size='sm'
|
||||
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
|
||||
variant={data.timerMessage.timerBlink ? 'ontime-filled' : 'ontime-subtle'}
|
||||
leftIcon={data.timerMessage.timerBlink ? <IoSunny size='24px' /> : <IoSunnyOutline size='24px' />}
|
||||
leftIcon={data.timerMessage.timerBlink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
|
||||
onClick={() => setMessage.timerBlink(!data.timerMessage.timerBlink)}
|
||||
data-testid='toggle timer blink'
|
||||
/>
|
||||
>
|
||||
Blink message
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
className={style.blackoutButton}
|
||||
variant={data.timerMessage.timerBlackout ? 'ontime-filled' : 'ontime-subtle'}
|
||||
leftIcon={data.timerMessage.timerBlackout ? <IoEye size='24px' /> : <IoEyeOffOutline size='24px' />}
|
||||
leftIcon={data.timerMessage.timerBlackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
|
||||
onClick={() => setMessage.timerBlackout(!data.timerMessage.timerBlackout)}
|
||||
data-testid='toggle timer blackout'
|
||||
/>
|
||||
>
|
||||
Blackout screen
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`${style.onAirSection} ${style.padTop}`}>
|
||||
<InputRow
|
||||
label='External Message'
|
||||
placeholder='-'
|
||||
readonly
|
||||
text={data.externalMessage.text || ''}
|
||||
visible={data.externalMessage.visible || false}
|
||||
changeHandler={() => undefined}
|
||||
actionHandler={() => undefined}
|
||||
/>
|
||||
<div className={style.onAirSection}>
|
||||
<label className={style.label}>Toggle On Air state</label>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'}
|
||||
leftIcon={data.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
|
||||
onClick={() => setMessage.onAir(!data.onAir)}
|
||||
|
||||
@@ -24,10 +24,7 @@ interface CuesheetProps {
|
||||
}
|
||||
|
||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
|
||||
const followSelected = useCuesheetSettings((state) => state.followSelected);
|
||||
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
||||
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
|
||||
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
||||
const { followSelected, showSettings, showDelayBlock, showPrevious } = useCuesheetSettings();
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage('table-hidden', {});
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
|
||||
@@ -66,7 +63,9 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
setColumnSizing({});
|
||||
};
|
||||
|
||||
const headerGroups = table.getHeaderGroups;
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
const rowModel = table.getRowModel();
|
||||
const allLeafColumns = table.getAllLeafColumns();
|
||||
|
||||
let eventIndex = 0;
|
||||
let isPast = Boolean(selectedId);
|
||||
@@ -75,7 +74,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
<>
|
||||
{showSettings && (
|
||||
<CuesheetTableSettings
|
||||
columns={table.getAllLeafColumns()}
|
||||
columns={allLeafColumns}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
@@ -85,7 +84,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
<table className={style.cuesheet}>
|
||||
<CuesheetHeader headerGroups={headerGroups} />
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
{rowModel.rows.map((row) => {
|
||||
const key = row.original.id;
|
||||
const isSelected = selectedId === key;
|
||||
if (isSelected) {
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
|
||||
& > * {
|
||||
border: 1px solid $white-10;
|
||||
border-radius: 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
|
||||
|
||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
|
||||
import Cuesheet from './Cuesheet';
|
||||
import { makeCuesheetColumns } from './cuesheetCols';
|
||||
@@ -140,6 +141,7 @@ export default function CuesheetWrapper() {
|
||||
return (
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetTableHeader handleExport={handleOpenModal} featureData={featureData} />
|
||||
<CuesheetProgress />
|
||||
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
|
||||
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.progressOverride {
|
||||
height: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||
|
||||
import styles from "./CuesheetProgress.module.scss"
|
||||
|
||||
export default function CuesheetProgress() {
|
||||
const { data } = useViewSettings();
|
||||
const timer = useTimer();
|
||||
const totalTime = (timer.duration ?? 0) + (timer.addedTime ?? 0);
|
||||
|
||||
return (
|
||||
<MultiPartProgressBar
|
||||
now={timer.current}
|
||||
complete={totalTime}
|
||||
normalColor={data!.normalColor}
|
||||
warning={data!.warningThreshold}
|
||||
warningColor={data!.warningColor}
|
||||
danger={data!.dangerThreshold}
|
||||
dangerColor={data!.dangerColor}
|
||||
className={styles.progressOverride}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { SortableCell } from './SortableCell';
|
||||
import style from '../Cuesheet.module.scss';
|
||||
|
||||
interface CuesheetHeaderProps {
|
||||
headerGroups: () => HeaderGroup<OntimeRundownEntry>[];
|
||||
headerGroups: HeaderGroup<OntimeRundownEntry>[];
|
||||
}
|
||||
|
||||
function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
@@ -75,7 +75,7 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
|
||||
return (
|
||||
<thead className={style.tableHeader}>
|
||||
{headerGroups().map((headerGroup) => {
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const key = headerGroup.id;
|
||||
|
||||
return (
|
||||
|
||||
+10
-8
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, ReactNode } from 'react';
|
||||
import { Button, Checkbox, Switch } from '@chakra-ui/react';
|
||||
import { Column } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
@@ -22,12 +22,14 @@ interface CuesheetTableSettingsProps {
|
||||
|
||||
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
||||
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
||||
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
|
||||
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
|
||||
const toggleDelayVisibility = useCuesheetSettings((state) => state.toggleDelayVisibility);
|
||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
||||
const toggleDelayedTimes = useCuesheetSettings((state) => state.toggleDelayedTimes);
|
||||
const {
|
||||
showPrevious,
|
||||
toggleDelayVisibility,
|
||||
showDelayBlock,
|
||||
showDelayedTimes,
|
||||
toggleDelayedTimes,
|
||||
togglePreviousVisibility,
|
||||
} = useCuesheetSettings();
|
||||
|
||||
return (
|
||||
<div className={style.tableSettings}>
|
||||
@@ -44,7 +46,7 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
defaultChecked={visible}
|
||||
onChange={column.getToggleVisibilityHandler()}
|
||||
/>
|
||||
{columnHeader}
|
||||
{columnHeader as ReactNode}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { millisToString } from 'ontime-utils';
|
||||
* @return {string}
|
||||
*/
|
||||
|
||||
export const parseField = (field: keyof OntimeRundown, data: unknown): string => {
|
||||
export const parseField = <T extends OntimeEntryCommonKeys>(field: T, data: unknown): string => {
|
||||
let val;
|
||||
switch (field) {
|
||||
case 'timeStart':
|
||||
@@ -96,6 +96,7 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userF
|
||||
|
||||
rundown.forEach((entry) => {
|
||||
const row: string[] = [];
|
||||
// @ts-expect-error -- not sure how to type this
|
||||
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
|
||||
data.push(row);
|
||||
});
|
||||
|
||||
@@ -73,8 +73,8 @@ export default function EventEditor() {
|
||||
handleSubmit={handleSubmit}
|
||||
>
|
||||
<CopyTag label='Event ID'>{event.id}</CopyTag>
|
||||
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid/${event.id}`}</CopyTag>
|
||||
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue/${event.cue}`}</CopyTag>
|
||||
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid "${event.id}"`}</CopyTag>
|
||||
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue "${event.cue}"`}</CopyTag>
|
||||
</EventEditorDataRight>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ export default function CountedTextInput(props: CountedTextInputProps) {
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete='off'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
.title {
|
||||
white-space: nowrap;
|
||||
@@ -14,12 +15,12 @@
|
||||
|
||||
.selected {
|
||||
min-width: max-content;
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-nocheck -- working on it
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FormControl, Input, Switch } from '@chakra-ui/react';
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import type { ProjectData } from 'ontime-types';
|
||||
|
||||
import { PROJECT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
|
||||
import { PROJECT_DATA, RUNDOWN } from '../../../common/api/apiConstants';
|
||||
import { postNew } from '../../../common/api/ontimeApi';
|
||||
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
|
||||
@@ -52,8 +52,8 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
||||
const onSubmit = async (data: Partial<ProjectData>) => {
|
||||
try {
|
||||
await postNew(data);
|
||||
await ontimeQueryClient.invalidateQueries(PROJECT_DATA);
|
||||
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_DATA });
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
|
||||
onClose();
|
||||
} catch (_) {
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function AliasesForm() {
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
reset({ aliases: data });
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function AliasesForm() {
|
||||
});
|
||||
};
|
||||
|
||||
const disableInputs = status === 'loading';
|
||||
const disableInputs = status === 'pending';
|
||||
const hasTooManyOptions = fields.length >= 20;
|
||||
|
||||
if (isFetching) {
|
||||
@@ -112,6 +112,7 @@ export default function AliasesForm() {
|
||||
icon={<IoRemove />}
|
||||
colorScheme='red'
|
||||
isDisabled={disableInputs}
|
||||
data-testid={`field__delete_${index}`}
|
||||
/>
|
||||
<Input
|
||||
{...inputProps}
|
||||
@@ -121,6 +122,7 @@ export default function AliasesForm() {
|
||||
variant='ontime-filled-on-light'
|
||||
placeholder='URL Alias'
|
||||
isDisabled={disableInputs}
|
||||
data-testid={`field__alias_${index}`}
|
||||
/>
|
||||
<Input
|
||||
{...inputProps}
|
||||
@@ -130,6 +132,7 @@ export default function AliasesForm() {
|
||||
variant='ontime-filled-on-light'
|
||||
placeholder='URL (portion after ontime Port)'
|
||||
isDisabled={disableInputs}
|
||||
data-testid={`field__url_${index}`}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
clickHandler={(event) => handleLinks(event, alias.alias)}
|
||||
@@ -140,8 +143,14 @@ export default function AliasesForm() {
|
||||
icon={<IoOpenOutline />}
|
||||
colorScheme='red'
|
||||
isDisabled={disableInputs}
|
||||
data-testid={`field__test_${index}`}
|
||||
/>
|
||||
<Switch
|
||||
{...register(`aliases.${index}.enabled`)}
|
||||
variant='ontime-on-light'
|
||||
isDisabled={disableInputs}
|
||||
data-testid={`field__enable_${index}`}
|
||||
/>
|
||||
<Switch {...register(`aliases.${index}.enabled`)} variant='ontime-on-light' isDisabled={disableInputs} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function AppSettingsModal() {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
const disableInputs = status === 'loading';
|
||||
const disableInputs = status === 'pending';
|
||||
|
||||
if (isFetching) {
|
||||
return <ModalLoader />;
|
||||
@@ -87,7 +87,7 @@ export default function AppSettingsModal() {
|
||||
description='Protect the editor with a pin code'
|
||||
error={errors.editorKey?.message}
|
||||
>
|
||||
<ModalPinInput register={register} formName='editorKey' isDisabled={disableInputs} />
|
||||
<ModalPinInput register={register as any} formName='editorKey' isDisabled={disableInputs} />
|
||||
</ModalSplitInput>
|
||||
<ModalSplitInput
|
||||
field='operatorKey'
|
||||
@@ -95,7 +95,7 @@ export default function AppSettingsModal() {
|
||||
description='Protect the cuesheet with a pin code'
|
||||
error={errors.operatorKey?.message}
|
||||
>
|
||||
<ModalPinInput register={register} formName='operatorKey' isDisabled={disableInputs} />
|
||||
<ModalPinInput register={register as any} formName='operatorKey' isDisabled={disableInputs} />
|
||||
</ModalSplitInput>
|
||||
<div style={{ height: '16px' }} />
|
||||
<ModalSplitInput
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function CuesheetSettingsForm() {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
const disableInputs = status === 'loading';
|
||||
const disableInputs = status === 'pending';
|
||||
|
||||
if (isFetching) {
|
||||
return <ModalLoader />;
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function ProjectDataForm() {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
const disableInputs = status === 'loading';
|
||||
const disableInputs = status === 'pending';
|
||||
|
||||
if (isFetching) {
|
||||
return <ModalLoader />;
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function ViewSettingsForm() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const disableInputs = status === 'loading';
|
||||
const disableInputs = status === 'pending';
|
||||
|
||||
if (isFetching || isFetchingInfo) {
|
||||
return <ModalLoader />;
|
||||
@@ -94,7 +94,7 @@ export default function ViewSettingsForm() {
|
||||
<AlertTitle>CSS Override</AlertTitle>
|
||||
<AlertDescription>
|
||||
Ontime will use the CSS file at its install location. <br />
|
||||
<span className={style.url}>{info.cssOverride}</span>
|
||||
<span className={style.url}>{info?.cssOverride}</span>
|
||||
<ModalLink href={cssOverrideDocsUrl}>For more information, see the docs</ModalLink>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
|
||||
|
||||
import { PROJECT_DATA, RUNDOWN_TABLE, USERFIELDS } from '../../../common/api/apiConstants';
|
||||
import { PROJECT_DATA, RUNDOWN, USERFIELDS } from '../../../common/api/apiConstants';
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils';
|
||||
import {
|
||||
patchData,
|
||||
@@ -155,11 +155,11 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await patchData({ rundown, userFields, project });
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, rundown);
|
||||
queryClient.setQueryData(RUNDOWN, { rundown, revision: -1 });
|
||||
queryClient.setQueryData(USERFIELDS, userFields);
|
||||
queryClient.setQueryData(PROJECT_DATA, project);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [...RUNDOWN_TABLE, ...USERFIELDS, ...PROJECT_DATA],
|
||||
queryKey: [...RUNDOWN, ...USERFIELDS, ...PROJECT_DATA],
|
||||
});
|
||||
doClose = true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -22,3 +22,24 @@
|
||||
.spacer {
|
||||
min-height: 95vh;
|
||||
}
|
||||
|
||||
.editPrompt {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
text-align: center;
|
||||
|
||||
background: rgba(black, 0.6);
|
||||
border-radius: 2px;
|
||||
padding: 0.5em 2em;
|
||||
color: gold;
|
||||
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
transition-duration: 0.3s;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
|
||||
import { getFirstEvent, getLastEvent } from 'ontime-utils';
|
||||
@@ -12,8 +12,10 @@ import { useOperator } from '../../common/hooks/useSocket';
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||
import { debounce } from '../../common/utils/debounce';
|
||||
import { isStringBoolean } from '../../common/utils/viewUtils';
|
||||
|
||||
import EditModal from './edit-modal/EditModal';
|
||||
import FollowButton from './follow-button/FollowButton';
|
||||
import OperatorBlock from './operator-block/OperatorBlock';
|
||||
import OperatorEvent from './operator-event/OperatorEvent';
|
||||
@@ -24,15 +26,24 @@ import style from './Operator.module.scss';
|
||||
const selectedOffset = 50;
|
||||
|
||||
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
|
||||
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string };
|
||||
export type PartialEdit = EditEvent & {
|
||||
field: keyof UserFields;
|
||||
};
|
||||
|
||||
export default function Operator() {
|
||||
const { data, status } = useRundown();
|
||||
const { data: userFields, status: userFieldsStatus } = useUserFields();
|
||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
|
||||
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const featureData = useOperator();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const isAutomatedScroll = useRef(false);
|
||||
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
||||
const [editEvent, setEditEvent] = useState<PartialEdit | null>(null);
|
||||
|
||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -41,7 +52,6 @@ export default function Operator() {
|
||||
scrollRef: scrollRef,
|
||||
doFollow: !lockAutoScroll,
|
||||
topOffset: selectedOffset,
|
||||
setScrollFlag: () => (isAutomatedScroll.current = true),
|
||||
});
|
||||
|
||||
// Set window title
|
||||
@@ -65,13 +75,8 @@ export default function Operator() {
|
||||
setLockAutoScroll(false);
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
// prevent considering automated scrolls as user scrolls
|
||||
if (isAutomatedScroll.current) {
|
||||
isAutomatedScroll.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// prevent considering automated scrolls as user scrolls
|
||||
const handleUserScroll = () => {
|
||||
if (selectedRef?.current && scrollRef?.current) {
|
||||
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||
@@ -82,9 +87,34 @@ export default function Operator() {
|
||||
}
|
||||
}
|
||||
};
|
||||
const debouncedHandleScroll = debounce(handleUserScroll, 1000);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (timeoutId.current) {
|
||||
clearTimeout(timeoutId.current);
|
||||
}
|
||||
timeoutId.current = setTimeout(() => {
|
||||
setShowEditPrompt(false);
|
||||
}, 700);
|
||||
|
||||
setShowEditPrompt(true);
|
||||
|
||||
debouncedHandleScroll();
|
||||
};
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(event: EditEvent) => {
|
||||
const field = searchParams.get('subscribe') as keyof UserFields | null;
|
||||
|
||||
if (field) {
|
||||
setEditEvent({ ...event, field });
|
||||
}
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const missingData = !data || !userFields || !projectData;
|
||||
const isLoading = status === 'loading' || userFieldsStatus === 'loading' || projectDataStatus === 'loading';
|
||||
const isLoading = status === 'pending' || userFieldsStatus === 'pending' || projectDataStatus === 'pending';
|
||||
|
||||
if (missingData || isLoading) {
|
||||
return <Empty text='Loading...' />;
|
||||
@@ -108,6 +138,7 @@ export default function Operator() {
|
||||
<div className={style.operatorContainer}>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={operatorOptions} />
|
||||
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
|
||||
|
||||
<StatusBar
|
||||
projectTitle={projectData.title}
|
||||
@@ -119,7 +150,13 @@ export default function Operator() {
|
||||
lastId={lastEvent?.id}
|
||||
/>
|
||||
|
||||
<div className={style.operatorEvents} onScroll={handleScroll} ref={scrollRef}>
|
||||
{subscribe && (
|
||||
<div className={`${style.editPrompt} ${showEditPrompt ? style.show : undefined}`}>
|
||||
Press and hold to edit user field
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||
{data.map((entry) => {
|
||||
if (isOntimeEvent(entry)) {
|
||||
const isSelected = featureData.selectedEventId === entry.id;
|
||||
@@ -139,6 +176,7 @@ export default function Operator() {
|
||||
return (
|
||||
<OperatorEvent
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
colour={entry.colour}
|
||||
cue={entry.cue}
|
||||
main={mainField}
|
||||
@@ -153,6 +191,7 @@ export default function Operator() {
|
||||
showSeconds={showSeconds}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
onLongPress={subscribe ? handleEdit : () => undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
.editModal {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
margin: 0 auto;
|
||||
top: 20%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
padding: 1rem;
|
||||
background-color: $gray-1250;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-width: min(400px, 90vw);
|
||||
box-shadow: $box-shadow-l1;
|
||||
|
||||
.buttonRow {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button, Textarea } from '@chakra-ui/react';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import type { PartialEdit } from '../Operator';
|
||||
|
||||
import style from './EditModal.module.scss';
|
||||
|
||||
interface EditModalProps {
|
||||
event: PartialEdit;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function EditModal(props: EditModalProps) {
|
||||
const { event, onClose } = props;
|
||||
|
||||
const { updateEvent } = useEventAction();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
const newValue = inputRef.current?.value;
|
||||
|
||||
const partialEvent: Partial<OntimeEvent> = {
|
||||
id: event.id,
|
||||
[event.field]: newValue,
|
||||
};
|
||||
await updateEvent(partialEvent);
|
||||
setLoading(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const fieldLabel = event?.fieldLabel ?? event.field;
|
||||
|
||||
return (
|
||||
<div className={style.editModal}>
|
||||
<div>{`Editing field ${fieldLabel} in cue ${event.cue}`}</div>
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
variant='ontime-filled'
|
||||
placeholder={`Add value for ${fieldLabel} field`}
|
||||
defaultValue={event.fieldValue}
|
||||
isDisabled={loading}
|
||||
/>
|
||||
<div className={style.buttonRow}>
|
||||
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='ontime-filled' onClick={handleSave} isDisabled={loading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
@use '../../../../src/theme/v2Styles' as *;
|
||||
|
||||
.followButton {
|
||||
position: relative;
|
||||
bottom: 12rem;
|
||||
margin: 0 auto;
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
bottom: calc(1.5rem + env(safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
|
||||
display: flex;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { memo, RefObject } from 'react';
|
||||
import { memo, RefObject, SyntheticEvent } from 'react';
|
||||
|
||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||
import useLongPress from '../../../common/hooks/useLongPress';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import type { EditEvent } from '../Operator';
|
||||
|
||||
import style from './OperatorEvent.module.scss';
|
||||
|
||||
interface OperatorEventProps {
|
||||
id: string;
|
||||
colour: string;
|
||||
cue: string;
|
||||
main: string;
|
||||
@@ -22,6 +25,7 @@ interface OperatorEventProps {
|
||||
showSeconds: boolean;
|
||||
isPast: boolean;
|
||||
selectedRef?: RefObject<HTMLDivElement>;
|
||||
onLongPress: (event: EditEvent) => void;
|
||||
}
|
||||
|
||||
// extract this to contain re-renders
|
||||
@@ -32,6 +36,7 @@ function RollingTime() {
|
||||
|
||||
function OperatorEvent(props: OperatorEventProps) {
|
||||
const {
|
||||
id,
|
||||
colour,
|
||||
cue,
|
||||
main,
|
||||
@@ -46,8 +51,17 @@ function OperatorEvent(props: OperatorEventProps) {
|
||||
showSeconds,
|
||||
isPast,
|
||||
selectedRef,
|
||||
onLongPress,
|
||||
} = props;
|
||||
|
||||
const handleLongPress = (event?: SyntheticEvent) => {
|
||||
// we dont have an event out of useLongPress
|
||||
event?.preventDefault();
|
||||
onLongPress({ id, cue, fieldLabel: subscribedAlias, fieldValue: subscribed ?? '' });
|
||||
};
|
||||
|
||||
const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 });
|
||||
|
||||
const start = formatTime(timeStart, { showSeconds });
|
||||
const end = formatTime(timeEnd, { showSeconds });
|
||||
|
||||
@@ -61,7 +75,7 @@ function OperatorEvent(props: OperatorEventProps) {
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={operatorClasses} ref={selectedRef}>
|
||||
<div className={operatorClasses} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}>
|
||||
<div className={style.binder} style={{ ...cueColours }}>
|
||||
<span className={style.cue}>{cue}</span>
|
||||
</div>
|
||||
|
||||
@@ -15,16 +15,19 @@
|
||||
background-color: $gray-1350;
|
||||
z-index: 2;
|
||||
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
box-shadow: $large-top-drawer-shadow;
|
||||
}
|
||||
|
||||
.timers {
|
||||
display: grid;
|
||||
padding: 0.5rem 1rem;
|
||||
grid-template-areas:
|
||||
"playback timer1B timer2B timer3B";
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
column-gap: 1.5rem;
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
.playbackIcon {
|
||||
@@ -83,7 +86,7 @@
|
||||
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.statusBar {
|
||||
.timers {
|
||||
grid-template-areas:
|
||||
"playback timer1B timer2A timer3A"
|
||||
"title title timer2B timer3B";
|
||||
@@ -103,3 +106,7 @@
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.progressOverride {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||
|
||||
import StatusBarProgress from './StatusBarProgress';
|
||||
import StatusBarTimers from './StatusBarTimers';
|
||||
|
||||
import styles from './StatusBar.module.scss';
|
||||
|
||||
@@ -22,73 +20,20 @@ interface StatusBarProps {
|
||||
export default function StatusBar(props: StatusBarProps) {
|
||||
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
|
||||
|
||||
const timer = useTimer();
|
||||
|
||||
const getTimeStart = () => {
|
||||
if (firstStart === undefined) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
if (selectedEventId) {
|
||||
if (firstId === selectedEventId) {
|
||||
return millisToString(timer.expectedFinish);
|
||||
}
|
||||
}
|
||||
return millisToString(firstStart);
|
||||
};
|
||||
|
||||
const getTimeEnd = () => {
|
||||
if (lastEnd === undefined) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
if (selectedEventId) {
|
||||
if (lastId === selectedEventId) {
|
||||
return millisToString(timer.expectedFinish);
|
||||
}
|
||||
}
|
||||
return millisToString(lastEnd);
|
||||
};
|
||||
|
||||
// use user defined format
|
||||
const timeNow = formatTime(timer.clock, {
|
||||
showSeconds: true,
|
||||
});
|
||||
|
||||
const runningTime = millisToString(timer.current);
|
||||
const elapsedTime = millisToString(timer.elapsed);
|
||||
|
||||
const PlaybackIconComponent = useMemo(() => {
|
||||
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
|
||||
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
|
||||
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
|
||||
}, [playback]);
|
||||
const { data } = useViewSettings();
|
||||
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
{PlaybackIconComponent}
|
||||
<div className={styles.timeNow}>
|
||||
<span className={styles.label}>Time now</span>
|
||||
<span className={styles.timer}>{timeNow}</span>
|
||||
</div>
|
||||
<div className={styles.elapsedTime}>
|
||||
<span className={styles.label}>Elapsed time</span>
|
||||
<span className={styles.timer}>{elapsedTime}</span>
|
||||
</div>
|
||||
<div className={styles.runningTime}>
|
||||
<span className={styles.label}>Running timer</span>
|
||||
<span className={styles.timer}>{runningTime}</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.title}>{projectTitle}</span>
|
||||
<div className={styles.startTime}>
|
||||
<span className={styles.label}>Scheduled start</span>
|
||||
<span className={styles.timer}>{getTimeStart()}</span>
|
||||
</div>
|
||||
<div className={styles.endTime}>
|
||||
<span className={styles.label}>Scheduled end</span>
|
||||
<span className={styles.timer}>{getTimeEnd()}</span>
|
||||
</div>
|
||||
<StatusBarTimers
|
||||
projectTitle={projectTitle}
|
||||
playback={playback}
|
||||
selectedEventId={selectedEventId}
|
||||
firstStart={firstStart}
|
||||
firstId={firstId}
|
||||
lastEnd={lastEnd}
|
||||
lastId={lastId}
|
||||
/>
|
||||
{data && <StatusBarProgress viewSettings={data} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
|
||||
import styles from './StatusBar.module.scss';
|
||||
|
||||
interface StatusBarProgressProps {
|
||||
viewSettings: ViewSettings;
|
||||
}
|
||||
|
||||
export default function StatusBarProgress(props: StatusBarProgressProps) {
|
||||
const { viewSettings } = props;
|
||||
|
||||
const timer = useTimer();
|
||||
const totalTime = (timer.duration ?? 0) + (timer.addedTime ?? 0);
|
||||
|
||||
return (
|
||||
<MultiPartProgressBar
|
||||
now={timer.current}
|
||||
complete={totalTime}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={viewSettings.warningThreshold}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={viewSettings.dangerThreshold}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
className={styles.progressOverride}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import styles from './StatusBar.module.scss';
|
||||
|
||||
interface StatusBarTimersProps {
|
||||
projectTitle: string;
|
||||
playback: Playback;
|
||||
selectedEventId: string | null;
|
||||
firstStart?: number;
|
||||
firstId?: string;
|
||||
lastEnd?: number;
|
||||
lastId?: string;
|
||||
}
|
||||
|
||||
export default function StatusBarTimers(props: StatusBarTimersProps) {
|
||||
const { projectTitle, playback, selectedEventId, firstStart, firstId, lastEnd, lastId } = props;
|
||||
|
||||
const timer = useTimer();
|
||||
|
||||
const getTimeStart = () => {
|
||||
if (firstStart === undefined) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
if (selectedEventId) {
|
||||
if (firstId === selectedEventId) {
|
||||
return millisToString(timer.expectedFinish);
|
||||
}
|
||||
}
|
||||
return millisToString(firstStart);
|
||||
};
|
||||
|
||||
const getTimeEnd = () => {
|
||||
if (lastEnd === undefined) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
if (selectedEventId) {
|
||||
if (lastId === selectedEventId) {
|
||||
return millisToString(timer.expectedFinish);
|
||||
}
|
||||
}
|
||||
return millisToString(lastEnd);
|
||||
};
|
||||
|
||||
const PlaybackIconComponent = useMemo(() => {
|
||||
const isPlaying = playback === Playback.Play || playback === Playback.Roll;
|
||||
const classes = cx([styles.playbackIcon, isPlaying ? styles.active : null]);
|
||||
return <PlaybackIcon state={playback} skipTooltip className={classes} />;
|
||||
}, [playback]);
|
||||
|
||||
// use user defined format
|
||||
const timeNow = formatTime(timer.clock, {
|
||||
showSeconds: true,
|
||||
});
|
||||
|
||||
const runningTime = millisToString(timer.current);
|
||||
const elapsedTime = millisToString(timer.elapsed);
|
||||
|
||||
return (
|
||||
<div className={styles.timers}>
|
||||
{PlaybackIconComponent}
|
||||
<div className={styles.timeNow}>
|
||||
<span className={styles.label}>Time now</span>
|
||||
<span className={styles.timer}>{timeNow}</span>
|
||||
</div>
|
||||
<div className={styles.elapsedTime}>
|
||||
<span className={styles.label}>Elapsed time</span>
|
||||
<span className={styles.timer}>{elapsedTime}</span>
|
||||
</div>
|
||||
<div className={styles.runningTime}>
|
||||
<span className={styles.label}>Running timer</span>
|
||||
<span className={styles.timer}>{runningTime}</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.title}>{projectTitle}</span>
|
||||
<div className={styles.startTime}>
|
||||
<span className={styles.label}>Scheduled start</span>
|
||||
<span className={styles.timer}>{getTimeStart()}</span>
|
||||
</div>
|
||||
<div className={styles.endTime}>
|
||||
<span className={styles.label}>Scheduled end</span>
|
||||
<span className={styles.timer}>{getTimeEnd()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
|
||||
import { GetRundownCached, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
|
||||
import { calculateDuration, getCueCandidate } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN_TABLE } from '../../common/api/apiConstants';
|
||||
import { RUNDOWN } from '../../common/api/apiConstants';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
import { useAppMode } from '../../common/stores/appModeStore';
|
||||
@@ -100,7 +100,8 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
}
|
||||
case 'clone': {
|
||||
const newEvent = cloneEvent(data as OntimeEvent, data.id);
|
||||
newEvent.cue = getCueCandidate(ontimeQueryClient.getQueryData(RUNDOWN_TABLE) || [], data.id);
|
||||
const rundown = ontimeQueryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? []
|
||||
newEvent.cue = getCueCandidate(rundown, data.id);
|
||||
addEvent(newEvent);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { EventItemActions } from '../../RundownEntry';
|
||||
interface BlockActionMenuProps {
|
||||
enableDelete?: boolean;
|
||||
showClone?: boolean;
|
||||
actionHandler: (action: EventItemActions, payload?: unknown) => void;
|
||||
actionHandler: (action: EventItemActions, payload?: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { ComponentType, useMemo } from 'react';
|
||||
import { SupportedEvent } from 'ontime-types';
|
||||
import { TimeManagerType } from 'common/models/TimeManager.type';
|
||||
import { Message, OntimeEvent, ProjectData, SupportedEvent, TimerMessage, ViewSettings } from 'ontime-types';
|
||||
import { useStore } from 'zustand';
|
||||
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
@@ -9,8 +9,33 @@ import useViewSettings from '../../common/hooks-query/useViewSettings';
|
||||
import { runtime } from '../../common/stores/runtime';
|
||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||
|
||||
const withData = <P extends object>(Component: ComponentType<P>) => {
|
||||
return (props: Partial<P>) => {
|
||||
type WithDataProps = {
|
||||
isMirrored: boolean;
|
||||
pres: TimerMessage;
|
||||
publ: Message;
|
||||
lower: Message;
|
||||
external: Message;
|
||||
eventNow: OntimeEvent | null;
|
||||
publicEventNow: OntimeEvent | null;
|
||||
eventNext: OntimeEvent | null;
|
||||
publicEventNext: OntimeEvent | null;
|
||||
time: TimeManagerType;
|
||||
events: OntimeEvent[];
|
||||
backstageEvents: OntimeEvent[];
|
||||
selectedId: string | null;
|
||||
publicSelectedId: string | null;
|
||||
nextId: string | null;
|
||||
general: ProjectData;
|
||||
viewSettings: ViewSettings;
|
||||
onAir: boolean;
|
||||
};
|
||||
|
||||
function getDisplayName(Component: React.ComponentType<any>): string {
|
||||
return Component.displayName || Component.name || 'Component';
|
||||
}
|
||||
|
||||
const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
||||
const WithDataComponent = (props: P) => {
|
||||
// persisted app state
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
@@ -27,12 +52,12 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
|
||||
}, [rundownData]);
|
||||
|
||||
// websocket data
|
||||
const data = useStore(runtime);
|
||||
const {
|
||||
timer,
|
||||
publicMessage,
|
||||
timerMessage,
|
||||
lowerMessage,
|
||||
externalMessage,
|
||||
playback,
|
||||
onAir,
|
||||
eventNext,
|
||||
@@ -40,7 +65,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
|
||||
publicEventNow,
|
||||
eventNow,
|
||||
loaded,
|
||||
} = data;
|
||||
} = useStore(runtime);
|
||||
const publicSelectedId = loaded.selectedPublicEventId;
|
||||
const selectedId = loaded.selectedEventId;
|
||||
const nextId = loaded.nextEventId;
|
||||
@@ -68,6 +93,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
|
||||
pres={timerMessage}
|
||||
publ={publicMessage}
|
||||
lower={lowerMessage}
|
||||
external={externalMessage}
|
||||
eventNow={eventNow}
|
||||
publicEventNow={publicEventNow}
|
||||
eventNext={eventNext}
|
||||
@@ -84,6 +110,9 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
WithDataComponent.displayName = `WithData(${getDisplayName(Component)})`;
|
||||
return WithDataComponent;
|
||||
};
|
||||
|
||||
export default withData;
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
font-weight: 600;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
}
|
||||
|
||||
.message {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { titleVariants } from '../common/animation';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
|
||||
import './Backstage.scss';
|
||||
|
||||
@@ -97,7 +98,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<div className='time'>{clock}</div>
|
||||
<SuperscriptTime time={clock} className='time' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,12 +129,16 @@ export default function Backstage(props: BackstageProps) {
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
|
||||
<div className='aux-timers__value'>{startedAt}</div>
|
||||
<SuperscriptTime time={startedAt} className='aux-timers__value' />
|
||||
</div>
|
||||
<div className='timer-gap' />
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
|
||||
<div className='aux-timers__value'>{expectedFinish}</div>
|
||||
{isNegative ? (
|
||||
<div className='aux-timers__value'>{expectedFinish}</div>
|
||||
) : (
|
||||
<SuperscriptTime time={startedAt} className='aux-timers__value' />
|
||||
)}
|
||||
</div>
|
||||
<div className='timer-gap' />
|
||||
<div className='aux-timers'>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { OverridableOptions } from '../../../common/models/View.types';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
|
||||
import './Clock.scss';
|
||||
|
||||
@@ -135,7 +136,8 @@ export default function Clock(props: ClockProps) {
|
||||
>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={CLOCK_OPTIONS} />
|
||||
<div
|
||||
<SuperscriptTime
|
||||
time={clock}
|
||||
className='clock'
|
||||
style={{
|
||||
color: userOptions.textColour,
|
||||
@@ -145,9 +147,7 @@ export default function Clock(props: ClockProps) {
|
||||
left: userOptions.left,
|
||||
backgroundColor: userOptions.textBackground,
|
||||
}}
|
||||
>
|
||||
{clock}
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
sup.period {
|
||||
top: -1em;
|
||||
font-size: 0.4em;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import './SuperscriptTime.scss';
|
||||
|
||||
interface SuperscriptTimeProps {
|
||||
time: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function SuperscriptTime(props: SuperscriptTimeProps) {
|
||||
const { time, className, style } = props;
|
||||
|
||||
// we assume anything after space is a period tag
|
||||
const [timeString, period] = time.split(' ');
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{timeString}
|
||||
{period && <sup className='period'>{period}</sup>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,6 +68,7 @@
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +136,7 @@
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
|
||||
&--delayed {
|
||||
color: $delay-color;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
|
||||
import { fetchTimerData, TimerMessage } from './countdown.helpers';
|
||||
import CountdownSelect from './CountdownSelect';
|
||||
@@ -118,24 +119,27 @@ export default function Countdown(props: CountdownProps) {
|
||||
<div className='countdown-container' data-testid='countdown-event'>
|
||||
<div className='clock-container'>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<div className='time'>{clock}</div>
|
||||
<SuperscriptTime time={clock} className='time' />
|
||||
</div>
|
||||
|
||||
<div className='status'>{getLocalizedString(`countdown.${runningMessage}`)}</div>
|
||||
{runningMessage !== TimerMessage.unhandled && (
|
||||
<div className='status'>{getLocalizedString(`countdown.${runningMessage}`)}</div>
|
||||
)}
|
||||
|
||||
<span className={`timer ${standby ? 'timer--paused' : ''} ${isRunningFinished ? 'timer--finished' : ''}`}>
|
||||
{formattedTimer}
|
||||
</span>
|
||||
<SuperscriptTime
|
||||
time={formattedTimer}
|
||||
className={`timer ${standby ? 'timer--paused' : ''} ${isRunningFinished ? 'timer--finished' : ''}`}
|
||||
/>
|
||||
<div className='title'>{follow?.title || 'Untitled Event'}</div>
|
||||
|
||||
<div className='timer-group'>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.start_time')}</div>
|
||||
<span className={`aux-timers__value ${delayedTimerStyles}`}>{startTime}</span>
|
||||
<SuperscriptTime time={startTime} className={`aux-timers__value ${delayedTimerStyles}`} />
|
||||
</div>
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.end_time')}</div>
|
||||
<span className={`aux-timers__value ${delayedTimerStyles}`}>{endTime}</span>
|
||||
<SuperscriptTime time={endTime} className={`aux-timers__value ${delayedTimerStyles}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,10 @@ import { sanitiseTitle } from './countdown.helpers';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
const formatOptions = {
|
||||
format: 'hh:mm a',
|
||||
};
|
||||
|
||||
interface CountdownSelectProps {
|
||||
events: OntimeRundownEntry[];
|
||||
}
|
||||
@@ -31,8 +35,8 @@ export default function CountdownSelect(props: CountdownSelectProps) {
|
||||
filteredEvents.map((event: OntimeEvent, counter: number) => {
|
||||
const index = counter + 1;
|
||||
const title = sanitiseTitle(event.title);
|
||||
const start = formatTime(event.timeStart, { format: 'hh:mm' });
|
||||
const end = formatTime(event.timeEnd, { format: 'hh:mm' });
|
||||
const start = formatTime(event.timeStart, formatOptions);
|
||||
const end = formatTime(event.timeEnd, formatOptions);
|
||||
|
||||
return (
|
||||
<li key={event.id}>
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
}
|
||||
|
||||
.message {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { titleVariants } from '../common/animation';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
|
||||
import './Public.scss';
|
||||
|
||||
@@ -63,7 +64,7 @@ export default function Public(props: BackstageProps) {
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<div className='time'>{clock}</div>
|
||||
<SuperscriptTime time={clock} className='time' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ $half-hours: min(1.5vh, 10px);
|
||||
$size-min: min(2.5vh, 18px);
|
||||
$half-min: min(1.25vh, 9px);
|
||||
$red-active: #c53030;
|
||||
$red-idle: #300000;
|
||||
$red-idle: #000000;
|
||||
$cyan-active: #0ff;
|
||||
$cyan-idle: #0aa;
|
||||
|
||||
@@ -56,11 +56,10 @@ $cyan-idle: #0aa;
|
||||
.hours {
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
background: $red-idle;
|
||||
background: var(--studio-idle, $red-idle);
|
||||
|
||||
&--active {
|
||||
background: $red-active;
|
||||
box-shadow: 0 0 10px 2px rgba(255, 0, 0, 0.25);
|
||||
background: var(--studio-active, $red-active);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +79,7 @@ $cyan-idle: #0aa;
|
||||
}
|
||||
|
||||
.studio-timer {
|
||||
color: $red-active;
|
||||
color: var(--studio-active, $red-active);
|
||||
font-size: calc(#{$clock-size} / 3);
|
||||
margin-top: calc(50% - calc(#{$clock-size} / 7));
|
||||
line-height: 0.8em;
|
||||
@@ -88,9 +87,8 @@ $cyan-idle: #0aa;
|
||||
&--with-seconds {
|
||||
font-size: calc(#{$clock-size} / 4.6);
|
||||
margin-top: calc(50% - calc(#{$clock-size} / 11));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.next-title:after,
|
||||
@@ -100,14 +98,15 @@ $cyan-idle: #0aa;
|
||||
}
|
||||
|
||||
.next-title {
|
||||
color: $cyan-idle;
|
||||
color: var(--studio-idle-label, $cyan-idle);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.next-countdown {
|
||||
color: var(--studio-active-label, $cyan-active);
|
||||
|
||||
font-size: 10vh;
|
||||
line-height: 1em;
|
||||
color: $cyan-active;
|
||||
|
||||
&--overtime {
|
||||
color: darken($red-active, 10%);
|
||||
@@ -131,16 +130,16 @@ $cyan-idle: #0aa;
|
||||
padding-bottom: 2vh;
|
||||
font-size: 15vh;
|
||||
line-height: 0.9em;
|
||||
color: $red-active;
|
||||
color: var(--studio-active, $red-active);
|
||||
|
||||
&--idle {
|
||||
color: $red-idle;
|
||||
color: var(--studio-idle, $red-active);
|
||||
}
|
||||
}
|
||||
|
||||
.schedule {
|
||||
ul {
|
||||
color: $cyan-idle;
|
||||
color: var(--studio-idle-label, $cyan-idle);
|
||||
font-size: 3.75vh;
|
||||
line-height: 1em;
|
||||
list-style: none;
|
||||
@@ -154,18 +153,18 @@ $cyan-idle: #0aa;
|
||||
}
|
||||
|
||||
.now {
|
||||
color: $cyan-active;
|
||||
color: var(--studio-active-label, $cyan-active);
|
||||
}
|
||||
|
||||
.next {
|
||||
color: $red-active;
|
||||
color: var(--studio-active, $red-active);
|
||||
}
|
||||
|
||||
.user-colour {
|
||||
width: 0.35em;
|
||||
height: 0.35em;
|
||||
aspect-ratio: 1;
|
||||
background-color: $red-idle;
|
||||
background-color: var(--studio-idle, $red-idle);
|
||||
margin-right: 0.35em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
font-size: clamp(32px, 3.5vw, 50px);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 0.95em;
|
||||
}
|
||||
|
||||
&--hidden {
|
||||
@@ -78,6 +79,9 @@
|
||||
justify-self: center;
|
||||
align-self: center;
|
||||
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.end-message {
|
||||
text-align: center;
|
||||
font-size: 11.5vw;
|
||||
@@ -89,7 +93,6 @@
|
||||
|
||||
.timer {
|
||||
opacity: 1;
|
||||
font-size: 20vw;
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
line-height: 0.9em;
|
||||
@@ -97,6 +100,9 @@
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
|
||||
transition-property: font-size;
|
||||
transition-duration: $viewer-transition-time;
|
||||
|
||||
&--paused {
|
||||
opacity: $viewer-opacity-disabled;
|
||||
transition: $viewer-transition-time;
|
||||
@@ -108,6 +114,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.external {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--external-color-override, $external-color);
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 0.9em;
|
||||
padding-bottom: 0.2em;
|
||||
transition-property: opacity, height;
|
||||
transition-duration: $viewer-transition-time;
|
||||
border-top: 1px solid rgba(white, 0.1);
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
grid-area: progress;
|
||||
width: 100%;
|
||||
@@ -115,12 +142,11 @@
|
||||
opacity: 1;
|
||||
transition: $viewer-transition-time;
|
||||
|
||||
&--paused {
|
||||
opacity: $viewer-opacity-disabled;
|
||||
transition: $viewer-transition-time;
|
||||
&--paused {
|
||||
opacity: $viewer-opacity-disabled;
|
||||
transition: $viewer-transition-time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =================== OVERLAY ===================*/
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { OntimeEvent, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
|
||||
import { Message, OntimeEvent, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||
@@ -11,7 +12,9 @@ import ViewParamsEditor from '../../../common/components/view-params-editor/View
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { isStringBoolean } from '../../../common/utils/viewUtils';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils';
|
||||
|
||||
import './Timer.scss';
|
||||
@@ -40,6 +43,7 @@ const titleVariants = {
|
||||
interface TimerProps {
|
||||
isMirrored: boolean;
|
||||
pres: TimerMessage;
|
||||
external: Message;
|
||||
eventNow: OntimeEvent | null;
|
||||
eventNext: OntimeEvent | null;
|
||||
time: TimeManagerType;
|
||||
@@ -47,9 +51,10 @@ interface TimerProps {
|
||||
}
|
||||
|
||||
export default function Timer(props: TimerProps) {
|
||||
const { isMirrored, pres, eventNow, eventNext, time, viewSettings } = props;
|
||||
const { isMirrored, pres, eventNow, eventNext, time, viewSettings, external } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Timer';
|
||||
@@ -60,6 +65,26 @@ export default function Timer(props: TimerProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// USER OPTIONS
|
||||
const userOptions = {
|
||||
hideClock: false,
|
||||
hideCards: false,
|
||||
hideProgress: false,
|
||||
hideMessage: false,
|
||||
};
|
||||
|
||||
const hideClock = searchParams.get('hideClock');
|
||||
userOptions.hideClock = isStringBoolean(hideClock);
|
||||
|
||||
const hideCards = searchParams.get('hideCards');
|
||||
userOptions.hideCards = isStringBoolean(hideCards);
|
||||
|
||||
const hideProgress = searchParams.get('hideProgress');
|
||||
userOptions.hideProgress = isStringBoolean(hideProgress);
|
||||
|
||||
const hideMessage = searchParams.get('hideMessage');
|
||||
userOptions.hideMessage = isStringBoolean(hideMessage);
|
||||
|
||||
const clock = formatTime(time.clock, formatOptions);
|
||||
const showOverlay = pres.text !== '' && pres.visible;
|
||||
const isPlaying = time.playback !== Playback.Pause;
|
||||
@@ -77,6 +102,7 @@ export default function Timer(props: TimerProps) {
|
||||
const showBlinking = pres.timerBlink;
|
||||
const showBlackout = pres.timerBlackout;
|
||||
const showClock = time.timerType !== TimerType.Clock;
|
||||
const showExternal = external.visible && external.text;
|
||||
|
||||
const timerColor =
|
||||
showProgress && showDanger
|
||||
@@ -93,23 +119,33 @@ export default function Timer(props: TimerProps) {
|
||||
}
|
||||
|
||||
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''} ${showBlackout ? 'blackout' : ''}`;
|
||||
const timerFontSize = 89 / (stageTimerCharacters - 1);
|
||||
let timerFontSize = 89 / (stageTimerCharacters - 1);
|
||||
// we need to shrink the timer if the external is going to be there
|
||||
if (showExternal) {
|
||||
timerFontSize *= 0.8;
|
||||
}
|
||||
const externalFontSize = timerFontSize * 0.4;
|
||||
const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`;
|
||||
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
|
||||
|
||||
return (
|
||||
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={TIMER_OPTIONS} />
|
||||
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
||||
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
|
||||
</div>
|
||||
{!userOptions.hideMessage && (
|
||||
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
||||
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`clock-container ${showClock ? '' : 'clock-container--hidden'}`}>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<div className='clock'>{clock}</div>
|
||||
</div>
|
||||
{!userOptions.hideClock && (
|
||||
<div className={`clock-container ${showClock ? '' : 'clock-container--hidden'}`}>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<SuperscriptTime time={clock} className='clock' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`}>
|
||||
<div className={timerContainerClasses}>
|
||||
{showEndMessage ? (
|
||||
<div className='end-message'>{viewSettings.endMessage}</div>
|
||||
) : (
|
||||
@@ -123,54 +159,71 @@ export default function Timer(props: TimerProps) {
|
||||
{display}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`external${showExternal ? '' : ' external--hidden'}`}
|
||||
style={{ fontSize: `${externalFontSize}vw` }}
|
||||
>
|
||||
{external.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MultiPartProgressBar
|
||||
className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'}
|
||||
now={time.current || 0}
|
||||
complete={totalTime}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={viewSettings.warningThreshold}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={viewSettings.dangerThreshold}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hidden={!showProgress}
|
||||
/>
|
||||
{!userOptions.hideProgress && (
|
||||
<MultiPartProgressBar
|
||||
className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'}
|
||||
now={time.current}
|
||||
complete={totalTime}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={viewSettings.warningThreshold}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={viewSettings.dangerThreshold}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hidden={!showProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{eventNow && !finished && (
|
||||
<motion.div
|
||||
className='event now'
|
||||
key='now'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='now' title={eventNow.title} subtitle={eventNow.subtitle} presenter={eventNow.presenter} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{!userOptions.hideCards && (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{eventNow && !finished && (
|
||||
<motion.div
|
||||
className='event now'
|
||||
key='now'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard
|
||||
label='now'
|
||||
title={eventNow.title}
|
||||
subtitle={eventNow.subtitle}
|
||||
presenter={eventNow.presenter}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{eventNext && (
|
||||
<motion.div
|
||||
className='event next'
|
||||
key='next'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard
|
||||
label='next'
|
||||
title={eventNext.title}
|
||||
subtitle={eventNext.subtitle}
|
||||
presenter={eventNext.presenter}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{eventNext && (
|
||||
<motion.div
|
||||
className='event next'
|
||||
key='next'
|
||||
variants={titleVariants}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard
|
||||
label='next'
|
||||
title={eventNext.title}
|
||||
subtitle={eventNext.subtitle}
|
||||
presenter={eventNext.presenter}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,23 +2,6 @@
|
||||
|
||||
//////////////////////////////////// general app elements
|
||||
|
||||
@mixin main-container {
|
||||
background-color: $bg-container-l1;
|
||||
border: 1px solid $bg-container-l1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@mixin second-container {
|
||||
background-color: $bg-container-l2;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@mixin third-container {
|
||||
background-color: $bg-container-l3;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@mixin action-link {
|
||||
color: $action-text-color;
|
||||
display: flex;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// General
|
||||
$viewer-transition-time: 0.5s;
|
||||
|
||||
|
||||
// Text
|
||||
$viewer-font-family: "Open Sans", "Segoe UI", sans-serif; // --font-family-override
|
||||
$viewer-opacity-disabled: 0.6;
|
||||
@@ -28,3 +27,4 @@ $timer-color: rgba(white, 80%); // --timer-color-override
|
||||
$timer-finished-color: $playback-negative;
|
||||
$timer-bold-font-family: "Arial Black", sans-serif; // --card-background-color-override
|
||||
|
||||
$external-color: rgba(white, 70%); // --external-color-override
|
||||
|
||||
@@ -12,6 +12,11 @@ const commonStyles = {
|
||||
border: '1px solid #578AF4', // $blue-500
|
||||
},
|
||||
_placeholder: { color: '#9d9d9d' }, // $gray-500
|
||||
_disabled: {
|
||||
_hover: {
|
||||
backgroundColor: '#262626', // $gray-1200
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimeInputFilled = {
|
||||
@@ -30,6 +35,11 @@ export const ontimeInputFilledOnLight = {
|
||||
_focus: {
|
||||
border: '2px solid #578AF4', // $blue-500
|
||||
},
|
||||
_disabled: {
|
||||
_hover: {
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,4 +68,9 @@ export const ontimeTextAreaFilledOnLight = {
|
||||
border: '2px solid #578AF4', // $blue-500
|
||||
},
|
||||
_placeholder: { color: '#9d9d9d' }, // $gray-500
|
||||
_disabled: {
|
||||
_hover: {
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "2.16.2",
|
||||
"version": "2.21.3",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -21,6 +21,7 @@
|
||||
"scripts": {
|
||||
"postinstall": "",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
"dev:electron": "cross-env NODE_ENV=development electron .",
|
||||
"dist-win": "electron-builder --publish=never --x64 --win",
|
||||
"dist-mac": "electron-builder --publish=never --mac",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "2.16.2",
|
||||
"version": "2.21.3",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"body-parser": "^1.20.0",
|
||||
@@ -49,10 +49,12 @@
|
||||
"dev:test": "cross-env IS_TEST=true nodemon --exec \"ts-node-esm\" ./src/index.ts",
|
||||
"prebuild": "pnpm setdb",
|
||||
"build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
|
||||
"build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
|
||||
"build:local": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
|
||||
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
|
||||
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
"test": "cross-env IS_TEST=true vitest",
|
||||
"test:pipeline": "cross-env IS_TEST=true vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -33,6 +33,7 @@ import { populateStyles } from './modules/loadStyles.js';
|
||||
import { eventStore, getInitialPayload } from './stores/EventStore.js';
|
||||
import { PlaybackService } from './services/PlaybackService.js';
|
||||
import { RestorePoint, restoreService } from './services/RestoreService.js';
|
||||
import { messageService } from './services/message-service/MessageService.js';
|
||||
|
||||
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
|
||||
@@ -155,6 +156,9 @@ export const startServer = async () => {
|
||||
const initialPayload = getInitialPayload();
|
||||
eventStore.init(initialPayload);
|
||||
|
||||
// eventStore set is a dependency of the services that publish to it
|
||||
messageService.init(eventStore.set.bind(eventStore));
|
||||
|
||||
expressServer.listen(serverPort, '0.0.0.0');
|
||||
|
||||
return { message: returnMessage, serverPort };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Alias, DatabaseModel, OntimeRundown, Settings } from 'ontime-types';
|
||||
import { safeMerge } from '../DataProvider.utils.js';
|
||||
|
||||
describe('safeMerge', () => {
|
||||
@@ -5,8 +6,10 @@ describe('safeMerge', () => {
|
||||
rundown: [],
|
||||
project: {
|
||||
title: 'existing title',
|
||||
description: 'existing description',
|
||||
publicUrl: 'existing public URL',
|
||||
backstageUrl: 'existing backstageUrl',
|
||||
publicInfo: 'existing backstageInfo',
|
||||
backstageInfo: 'existing backstageInfo',
|
||||
},
|
||||
settings: {
|
||||
@@ -42,7 +45,7 @@ describe('safeMerge', () => {
|
||||
onFinish: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
} as DatabaseModel;
|
||||
|
||||
it('returns existing data if new data is not provided', () => {
|
||||
const mergedData = safeMerge(existing, undefined);
|
||||
@@ -51,7 +54,7 @@ describe('safeMerge', () => {
|
||||
|
||||
it('merges the rundown key', () => {
|
||||
const newData = {
|
||||
rundown: [{ name: 'item 1' }, { name: 'item 2' }],
|
||||
rundown: [{ title: 'item 1' }, { title: 'item 2' }] as OntimeRundown,
|
||||
};
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.rundown).toEqual(newData.rundown);
|
||||
@@ -64,9 +67,11 @@ describe('safeMerge', () => {
|
||||
publicInfo: 'new public info',
|
||||
},
|
||||
};
|
||||
// @ts-expect-error -- just testing
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.project).toEqual({
|
||||
title: 'new title',
|
||||
description: 'existing description',
|
||||
publicUrl: 'existing public URL',
|
||||
publicInfo: 'new public info',
|
||||
backstageUrl: 'existing backstageUrl',
|
||||
@@ -79,7 +84,7 @@ describe('safeMerge', () => {
|
||||
settings: {
|
||||
serverPort: 3000,
|
||||
language: 'pt',
|
||||
},
|
||||
} as Settings,
|
||||
};
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.settings).toEqual({
|
||||
@@ -108,6 +113,7 @@ describe('safeMerge', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
//@ts-expect-error -- testing partial merge
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.osc).toEqual({
|
||||
portIn: 7777,
|
||||
@@ -135,7 +141,7 @@ describe('safeMerge', () => {
|
||||
it('should merge the aliases key when present', () => {
|
||||
const existingData = {
|
||||
rundown: [],
|
||||
event: {
|
||||
project: {
|
||||
title: '',
|
||||
publicUrl: '',
|
||||
publicInfo: '',
|
||||
@@ -183,10 +189,13 @@ describe('safeMerge', () => {
|
||||
onFinish: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
} as DatabaseModel;
|
||||
|
||||
const newData = {
|
||||
aliases: ['alias1', 'alias2'],
|
||||
aliases: [
|
||||
{ enabled: true, alias: 'alias1', pathAndParams: '' },
|
||||
{ enabled: true, alias: 'alias2', pathAndParams: '' },
|
||||
] as Alias[],
|
||||
};
|
||||
|
||||
const mergedData = safeMerge(existingData, newData);
|
||||
@@ -217,6 +226,7 @@ describe('safeMerge', () => {
|
||||
user3: 'David',
|
||||
};
|
||||
|
||||
//@ts-expect-error -- testing partial merge
|
||||
const result = safeMerge(existing, newData);
|
||||
expect(result.userFields).toEqual(expected);
|
||||
});
|
||||
|
||||
@@ -109,6 +109,20 @@ export function dispatchFromAdapter(
|
||||
break;
|
||||
}
|
||||
|
||||
case 'set-external-message-text': {
|
||||
if (typeof payload !== 'string') {
|
||||
throw new Error(`Unable to parse payload: ${payload}`);
|
||||
}
|
||||
messageService.setExternalText(payload);
|
||||
return;
|
||||
}
|
||||
case 'set-external-message-visible': {
|
||||
if (typeof payload === 'undefined') {
|
||||
throw new Error(`Unable to parse payload: ${payload}`);
|
||||
}
|
||||
messageService.setExternalVisibility(Boolean(payload));
|
||||
break;
|
||||
}
|
||||
case 'start': {
|
||||
PlaybackService.start();
|
||||
break;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { GetRundownCached } from 'ontime-types';
|
||||
|
||||
import { Request, Response, RequestHandler } from 'express';
|
||||
|
||||
import { failEmptyObjects } from '../utils/routerUtils.js';
|
||||
import {
|
||||
addEvent,
|
||||
@@ -8,8 +12,7 @@ import {
|
||||
reorderEvent,
|
||||
swapEvents,
|
||||
} from '../services/rundown-service/RundownService.js';
|
||||
import { getDelayedRundown } from '../services/rundown-service/delayedRundown.utils.js';
|
||||
import { RequestHandler } from 'express';
|
||||
import { getDelayedRundown, getRundownCache } from '../services/rundown-service/delayedRundown.utils.js';
|
||||
|
||||
// Create controller for GET request to '/events'
|
||||
// Returns -
|
||||
@@ -18,6 +21,13 @@ export const rundownGetAll: RequestHandler = async (_req, res) => {
|
||||
res.json(delayedRundown);
|
||||
};
|
||||
|
||||
// Create controller for GET request to '/events/cached'
|
||||
// Returns -
|
||||
export const rundownGetCached: RequestHandler = async (_req: Request, res: Response<GetRundownCached>) => {
|
||||
const cachedRundown = getRundownCache();
|
||||
res.json(cachedRundown);
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/events/'
|
||||
// Returns -
|
||||
export const rundownPost: RequestHandler = async (req, res) => {
|
||||
|
||||
@@ -12,10 +12,17 @@
|
||||
--timer-progress-bg-override: #fff;
|
||||
--timer-progress-override: #202020;
|
||||
|
||||
--external-color-override: #161616;
|
||||
|
||||
--cuesheet-running-bg-override: #D20300;
|
||||
|
||||
--operator-running-bg-override: #D20300;
|
||||
--operator-highlight-override: #FFAB33;
|
||||
|
||||
--studio-active: #101010;
|
||||
--studio-idle: #cfcfcf;
|
||||
--studio-active-label: #101010;
|
||||
--studio-idle-label: #595959;
|
||||
}
|
||||
|
||||
.timer {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
rundownApplyDelay,
|
||||
rundownDelete,
|
||||
rundownGetAll,
|
||||
rundownGetCached,
|
||||
rundownPost,
|
||||
rundownPut,
|
||||
rundownReorder,
|
||||
@@ -19,6 +20,9 @@ import {
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
// create route between controller and '/events/cached' endpoint
|
||||
router.get('/cached', rundownGetCached);
|
||||
|
||||
// create route between controller and '/events/' endpoint
|
||||
router.get('/', rundownGetAll);
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Message } from 'ontime-types';
|
||||
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
import { TimerMessage } from 'ontime-types/src/definitions/runtime/MessageControl.type.js';
|
||||
import { throttle } from '../../utils/throttle.js';
|
||||
|
||||
import type { PublishFn } from '../../stores/EventStore.js';
|
||||
|
||||
let instance;
|
||||
|
||||
@@ -9,8 +11,12 @@ class MessageService {
|
||||
timerMessage: TimerMessage;
|
||||
publicMessage: Message;
|
||||
lowerMessage: Message;
|
||||
externalMessage: Message;
|
||||
onAir: boolean;
|
||||
|
||||
private throttledSet: PublishFn;
|
||||
private publish: PublishFn | null;
|
||||
|
||||
constructor() {
|
||||
if (instance) {
|
||||
throw new Error('There can be only one');
|
||||
@@ -36,7 +42,40 @@ class MessageService {
|
||||
visible: false,
|
||||
};
|
||||
|
||||
this.externalMessage = {
|
||||
text: '',
|
||||
visible: false,
|
||||
};
|
||||
|
||||
this.onAir = false;
|
||||
this.throttledSet = () => {
|
||||
throw new Error('Published called before initialisation');
|
||||
};
|
||||
}
|
||||
|
||||
init(publish: PublishFn) {
|
||||
this.publish = publish;
|
||||
this.throttledSet = throttle((key, value) => this.publish(key, value), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description sets message on stage timer screen
|
||||
*/
|
||||
setExternalText(payload: string) {
|
||||
if (this.externalMessage.text !== payload) {
|
||||
this.externalMessage.text = payload;
|
||||
this.throttledSet('externalMessage', this.externalMessage);
|
||||
}
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description sets message visibility on stage timer screen
|
||||
*/
|
||||
setExternalVisibility(status: boolean) {
|
||||
this.externalMessage.visible = status;
|
||||
this.throttledSet('externalMessage', this.externalMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,7 +83,7 @@ class MessageService {
|
||||
*/
|
||||
setTimerText(payload: string) {
|
||||
this.timerMessage.text = payload;
|
||||
eventStore.set('timerMessage', this.timerMessage);
|
||||
this.throttledSet('timerMessage', this.timerMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
@@ -53,7 +92,7 @@ class MessageService {
|
||||
*/
|
||||
setTimerVisibility(status: boolean) {
|
||||
this.timerMessage.visible = status;
|
||||
eventStore.set('timerMessage', this.timerMessage);
|
||||
this.throttledSet('timerMessage', this.timerMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
@@ -62,7 +101,7 @@ class MessageService {
|
||||
*/
|
||||
setPublicText(payload: string) {
|
||||
this.publicMessage.text = payload;
|
||||
eventStore.set('publicMessage', this.publicMessage);
|
||||
this.throttledSet('publicMessage', this.publicMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
@@ -71,7 +110,7 @@ class MessageService {
|
||||
*/
|
||||
setPublicVisibility(status: boolean) {
|
||||
this.publicMessage.visible = status;
|
||||
eventStore.set('publicMessage', this.publicMessage);
|
||||
this.throttledSet('publicMessage', this.publicMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
@@ -80,7 +119,7 @@ class MessageService {
|
||||
*/
|
||||
setLowerText(payload: string) {
|
||||
this.lowerMessage.text = payload;
|
||||
eventStore.set('lowerMessage', this.lowerMessage);
|
||||
this.throttledSet('lowerMessage', this.lowerMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
@@ -89,7 +128,7 @@ class MessageService {
|
||||
*/
|
||||
setLowerVisibility(status: boolean) {
|
||||
this.lowerMessage.visible = status;
|
||||
eventStore.set('lowerMessage', this.lowerMessage);
|
||||
this.throttledSet('lowerMessage', this.lowerMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
@@ -102,7 +141,7 @@ class MessageService {
|
||||
} else {
|
||||
this.onAir = status;
|
||||
}
|
||||
eventStore.set('onAir', this.onAir);
|
||||
this.throttledSet('onAir', this.onAir);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
@@ -116,7 +155,7 @@ class MessageService {
|
||||
} else {
|
||||
this.timerMessage.timerBlink = status;
|
||||
}
|
||||
eventStore.set('timerMessage', this.timerMessage);
|
||||
this.throttledSet('timerMessage', this.timerMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
@@ -130,7 +169,7 @@ class MessageService {
|
||||
} else {
|
||||
this.timerMessage.timerBlackout = status;
|
||||
}
|
||||
eventStore.set('timerMessage', this.timerMessage);
|
||||
this.throttledSet('timerMessage', this.timerMessage);
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
GetRundownCached,
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
@@ -16,6 +17,11 @@ import { isProduction } from '../../setup.js';
|
||||
import { deleteAtIndex, insertAtIndex, reorderArray } from '../../utils/arrayUtils.js';
|
||||
import { _applyDelay } from '../delayUtils.js';
|
||||
|
||||
/**
|
||||
* Keep incremental revision number of rundown for runtime
|
||||
*/
|
||||
let rundownRevision = 0;
|
||||
|
||||
/**
|
||||
* Key of rundown in cache
|
||||
*/
|
||||
@@ -38,7 +44,25 @@ export function invalidateFromError(errorMessage = 'Found mismatch between store
|
||||
* Returns rundown with calculated delays
|
||||
* Ensures request goes through the caching layer
|
||||
*/
|
||||
export function getDelayedRundown(): OntimeRundown {
|
||||
export function getRundownCache(): GetRundownCached {
|
||||
function calculateRundown() {
|
||||
const rundown = DataProvider.getRundown();
|
||||
return calculateRuntimeDelays(rundown);
|
||||
}
|
||||
|
||||
const cached = getCached(delayedRundownCacheKey, calculateRundown);
|
||||
|
||||
return {
|
||||
rundown: cached,
|
||||
revision: rundownRevision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns rundown with calculated delays
|
||||
* Ensures request goes through the caching layer
|
||||
*/
|
||||
export function getDelayedRundown() {
|
||||
function calculateRundown() {
|
||||
const rundown = DataProvider.getRundown();
|
||||
return calculateRuntimeDelays(rundown);
|
||||
@@ -72,6 +96,8 @@ export async function cachedAdd(eventIndex: number, event: OntimeEvent | OntimeD
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, newDelayedRundown);
|
||||
// we need to delay updating this to ensure add operation happens on same dataset
|
||||
await DataProvider.setRundown(newRundown);
|
||||
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,6 +139,8 @@ export async function cachedEdit(
|
||||
// we need to delay updating this to ensure edit operation happens on same dataset
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
|
||||
rundownRevision++;
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
@@ -147,6 +175,8 @@ export async function cachedDelete(eventId: string) {
|
||||
}
|
||||
// we need to delay updating this to ensure edit operation happens on same dataset
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,12 +208,15 @@ export async function cachedReorder(eventId: string, from: number, to: number) {
|
||||
// we need to delay updating this to ensure edit operation happens on same dataset
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
|
||||
rundownRevision++;
|
||||
|
||||
return reorderedEvent;
|
||||
}
|
||||
|
||||
export async function cachedClear() {
|
||||
await DataProvider.clearRundown();
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, []);
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,6 +244,8 @@ export async function cachedSwap(fromEventId: string, toEventId: string) {
|
||||
}
|
||||
|
||||
await DataProvider.setRundown(rundownToUpdate);
|
||||
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
export async function cachedApplyDelay(eventId: string) {
|
||||
@@ -224,6 +259,8 @@ export async function cachedApplyDelay(eventId: string) {
|
||||
// update
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, cachedRundown);
|
||||
await DataProvider.setRundown(persistedRundown);
|
||||
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,8 @@ import { eventTimer } from '../services/TimerService.js';
|
||||
import { messageService } from '../services/message-service/MessageService.js';
|
||||
import { eventLoader } from '../classes/event-loader/EventLoader.js';
|
||||
|
||||
export type PublishFn = <T extends keyof RuntimeStore>(key: T, value: RuntimeStore[T]) => void;
|
||||
|
||||
let store: Partial<RuntimeStore> = {};
|
||||
|
||||
/**
|
||||
@@ -68,6 +70,7 @@ export const getInitialPayload = () => ({
|
||||
timerMessage: messageService.timerMessage,
|
||||
publicMessage: messageService.publicMessage,
|
||||
lowerMessage: messageService.lowerMessage,
|
||||
externalMessage: messageService.externalMessage,
|
||||
onAir: messageService.onAir,
|
||||
loaded: eventLoader.loaded,
|
||||
eventNow: eventLoader.eventNow,
|
||||
|
||||
@@ -34,13 +34,13 @@ describe('mergeObject()', () => {
|
||||
third: 'yes',
|
||||
};
|
||||
const b = {
|
||||
first: 0,
|
||||
first: 'no',
|
||||
second: null,
|
||||
third: '',
|
||||
};
|
||||
const merged = mergeObject(a, b);
|
||||
expect(merged).toStrictEqual({
|
||||
first: 0,
|
||||
first: 'no',
|
||||
second: null,
|
||||
third: '',
|
||||
});
|
||||
@@ -57,6 +57,7 @@ describe('mergeObject()', () => {
|
||||
third: '',
|
||||
forth: 'not-this',
|
||||
};
|
||||
// @ts-expect-error -- testing changing type
|
||||
const merged = mergeObject(a, b);
|
||||
expect(merged).toStrictEqual({
|
||||
first: 0,
|
||||
@@ -83,6 +84,7 @@ describe('mergeObject()', () => {
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error -- testing missing property
|
||||
const merged = mergeObject(a, b);
|
||||
|
||||
expect(merged.name).toBe('Doe');
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Creates a throttled version of the passed function
|
||||
* This function uses a leading algorithm
|
||||
* which means that the function will be executed immediately on first call
|
||||
* @param {Function} cb - function to throttle
|
||||
* @param {number} delay - time (in ms) to throttle
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function throttle<T extends any[], U>(cb: (...args: T) => U, delay: number) {
|
||||
let shouldWait = false;
|
||||
let waitingArgs;
|
||||
const timeoutFunc = () => {
|
||||
if (waitingArgs == null) {
|
||||
shouldWait = false;
|
||||
} else {
|
||||
cb(...waitingArgs);
|
||||
waitingArgs = null;
|
||||
setTimeout(timeoutFunc, delay);
|
||||
}
|
||||
};
|
||||
|
||||
return (...args: T) => {
|
||||
if (shouldWait) {
|
||||
waitingArgs = args;
|
||||
return;
|
||||
}
|
||||
|
||||
cb(...args);
|
||||
shouldWait = true;
|
||||
setTimeout(timeoutFunc, delay);
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('test', async ({ context }) => {
|
||||
test('message control sends messages to screens', async ({ context }) => {
|
||||
const editorPage = await context.newPage();
|
||||
const featurePage = await context.newPage();
|
||||
|
||||
@@ -25,9 +25,9 @@ test('test', async ({ context }) => {
|
||||
await featurePage.getByText('testing lower').click({ timeout: 5000 });
|
||||
|
||||
// stage timer message
|
||||
await editorPage.getByPlaceholder('Shown in stage timer').click();
|
||||
await editorPage.getByPlaceholder('Shown in stage timer').fill('testing stage');
|
||||
await editorPage.getByRole('button', { name: /toggle timer message/i }).click({ timeout: 5000 });
|
||||
await editorPage.getByPlaceholder('Timer').click();
|
||||
await editorPage.getByPlaceholder('Timer').fill('testing stage');
|
||||
await editorPage.getByRole('button', { name: /toggle timer/i }).click({ timeout: 5000 });
|
||||
|
||||
await featurePage.goto('http://localhost:4001/timer');
|
||||
await featurePage.waitForLoadState('load', { timeout: 5000 });
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test aliases feature, it should redirect to given alias', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/editor');
|
||||
|
||||
// open settings
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByRole('tab', { name: 'URL Aliases' }).click();
|
||||
|
||||
// create alias
|
||||
await page.getByRole('button', { name: 'Add new' }).click();
|
||||
await page.getByTestId('field__alias_1').fill('testing');
|
||||
await page.getByTestId('field__url_1').fill('countdown');
|
||||
await page.getByTestId('field__enable_1').click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// make sure alias works
|
||||
await page.goto('http://localhost:4001/testing');
|
||||
await page.getByText('Select an event to follow').click();
|
||||
});
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "2.16.2",
|
||||
"version": "2.21.3",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"lighdev",
|
||||
@@ -29,6 +29,7 @@
|
||||
"lint-staged": "turbo run lint-staged --concurrency=1",
|
||||
"build": "turbo run build",
|
||||
"build:local": "turbo run build:local",
|
||||
"build:electron": "turbo run build:electron",
|
||||
"dist-win": "turbo run dist-win",
|
||||
"dist-mac": "turbo run dist-mac",
|
||||
"dist-linux": "turbo run dist-linux",
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"description": "shared typings for ontime",
|
||||
"scripts": {
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules",
|
||||
"lint": "eslint . --quiet"
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { OntimeRundown } from '../../definitions/core/Rundown.type.js';
|
||||
|
||||
export interface GetRundownCached {
|
||||
rundown: OntimeRundown;
|
||||
revision: number;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export type RuntimeStore = {
|
||||
timerMessage: TimerMessage;
|
||||
publicMessage: Message;
|
||||
lowerMessage: Message;
|
||||
externalMessage: Message;
|
||||
onAir: boolean;
|
||||
|
||||
// event loader
|
||||
|
||||
@@ -35,6 +35,7 @@ export type { OSCSettings, OscSubscription, OscSubscriptionOptions } from './def
|
||||
|
||||
// SERVER RESPONSES
|
||||
export type { NetworkInterface, GetInfo } from './api/ontime-controller/BackendResponse.type.js';
|
||||
export type { GetRundownCached } from './api/rundown-controller/BackendResponse.type.js';
|
||||
|
||||
// SERVER RUNTIME
|
||||
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"description": "shared logic for ontime",
|
||||
"scripts": {
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
"test": "vitest",
|
||||
"test:pipeline": "vitest run",
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules"
|
||||
|
||||
Generated
+32
-53
@@ -78,11 +78,11 @@ importers:
|
||||
specifier: ^7.46.0
|
||||
version: 7.46.0
|
||||
'@tanstack/react-query':
|
||||
specifier: ^4.28.0
|
||||
version: 4.28.0(react-dom@18.2.0)(react@18.2.0)
|
||||
specifier: ^5.8.4
|
||||
version: 5.8.4(react-dom@18.2.0)(react@18.2.0)
|
||||
'@tanstack/react-query-devtools':
|
||||
specifier: ^4.29.0
|
||||
version: 4.29.0(@tanstack/react-query@4.28.0)(react-dom@18.2.0)(react@18.2.0)
|
||||
specifier: ^5.8.4
|
||||
version: 5.8.4(@tanstack/react-query@5.8.4)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.9.2
|
||||
version: 8.9.2(react-dom@18.2.0)(react@18.2.0)
|
||||
@@ -139,8 +139,8 @@ importers:
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0
|
||||
'@tanstack/eslint-plugin-query':
|
||||
specifier: ^4.26.2
|
||||
version: 4.26.2
|
||||
specifier: ^5.8.4
|
||||
version: 5.8.4(eslint@8.53.0)(typescript@5.2.2)
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^5.16.5
|
||||
version: 5.16.5
|
||||
@@ -2704,41 +2704,44 @@ packages:
|
||||
defer-to-connect: 2.0.1
|
||||
dev: true
|
||||
|
||||
/@tanstack/eslint-plugin-query@4.26.2:
|
||||
resolution: {integrity: sha512-ugAvl6Is+bUMLt9BlAnXK6Wi7UnGV+4RwJ2W1ToFoucPvUb2Uf+ADU38JkHaNsI/TFgE3+kePhKh0zzDBhkw0Q==}
|
||||
/@tanstack/eslint-plugin-query@5.8.4(eslint@8.53.0)(typescript@5.2.2):
|
||||
resolution: {integrity: sha512-KVgcMc+Bn1qbwkxYVWQoiVSNEIN4IAiLj3cUH/SAHT8m8E59Y97o8ON1syp0Rcw094ItG8pEVZFyQuOaH6PDgQ==}
|
||||
peerDependencies:
|
||||
eslint: ^8.0.0
|
||||
dependencies:
|
||||
'@typescript-eslint/utils': 5.62.0(eslint@8.53.0)(typescript@5.2.2)
|
||||
eslint: 8.53.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
dev: true
|
||||
|
||||
/@tanstack/match-sorter-utils@8.7.6:
|
||||
resolution: {integrity: sha512-2AMpRiA6QivHOUiBpQAVxjiHAA68Ei23ZUMNaRJrN6omWiSFLoYrxGcT6BXtuzp0Jw4h6HZCmGGIM/gbwebO2A==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
remove-accents: 0.4.2
|
||||
/@tanstack/query-core@5.8.3:
|
||||
resolution: {integrity: sha512-SWFMFtcHfttLYif6pevnnMYnBvxKf3C+MHMH7bevyYfpXpTMsLB9O6nNGBdWSoPwnZRXFNyNeVZOw25Wmdasow==}
|
||||
dev: false
|
||||
|
||||
/@tanstack/query-core@4.27.0:
|
||||
resolution: {integrity: sha512-sm+QncWaPmM73IPwFlmWSKPqjdTXZeFf/7aEmWh00z7yl2FjqophPt0dE1EHW9P1giMC5rMviv7OUbSDmWzXXA==}
|
||||
/@tanstack/query-devtools@5.8.4:
|
||||
resolution: {integrity: sha512-F1dRbITNt9tMUoM9WCH8WQ2c54116hv52m/PKK8ZiN/pO2wGVzTZtKuLanF8pFpwmNchjIixcMw/a57HY5ivcw==}
|
||||
dev: false
|
||||
|
||||
/@tanstack/react-query-devtools@4.29.0(@tanstack/react-query@4.28.0)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-bzotqin4Wa/GlPgJ2dI7eggQcbMDLIOwEClHGrkyie76DbT8vEEmEV9Kbh6kriKVSqCLpa9ZrgG/f8/Bx1zIwA==}
|
||||
/@tanstack/react-query-devtools@5.8.4(@tanstack/react-query@5.8.4)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-mffs51FJqXU/5rwhbwv393DccL6et7uK2pRLwOcmMrWbPyW8vpxr9oidaghHX4cdVeP/7u5owW9yMpBhBAJfcQ==}
|
||||
peerDependencies:
|
||||
'@tanstack/react-query': 4.28.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
'@tanstack/react-query': ^5.8.4
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
dependencies:
|
||||
'@tanstack/match-sorter-utils': 8.7.6
|
||||
'@tanstack/react-query': 4.28.0(react-dom@18.2.0)(react@18.2.0)
|
||||
'@tanstack/query-devtools': 5.8.4
|
||||
'@tanstack/react-query': 5.8.4(react-dom@18.2.0)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
superjson: 1.12.1
|
||||
use-sync-external-store: 1.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@tanstack/react-query@4.28.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-8cGBV5300RHlvYdS4ea+G1JcZIt5CIuprXYFnsWggkmGoC0b5JaqG0fIX3qwDL9PTNkKvG76NGThIWbpXivMrQ==}
|
||||
/@tanstack/react-query@5.8.4(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-CD+AkXzg8J72JrE6ocmuBEJfGzEzu/bzkD6sFXFDDB5yji9N20JofXZlN6n0+CaPJuIi+e4YLCbGsyPFKkfNQA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
react-native: '*'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
@@ -2746,10 +2749,9 @@ packages:
|
||||
react-native:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@tanstack/query-core': 4.27.0
|
||||
'@tanstack/query-core': 5.8.3
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
use-sync-external-store: 1.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@tanstack/react-table@8.9.2(react-dom@18.2.0)(react@18.2.0):
|
||||
@@ -4243,13 +4245,6 @@ packages:
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/copy-anything@3.0.3:
|
||||
resolution: {integrity: sha512-fpW2W/BqEzqPp29QS+MwwfisHCQZtiduTe/m8idFo0xbti9fIZ2WVhAsCv4ggFVH3AgCkVdpoOCtQC6gBrdhjw==}
|
||||
engines: {node: '>=12.13'}
|
||||
dependencies:
|
||||
is-what: 4.1.8
|
||||
dev: false
|
||||
|
||||
/copy-to-clipboard@3.3.3:
|
||||
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
|
||||
dependencies:
|
||||
@@ -6131,11 +6126,6 @@ packages:
|
||||
get-intrinsic: 1.1.3
|
||||
dev: true
|
||||
|
||||
/is-what@4.1.8:
|
||||
resolution: {integrity: sha512-yq8gMao5upkPoGEU9LsB2P+K3Kt8Q3fQFCGyNCWOAnJAMzEXVV9drYb0TXr42TTliLLhKIBvulgAXgtLLnwzGA==}
|
||||
engines: {node: '>=12.13'}
|
||||
dev: false
|
||||
|
||||
/is-wsl@2.2.0:
|
||||
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7488,10 +7478,6 @@ packages:
|
||||
functions-have-names: 1.2.3
|
||||
dev: true
|
||||
|
||||
/remove-accents@0.4.2:
|
||||
resolution: {integrity: sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==}
|
||||
dev: false
|
||||
|
||||
/require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -8001,13 +7987,6 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/superjson@1.12.1:
|
||||
resolution: {integrity: sha512-HMTj43zvwW5bD+JCZCvFf4DkZQCmiLTen4C+W1Xogj0SPOpnhxsriogM04QmBVGH5b3kcIIOr6FqQ/aoIDx7TQ==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
copy-anything: 3.0.3
|
||||
dev: false
|
||||
|
||||
/supports-color@5.5.0:
|
||||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user