mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-18 13:44:12 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9287248333 | |||
| 0957c67474 | |||
| 979b6dc6d8 | |||
| 6dc8bf7eb0 | |||
| f1f628a48e | |||
| d58335d1a1 | |||
| 78c2bd79b5 | |||
| 28452d1e38 | |||
| 46790441e0 | |||
| 3f35b5e297 | |||
| 49cae647d6 | |||
| 90700c85e8 | |||
| 8fac23c078 | |||
| b5cf6c4bd1 | |||
| 0ea00ff6be | |||
| 30e290c320 | |||
| ac52b3f0c1 | |||
| 8e3db6c559 | |||
| 1d2013ad41 | |||
| 9c910c079d | |||
| 420cef8b0d | |||
| b4c7b84f92 | |||
| 0450eb9169 | |||
| 7fe5223af9 | |||
| ce8d534953 | |||
| 4a6fbc2a92 | |||
| 50ebcf4e14 | |||
| f0f7d1cede | |||
| d2bdb1ea03 | |||
| 9be6febcda | |||
| aa929fd0dc | |||
| 7baa6a4ab9 | |||
| 082fe8b8d7 |
@@ -24,6 +24,7 @@ COPY --from=builder /app/apps/client/build ./client/
|
|||||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||||
COPY --from=builder /app/apps/server/src/user/ ./user/
|
COPY --from=builder /app/apps/server/src/user/ ./user/
|
||||||
|
COPY --from=builder /app/apps/server/src/html/ ./html/
|
||||||
|
|
||||||
# Export default ports
|
# Export default ports
|
||||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
[](https://github.com/sponsors/cpvalente)
|
[](https://github.com/sponsors/cpvalente)
|
||||||
[](https://www.buymeacoffee.com/cpvalente)
|
[](https://www.buymeacoffee.com/cpvalente)
|
||||||
|
|
||||||
@@ -12,7 +13,11 @@
|
|||||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
|
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
|
||||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
|
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
|
||||||
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
|
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
|
||||||
|
|
||||||
|
... or
|
||||||
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
||||||
|
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
|
||||||
|
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
|
||||||
|
|
||||||
## Need help?
|
## Need help?
|
||||||
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
|
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.10.3",
|
"version": "3.10.7",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
],
|
],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"react",
|
"react",
|
||||||
"testing-library",
|
|
||||||
"simple-import-sort",
|
"simple-import-sort",
|
||||||
"@tanstack/query"
|
"@tanstack/query"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.10.3",
|
"version": "3.10.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@emotion/is-prop-valid": "^1.3.1",
|
||||||
"@emotion/react": "^11.10.6",
|
"@emotion/react": "^11.10.6",
|
||||||
"@emotion/styled": "^11.10.6",
|
"@emotion/styled": "^11.10.6",
|
||||||
"@fontsource/open-sans": "^5.0.28",
|
"@fontsource/open-sans": "^5.0.28",
|
||||||
@@ -62,13 +63,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sentry/vite-plugin": "^2.16.1",
|
"@sentry/vite-plugin": "^2.16.1",
|
||||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
"@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",
|
|
||||||
"@types/color": "^3.0.3",
|
"@types/color": "^3.0.3",
|
||||||
"@types/react": "^18.0.26",
|
"@types/react": "^18.0.26",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
"@types/testing-library__jest-dom": "^5.14.5",
|
|
||||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
"@typescript-eslint/parser": "catalog:",
|
"@typescript-eslint/parser": "catalog:",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -79,8 +76,7 @@
|
|||||||
"eslint-plugin-react": "^7.32.0",
|
"eslint-plugin-react": "^7.32.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||||
"eslint-plugin-testing-library": "^5.9.1",
|
"happy-dom": "^16.7.2",
|
||||||
"jsdom": "^21.1.0",
|
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
|
|||||||
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||||
|
|
||||||
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
|
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
||||||
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||||
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
||||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||||
|
|||||||
@@ -19,3 +19,10 @@ export async function getSettings(): Promise<Settings> {
|
|||||||
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
|
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
|
||||||
return axios.post(settingsPath, data);
|
return axios.post(settingsPath, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows setting the welcome modal dialog state from the clients
|
||||||
|
*/
|
||||||
|
export async function postShowWelcomeDialog(show: boolean) {
|
||||||
|
axios.post(`${settingsPath}/welcomedialog`, { show });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
$progress-bar-size: 12px;
|
$progress-bar-size: 1.5rem;
|
||||||
$progress-bar-br: 3px;
|
$progress-bar-br: 3px;
|
||||||
|
|
||||||
.multiprogress-bar {
|
.multiprogress-bar {
|
||||||
@@ -53,3 +53,6 @@ $progress-bar-br: 3px;
|
|||||||
.multiprogress-bar__bg-danger {
|
.multiprogress-bar__bg-danger {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.multiprogress-bar__bg-danger--overtime {
|
||||||
|
background: var(--timer-overtime-color-override, $timer-finished-color);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MaybeNumber } from 'ontime-types';
|
import { MaybeNumber } from 'ontime-types';
|
||||||
|
|
||||||
import { getProgress } from '../../utils/getProgress';
|
import { getProgress } from '../../utils/getProgress';
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
import './MultiPartProgressBar.scss';
|
import './MultiPartProgressBar.scss';
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ interface MultiPartProgressBar {
|
|||||||
danger?: MaybeNumber;
|
danger?: MaybeNumber;
|
||||||
dangerColor: string;
|
dangerColor: string;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
hideOvertime?: boolean;
|
||||||
ignoreCssOverride?: boolean;
|
ignoreCssOverride?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -27,6 +29,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
|||||||
danger,
|
danger,
|
||||||
dangerColor,
|
dangerColor,
|
||||||
hidden,
|
hidden,
|
||||||
|
hideOvertime,
|
||||||
ignoreCssOverride,
|
ignoreCssOverride,
|
||||||
className = '',
|
className = '',
|
||||||
} = props;
|
} = props;
|
||||||
@@ -34,12 +37,17 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
|||||||
const percentRemaining = 100 - getProgress(now, complete);
|
const percentRemaining = 100 - getProgress(now, complete);
|
||||||
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
|
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
|
||||||
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
|
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
|
||||||
|
const isOvertime = now !== null && now < 0;
|
||||||
|
const showOvertime = isOvertime && !hideOvertime;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
|
className={cx([
|
||||||
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
|
'multiprogress-bar',
|
||||||
} ${className}`}
|
hidden && 'multiprogress-bar--hidden',
|
||||||
|
ignoreCssOverride && 'multiprogress-bar--ignore-css-override',
|
||||||
|
className,
|
||||||
|
])}
|
||||||
>
|
>
|
||||||
{now !== null && (
|
{now !== null && (
|
||||||
<>
|
<>
|
||||||
@@ -50,8 +58,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
|||||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className='multiprogress-bar__bg-danger'
|
className={cx(['multiprogress-bar__bg-danger', showOvertime && 'multiprogress-bar__bg-danger--overtime'])}
|
||||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
style={{ width: `${dangerWidth}%`, backgroundColor: showOvertime ? undefined : dangerColor }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='multiprogress-bar__indicator'>
|
<div className='multiprogress-bar__indicator'>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, PropsWithChildren, useRef } from 'react';
|
import { memo, PropsWithChildren } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +19,6 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
|||||||
|
|
||||||
import { isLocalhost } from '../../../externals';
|
import { isLocalhost } from '../../../externals';
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import useClickOutside from '../../hooks/useClickOutside';
|
|
||||||
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
||||||
import useInfo from '../../hooks-query/useInfo';
|
import useInfo from '../../hooks-query/useInfo';
|
||||||
import { useClientStore } from '../../stores/clientStore';
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
@@ -48,12 +47,8 @@ function NavigationMenu(props: NavigationMenuProps) {
|
|||||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useClickOutside(menuRef, () => onClose);
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div id='navigation-menu-portal' ref={menuRef}>
|
<div id='navigation-menu-portal'>
|
||||||
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||||
<DrawerOverlay />
|
<DrawerOverlay />
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
|
$title-primary-size: clamp(1.5rem, 3vw, 3rem);
|
||||||
|
$title-secondary-size: clamp(1rem, 2vw, 2.25rem);
|
||||||
|
|
||||||
.title-card {
|
.title-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-card__title,
|
.title-card__title,
|
||||||
@@ -16,13 +18,13 @@
|
|||||||
|
|
||||||
.title-card__title {
|
.title-card__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: clamp(1.5rem, 3vw, 3rem);
|
font-size: $title-primary-size;
|
||||||
color: var(--color-override, $viewer-color);
|
color: var(--color-override, $viewer-color);
|
||||||
line-height: 1.2em;
|
line-height: 1.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-card__secondary {
|
.title-card__secondary {
|
||||||
font-size: clamp(1rem, 2vw, 2.25rem);
|
font-size: $title-secondary-size;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
line-height: 1.2em;
|
line-height: 1.2em;
|
||||||
}
|
}
|
||||||
@@ -31,7 +33,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
font-size: $timer-label-size;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from '../../../translation/TranslationProvider';
|
|||||||
import './TitleCard.scss';
|
import './TitleCard.scss';
|
||||||
|
|
||||||
interface TitleCardProps {
|
interface TitleCardProps {
|
||||||
title: string;
|
title?: string;
|
||||||
label?: 'now' | 'next';
|
label?: 'now' | 'next';
|
||||||
secondary?: string;
|
secondary?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { act, renderHook } from '@testing-library/react';
|
|
||||||
|
|
||||||
import useClickOutside from '../useClickOutside';
|
|
||||||
|
|
||||||
describe('useClickOutside', () => {
|
|
||||||
let target: HTMLElement;
|
|
||||||
let anotherElement: HTMLElement;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
target = global.document.createElement('div');
|
|
||||||
global.document.body.appendChild(target);
|
|
||||||
|
|
||||||
anotherElement = global.document.createElement('div');
|
|
||||||
global.document.body.appendChild(anotherElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should trigger clicking outside', () => {
|
|
||||||
const ref = { current: target };
|
|
||||||
const callback = vi.fn();
|
|
||||||
renderHook(() => useClickOutside(ref, callback));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
global.document.dispatchEvent(new Event('click'));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(callback).toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
anotherElement.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(callback).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not trigger clicking inside', () => {
|
|
||||||
const ref = { current: target };
|
|
||||||
const callback = vi.fn();
|
|
||||||
renderHook(() => useClickOutside(ref, callback));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
target.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(callback).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { RefObject, useEffect } from 'react';
|
|
||||||
|
|
||||||
type ClickOutsideEventHandler = (event: MouseEvent) => void;
|
|
||||||
|
|
||||||
export default function useClickOutside<T extends HTMLElement = HTMLElement>(
|
|
||||||
ref: RefObject<T>,
|
|
||||||
callback: ClickOutsideEventHandler,
|
|
||||||
) {
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClick(event: MouseEvent) {
|
|
||||||
const element = ref?.current;
|
|
||||||
|
|
||||||
// Do nothing if clicking ref's element or descendent element
|
|
||||||
if (!element || element.contains(event.target as Node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
callback(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', handleClick);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('click', handleClick);
|
|
||||||
};
|
|
||||||
}, [ref, callback]);
|
|
||||||
}
|
|
||||||
@@ -28,6 +28,13 @@ export const validateUrlPresetPath = (preset: string): { message: string; isVali
|
|||||||
return { isValid: true, message: 'ok' };
|
return { isValid: true, message: 'ok' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility removes trailing slash from a string
|
||||||
|
*/
|
||||||
|
function removeTrailingSlash(text: string): string {
|
||||||
|
return text.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the URL to send a preset to
|
* Gets the URL to send a preset to
|
||||||
* @param location
|
* @param location
|
||||||
@@ -38,7 +45,7 @@ export const getRouteFromPreset = (location: Location, data: URLPreset[], search
|
|||||||
const currentURL = location.pathname.substring(1);
|
const currentURL = location.pathname.substring(1);
|
||||||
|
|
||||||
// we need to check if the whole url here is an alias, so we can redirect
|
// we need to check if the whole url here is an alias, so we can redirect
|
||||||
const foundPreset = data.filter((d) => d.alias === currentURL && d.enabled)[0];
|
const foundPreset = data.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
|
||||||
if (foundPreset) {
|
if (foundPreset) {
|
||||||
return generateUrlFromPreset(foundPreset);
|
return generateUrlFromPreset(foundPreset);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
|||||||
export const documentationUrl = 'https://docs.getontime.no';
|
export const documentationUrl = 'https://docs.getontime.no';
|
||||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||||
|
|
||||||
|
export const githubSponsorUrl = 'https://github.com/sponsors/cpvalente';
|
||||||
|
export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
|
||||||
|
|
||||||
// resolve environment
|
// resolve environment
|
||||||
export const appVersion = version;
|
export const appVersion = version;
|
||||||
export const isProduction = import.meta.env.MODE === 'production';
|
export const isProduction = import.meta.env.MODE === 'production';
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||||
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
|
import {
|
||||||
|
buyMeACoffeeUrl,
|
||||||
|
discordUrl,
|
||||||
|
documentationUrl,
|
||||||
|
githubSponsorUrl,
|
||||||
|
githubUrl,
|
||||||
|
websiteUrl,
|
||||||
|
} from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import AppVersion from './AppVersion';
|
import AppVersion from './AppVersion';
|
||||||
@@ -9,22 +16,29 @@ export default function AboutPanel() {
|
|||||||
<>
|
<>
|
||||||
<Panel.Header>About Ontime</Panel.Header>
|
<Panel.Header>About Ontime</Panel.Header>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.SubHeader>Ontime</Panel.SubHeader>
|
<Panel.Card>
|
||||||
<Panel.Paragraph>
|
<Panel.SubHeader>Ontime</Panel.SubHeader>
|
||||||
Free, open-source software for managing rundowns and event timers
|
<Panel.Paragraph>
|
||||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
Free, open-source software for managing rundowns and event timers
|
||||||
</Panel.Paragraph>
|
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
||||||
</Panel.Section>
|
</Panel.Paragraph>
|
||||||
<Panel.Section>
|
<Panel.Paragraph>
|
||||||
|
Considering sponsoring our work
|
||||||
|
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
|
||||||
|
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
|
||||||
|
</Panel.Paragraph>
|
||||||
|
<Panel.Paragraph>
|
||||||
|
And trying out our cloud service
|
||||||
|
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
||||||
|
</Panel.Paragraph>
|
||||||
|
</Panel.Card>
|
||||||
|
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||||
|
<AppVersion />
|
||||||
<Panel.SubHeader>Links</Panel.SubHeader>
|
<Panel.SubHeader>Links</Panel.SubHeader>
|
||||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
<Panel.Section>
|
|
||||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
|
||||||
<AppVersion />
|
|
||||||
</Panel.Section>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
|
|
||||||
|
:first-child {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer {
|
.tableContainer {
|
||||||
@@ -38,14 +42,20 @@
|
|||||||
tbody {
|
tbody {
|
||||||
background-color: $gray-1300;
|
background-color: $gray-1300;
|
||||||
tr {
|
tr {
|
||||||
&:has(:hover) {
|
&:hover:not(.current) {
|
||||||
background-color: $gray-1350;
|
background-color: $gray-1350;
|
||||||
}
|
}
|
||||||
|
.current {
|
||||||
|
&:hover {
|
||||||
|
background-color: $blue-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
@@ -62,4 +72,8 @@
|
|||||||
|
|
||||||
.current {
|
.current {
|
||||||
background-color: $blue-700;
|
background-color: $blue-700;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $blue-900;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
|
import { Button, Checkbox, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
|
||||||
|
|
||||||
import { loadDemo, loadProject } from '../../../common/api/db';
|
import { loadDemo, loadProject } from '../../../common/api/db';
|
||||||
|
import { postShowWelcomeDialog } from '../../../common/api/settings';
|
||||||
import { invalidateAllCaches } from '../../../common/api/utils';
|
import { invalidateAllCaches } from '../../../common/api/utils';
|
||||||
import ExternalLink from '../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../common/components/external-link/ExternalLink';
|
||||||
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
|
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
|
||||||
|
import * as Editor from '../editor-utils/EditorUtils';
|
||||||
|
|
||||||
import ImportProjectButton from './composite/ImportProjectButton';
|
import ImportProjectButton from './composite/ImportProjectButton';
|
||||||
import WelcomeProjectList from './composite/WelcomeProjectList';
|
import WelcomeProjectList from './composite/WelcomeProjectList';
|
||||||
@@ -68,30 +70,37 @@ export default function Welcome(props: WelcomeProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<div className={style.header}>Welcome to Ontime</div>
|
<div className={style.header}>Welcome to Ontime</div>
|
||||||
|
<Editor.Title>Select project</Editor.Title>
|
||||||
<div className={style.tableContainer}>
|
<div className={style.tableContainer}>
|
||||||
<table className={style.table}>
|
<table className={style.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Project Name</th>
|
<th>Project Name</th>
|
||||||
<th>Last Used</th>
|
<th>Last Used</th>
|
||||||
<th />
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
|
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={style.buttonRow}>
|
|
||||||
<ImportProjectButton onFinish={handleClose} />
|
|
||||||
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
|
|
||||||
Load demo project
|
|
||||||
</Button>
|
|
||||||
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
|
|
||||||
Create new...
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={style.buttonRow}>
|
||||||
|
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
|
||||||
|
Load demo project
|
||||||
|
</Button>
|
||||||
|
<ImportProjectButton onFinish={handleClose} />
|
||||||
|
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
|
||||||
|
Create new...
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-ondark'
|
||||||
|
defaultChecked
|
||||||
|
onChange={(event) => postShowWelcomeDialog(event.target.checked)}
|
||||||
|
>
|
||||||
|
Show this modal on next startup
|
||||||
|
</Checkbox>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Button } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||||
|
|
||||||
import style from '../Welcome.module.scss';
|
import style from '../Welcome.module.scss';
|
||||||
@@ -18,26 +16,16 @@ export default function WelcomeProjectList(props: WelcomeProjectListProps) {
|
|||||||
{data.reorderedProjectFiles.map((project) => {
|
{data.reorderedProjectFiles.map((project) => {
|
||||||
if (project.filename === data.lastLoadedProject) {
|
if (project.filename === data.lastLoadedProject) {
|
||||||
return (
|
return (
|
||||||
<tr className={style.current} key={project.filename}>
|
<tr className={style.current} key={project.filename} onClick={onClose}>
|
||||||
<td>{project.filename}</td>
|
<td>{project.filename}</td>
|
||||||
<td>Loaded from last session</td>
|
<td>Loaded from last session</td>
|
||||||
<td>
|
|
||||||
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<tr key={project.filename}>
|
<tr key={project.filename} onClick={() => loadProject(project.filename)}>
|
||||||
<td>{project.filename}</td>
|
<td>{project.filename}</td>
|
||||||
<td>{new Date(project.updatedAt).toLocaleString()}</td>
|
<td>{new Date(project.updatedAt).toLocaleString()}</td>
|
||||||
<td>
|
|
||||||
<Button variant='ontime-subtle' size='sm' onClick={() => loadProject(project.filename)}>
|
|
||||||
Load
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -105,5 +105,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progressOverride {
|
.progressOverride {
|
||||||
|
height: 1rem;
|
||||||
--progress-bar-br: 0;
|
--progress-bar-br: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,7 @@ sup.period {
|
|||||||
top: -1em;
|
top: -1em;
|
||||||
font-size: 0.4em;
|
font-size: 0.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subscript {
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,17 +9,32 @@ interface SuperscriptTimeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description receives a string like 12:00 AM and adds the period part to the superscript
|
* When the timer includes seconds, we want to split it from the rest
|
||||||
|
*/
|
||||||
|
function getTimerParts(time: string) {
|
||||||
|
if (time.length !== 8) {
|
||||||
|
return [time, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [time.slice(0, 5), time.slice(5)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives a time string and formats it with a subscript or superscript
|
||||||
|
* @example 12:00 AM -> AM becomes a superscript
|
||||||
|
* @example 12:00:10 -> the seconds become a subscript
|
||||||
*/
|
*/
|
||||||
export default function SuperscriptTime(props: SuperscriptTimeProps) {
|
export default function SuperscriptTime(props: SuperscriptTimeProps) {
|
||||||
const { time, className, style } = props;
|
const { time, className, style } = props;
|
||||||
|
|
||||||
// we assume anything after space is a period tag
|
// we assume anything after space is a period tag
|
||||||
const [timeString, period] = time.split(' ');
|
const [timeString, period] = time.split(' ');
|
||||||
|
const [mainTime, subscript] = getTimerParts(timeString);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} style={style}>
|
<div className={className} style={style}>
|
||||||
{timeString}
|
{mainTime}
|
||||||
|
{subscript && <span className='subscript'>{subscript}</span>}
|
||||||
{period && <sup className='period'>{period}</sup>}
|
{period && <sup className='period'>{period}</sup>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import { formatTime } from '../../../common/utils/time';
|
|||||||
|
|
||||||
type TimerTypeParams = Pick<ViewExtendedTimer, 'countToEnd' | 'timerType' | 'current' | 'elapsed' | 'clock'>;
|
type TimerTypeParams = Pick<ViewExtendedTimer, 'countToEnd' | 'timerType' | 'current' | 'elapsed' | 'clock'>;
|
||||||
|
|
||||||
export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null {
|
export function getTimerByType(
|
||||||
|
freezeEnd: boolean,
|
||||||
|
timerObject?: TimerTypeParams,
|
||||||
|
timerTypeOverride?: TimerType,
|
||||||
|
): number | null {
|
||||||
if (!timerObject) {
|
if (!timerObject) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viewTimerType = timerTypeOverride ?? timerObject.timerType;
|
||||||
|
|
||||||
if (timerObject.countToEnd) {
|
if (timerObject.countToEnd) {
|
||||||
if (timerObject.current === null) {
|
if (timerObject.current === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -19,7 +25,7 @@ export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams
|
|||||||
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
|
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (timerObject.timerType) {
|
switch (viewTimerType) {
|
||||||
case TimerType.CountDown:
|
case TimerType.CountDown:
|
||||||
if (timerObject.current === null) {
|
if (timerObject.current === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -31,10 +37,8 @@ export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams
|
|||||||
return timerObject.clock;
|
return timerObject.clock;
|
||||||
case TimerType.None:
|
case TimerType.None:
|
||||||
return null;
|
return null;
|
||||||
default: {
|
default:
|
||||||
const exhaustiveCheck: never = timerObject.timerType;
|
return null;
|
||||||
return exhaustiveCheck;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
CustomFields,
|
|
||||||
MessageState,
|
|
||||||
OntimeEvent,
|
|
||||||
Playback,
|
|
||||||
ProjectData,
|
|
||||||
Settings,
|
|
||||||
SimpleTimerState,
|
|
||||||
TimerPhase,
|
|
||||||
TimerType,
|
|
||||||
ViewSettings,
|
|
||||||
} from 'ontime-types';
|
|
||||||
|
|
||||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
|
||||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
|
||||||
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
|
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
|
||||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
|
||||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
|
||||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
|
||||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
|
||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
|
||||||
import { getFormattedTimer, getPropertyValue, getTimerByType, isStringBoolean } from '../common/viewUtils';
|
|
||||||
|
|
||||||
import { getTimerOptions } from './timer.options';
|
|
||||||
|
|
||||||
import './Timer.scss';
|
|
||||||
|
|
||||||
// motion
|
|
||||||
const titleVariants = {
|
|
||||||
hidden: {
|
|
||||||
x: -2500,
|
|
||||||
},
|
|
||||||
visible: {
|
|
||||||
x: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
exit: {
|
|
||||||
x: -2500,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MotionTitleCard = motion(TitleCard);
|
|
||||||
|
|
||||||
interface TimerProps {
|
|
||||||
auxTimer: SimpleTimerState;
|
|
||||||
customFields: CustomFields;
|
|
||||||
eventNext: OntimeEvent | null;
|
|
||||||
eventNow: OntimeEvent | null;
|
|
||||||
general: ProjectData;
|
|
||||||
isMirrored: boolean;
|
|
||||||
message: MessageState;
|
|
||||||
settings: Settings | undefined;
|
|
||||||
time: ViewExtendedTimer;
|
|
||||||
viewSettings: ViewSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Timer(props: TimerProps) {
|
|
||||||
const { auxTimer, customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } =
|
|
||||||
props;
|
|
||||||
|
|
||||||
const { getLocalizedString } = useTranslation();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
useWindowTitle('Timer');
|
|
||||||
|
|
||||||
// USER OPTIONS
|
|
||||||
const userOptions = {
|
|
||||||
hideClock: false,
|
|
||||||
hideCards: false,
|
|
||||||
hideProgress: false,
|
|
||||||
hideMessage: false,
|
|
||||||
hideTimerSeconds: false,
|
|
||||||
hideClockSeconds: false,
|
|
||||||
removeLeadingZeros: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 hideClockSeconds = searchParams.get('hideClockSeconds');
|
|
||||||
userOptions.hideClockSeconds = isStringBoolean(hideClockSeconds);
|
|
||||||
const clock = formatTime(time.clock);
|
|
||||||
|
|
||||||
const hideTimerSeconds = searchParams.get('hideTimerSeconds');
|
|
||||||
userOptions.hideTimerSeconds = isStringBoolean(hideTimerSeconds);
|
|
||||||
|
|
||||||
const showLeadingZeros = searchParams.get('showLeadingZeros');
|
|
||||||
userOptions.removeLeadingZeros = !isStringBoolean(showLeadingZeros);
|
|
||||||
|
|
||||||
const secondarySource = searchParams.get('secondary-src');
|
|
||||||
const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
|
|
||||||
const secondaryTextNext = getPropertyValue(eventNext, secondarySource);
|
|
||||||
|
|
||||||
const main = searchParams.get('main');
|
|
||||||
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
|
|
||||||
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
|
|
||||||
|
|
||||||
const showOverlay = message.timer.text !== '' && message.timer.visible;
|
|
||||||
const isPlaying = time.playback !== Playback.Pause;
|
|
||||||
|
|
||||||
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
|
|
||||||
|
|
||||||
const finished = time.phase === TimerPhase.Overtime;
|
|
||||||
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
|
||||||
|
|
||||||
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.countToEnd;
|
|
||||||
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage;
|
|
||||||
const showProgress =
|
|
||||||
eventNow !== null &&
|
|
||||||
time.timerType !== TimerType.None &&
|
|
||||||
time.timerType !== TimerType.Clock &&
|
|
||||||
time.playback !== Playback.Stop;
|
|
||||||
const showFinished = shouldShowModifiers && finished && (shouldShowModifiers || showEndMessage);
|
|
||||||
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
|
|
||||||
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
|
|
||||||
const showClock = time.timerType !== TimerType.Clock;
|
|
||||||
|
|
||||||
const secondaryContent = ((): string | undefined => {
|
|
||||||
if (message.timer.secondarySource === 'aux') {
|
|
||||||
return getFormattedTimer(auxTimer.current, TimerType.CountDown, getLocalizedString('common.minutes'), {
|
|
||||||
removeSeconds: userOptions.hideTimerSeconds,
|
|
||||||
removeLeadingZero: userOptions.removeLeadingZeros,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (message.timer.secondarySource === 'external' && message.external) {
|
|
||||||
return message.external;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
})();
|
|
||||||
|
|
||||||
let timerColor = viewSettings.normalColor;
|
|
||||||
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
|
|
||||||
if (!timerIsTimeOfDay && showProgress && showDanger) timerColor = viewSettings.dangerColor;
|
|
||||||
|
|
||||||
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
|
|
||||||
const display = getFormattedTimer(stageTimer, time.timerType, getLocalizedString('common.minutes'), {
|
|
||||||
removeSeconds: userOptions.hideTimerSeconds,
|
|
||||||
removeLeadingZero: userOptions.removeLeadingZeros,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stageTimerCharacters = display.replace('/:/g', '').length;
|
|
||||||
|
|
||||||
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
|
|
||||||
|
|
||||||
let timerFontSize = 89 / (stageTimerCharacters - 1);
|
|
||||||
// we need to shrink the timer if the external is going to be there
|
|
||||||
if (secondaryContent) {
|
|
||||||
timerFontSize *= 0.8;
|
|
||||||
}
|
|
||||||
const externalFontSize = timerFontSize * 0.4;
|
|
||||||
const timerContainerClasses = `timer-container ${message.timer.blink ? (showOverlay ? '' : 'blink') : ''}`;
|
|
||||||
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
|
|
||||||
|
|
||||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
|
||||||
const timerOptions = getTimerOptions(defaultFormat, customFields);
|
|
||||||
const disableProgress = timerIsTimeOfDay || time.timerType === TimerType.None;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
|
||||||
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
|
||||||
|
|
||||||
<ViewParamsEditor viewOptions={timerOptions} />
|
|
||||||
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} />
|
|
||||||
{!userOptions.hideMessage && (
|
|
||||||
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
|
||||||
<FitText mode='multi' min={32} max={256} className={`message ${message.timer.blink ? 'blink' : ''}`}>
|
|
||||||
{message.timer.text}
|
|
||||||
</FitText>
|
|
||||||
</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={timerContainerClasses}>
|
|
||||||
{showEndMessage ? (
|
|
||||||
<div className='end-message'>{viewSettings.endMessage}</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={timerClasses}
|
|
||||||
style={{
|
|
||||||
fontSize: `${timerFontSize}vw`,
|
|
||||||
'--phase-color': timerColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{display}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={`secondary${secondaryContent ? '' : ' secondary--hidden'}`}
|
|
||||||
style={{ fontSize: `${externalFontSize}vw` }}
|
|
||||||
>
|
|
||||||
{secondaryContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!userOptions.hideProgress && (
|
|
||||||
<MultiPartProgressBar
|
|
||||||
className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'}
|
|
||||||
now={disableProgress ? null : time.current}
|
|
||||||
complete={totalTime}
|
|
||||||
normalColor={viewSettings.normalColor}
|
|
||||||
warning={eventNow?.timeWarning}
|
|
||||||
warningColor={viewSettings.warningColor}
|
|
||||||
danger={eventNow?.timeDanger}
|
|
||||||
dangerColor={viewSettings.dangerColor}
|
|
||||||
hidden={!showProgress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!userOptions.hideCards && (
|
|
||||||
<>
|
|
||||||
<AnimatePresence>
|
|
||||||
{eventNow?.title && (
|
|
||||||
<MotionTitleCard
|
|
||||||
className='event now'
|
|
||||||
key='now'
|
|
||||||
variants={titleVariants}
|
|
||||||
initial='hidden'
|
|
||||||
animate='visible'
|
|
||||||
exit='exit'
|
|
||||||
label='now'
|
|
||||||
title={mainFieldNow}
|
|
||||||
secondary={secondaryTextNow}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{eventNext?.title && (
|
|
||||||
<MotionTitleCard
|
|
||||||
key='next'
|
|
||||||
variants={titleVariants}
|
|
||||||
initial='hidden'
|
|
||||||
animate='visible'
|
|
||||||
exit='exit'
|
|
||||||
label='next'
|
|
||||||
title={mainFieldNext}
|
|
||||||
secondary={secondaryTextNext}
|
|
||||||
className='event next'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { CustomFields } from 'ontime-types';
|
|
||||||
|
|
||||||
import {
|
|
||||||
getTimeOption,
|
|
||||||
hideTimerSeconds,
|
|
||||||
makeOptionsFromCustomFields,
|
|
||||||
showLeadingZeros,
|
|
||||||
} from '../../../common/components/view-params-editor/constants';
|
|
||||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
|
||||||
|
|
||||||
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
|
||||||
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
|
||||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ section: 'Clock Options' },
|
|
||||||
getTimeOption(timeFormat),
|
|
||||||
{ section: 'Timer Options' },
|
|
||||||
hideTimerSeconds,
|
|
||||||
showLeadingZeros,
|
|
||||||
{ section: 'Data sources' },
|
|
||||||
{
|
|
||||||
id: 'main',
|
|
||||||
title: 'Main text',
|
|
||||||
description: 'Select the data source for the main text',
|
|
||||||
type: 'option',
|
|
||||||
values: mainOptions,
|
|
||||||
defaultValue: 'Title',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'secondary-src',
|
|
||||||
title: 'Secondary text',
|
|
||||||
description: 'Select the data source for the secondary text',
|
|
||||||
type: 'option',
|
|
||||||
values: secondaryOptions,
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
{ section: 'Element visibility' },
|
|
||||||
{
|
|
||||||
id: 'hideClock',
|
|
||||||
title: 'Hide Time Now',
|
|
||||||
description: 'Hides the Time Now field',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideCards',
|
|
||||||
title: 'Hide Cards',
|
|
||||||
description: 'Hides the Now and Next cards',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideProgress',
|
|
||||||
title: 'Hide progress bar',
|
|
||||||
description: 'Hides the progress bar',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideMessage',
|
|
||||||
title: 'Hide Presenter Message',
|
|
||||||
description: 'Prevents the screen from displaying messages from the presenter',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hideExternal',
|
|
||||||
title: 'Hide External',
|
|
||||||
description: 'Prevents the screen from displaying the external field',
|
|
||||||
type: 'boolean',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import matchers from '@testing-library/jest-dom/matchers';
|
|
||||||
|
|
||||||
expect.extend(matchers);
|
|
||||||
@@ -21,6 +21,11 @@ $viewer-secondary-color: rgba(white, 45%); // --secondary-color-override
|
|||||||
$viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
|
$viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
|
||||||
$element-border-radius: 8px;
|
$element-border-radius: 8px;
|
||||||
|
|
||||||
|
// Generic element sizes
|
||||||
|
$view-element-gap: min(2vh, 16px);
|
||||||
|
$view-outer-padding: min(2vh, 16px) clamp(16px, 2vw, 24px);
|
||||||
|
$view-card-padding: min(2vh, 8px) clamp(16px, 2vw, 24px);
|
||||||
|
|
||||||
// Properties related to timer
|
// Properties related to timer
|
||||||
$timer-color: rgba(white, 80%); // --timer-color-override
|
$timer-color: rgba(white, 80%); // --timer-color-override
|
||||||
$timer-finished-color: $playback-negative;
|
$timer-finished-color: $playback-negative;
|
||||||
@@ -30,4 +35,4 @@ $external-color: rgba(white, 85%); // --external-color-override
|
|||||||
|
|
||||||
// properties of other timers (clock and countdown)
|
// properties of other timers (clock and countdown)
|
||||||
$timer-label-size: clamp(16px, 1.5vw, 24px);
|
$timer-label-size: clamp(16px, 1.5vw, 24px);
|
||||||
$timer-value-size: clamp(2rem, 3.5vw, 3.5rem);
|
$timer-value-size: clamp(32px, 3.5vw, 50px);
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ type CuesheetOptions = {
|
|||||||
* Utility extract the view options from URL Params
|
* Utility extract the view options from URL Params
|
||||||
* the names and fallbacks are manually matched with cuesheetOptions
|
* the names and fallbacks are manually matched with cuesheetOptions
|
||||||
*/
|
*/
|
||||||
export function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
|
function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
|
||||||
// we manually make an object that matches the key above
|
// we manually make an object that matches the key above
|
||||||
return {
|
return {
|
||||||
showActionMenu: isStringBoolean(searchParams.get('showActionMenu')),
|
showActionMenu: isStringBoolean(searchParams.get('showActionMenu')),
|
||||||
|
|||||||
+28
-27
@@ -1,4 +1,4 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
.stage-timer {
|
.stage-timer {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -11,21 +11,14 @@
|
|||||||
font-family: var(--font-family-override, $viewer-font-family);
|
font-family: var(--font-family-override, $viewer-font-family);
|
||||||
background: var(--background-color-override, $viewer-background-color);
|
background: var(--background-color-override, $viewer-background-color);
|
||||||
color: var(--color-override, $viewer-color);
|
color: var(--color-override, $viewer-color);
|
||||||
gap: min(2vh, 16px);
|
gap: $view-element-gap;
|
||||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
padding: $view-outer-padding;
|
||||||
|
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 100%;
|
flex-direction: column;
|
||||||
grid-template-rows: auto 1fr auto auto auto;
|
|
||||||
grid-template-areas:
|
|
||||||
'clock'
|
|
||||||
'timer'
|
|
||||||
'progress'
|
|
||||||
'now'
|
|
||||||
'next';
|
|
||||||
|
|
||||||
&--finished {
|
&--finished {
|
||||||
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
|
outline: clamp(4px, 1vw, 16px) solid var(--timer-overtime-color-override, $timer-finished-color);
|
||||||
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
|
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
|
||||||
transition: $viewer-transition-time;
|
transition: $viewer-transition-time;
|
||||||
}
|
}
|
||||||
@@ -48,19 +41,18 @@
|
|||||||
/* =================== CLOCK ===================*/
|
/* =================== CLOCK ===================*/
|
||||||
|
|
||||||
.clock-container {
|
.clock-container {
|
||||||
grid-area: clock;
|
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: opacity $viewer-transition-time;
|
transition: opacity $viewer-transition-time;
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: clamp(16px, 1.5vw, 24px);
|
font-size: $timer-label-size;
|
||||||
color: var(--label-color-override, $viewer-label-color);
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock {
|
.clock {
|
||||||
font-size: clamp(32px, 3.5vw, 50px);
|
font-size: $timer-value-size;
|
||||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
line-height: 0.95em;
|
line-height: 0.95em;
|
||||||
@@ -75,8 +67,8 @@
|
|||||||
|
|
||||||
.event {
|
.event {
|
||||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
padding: 16px 24px;
|
padding: $view-card-padding;
|
||||||
border-radius: 8px;
|
border-radius: $element-border-radius;
|
||||||
|
|
||||||
&.now {
|
&.now {
|
||||||
grid-area: now;
|
grid-area: now;
|
||||||
@@ -90,7 +82,8 @@
|
|||||||
/* =================== MAIN ===================*/
|
/* =================== MAIN ===================*/
|
||||||
|
|
||||||
.timer-container {
|
.timer-container {
|
||||||
grid-area: timer;
|
flex: 1;
|
||||||
|
align-content: center;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
|
||||||
@@ -102,7 +95,7 @@
|
|||||||
font-size: 11.5vw;
|
font-size: 11.5vw;
|
||||||
line-height: 0.9em;
|
line-height: 0.9em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $timer-finished-color;
|
color: var(--timer-overtime-color-override, $timer-finished-color);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,8 +116,16 @@
|
|||||||
transition: $viewer-transition-time;
|
transition: $viewer-transition-time;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// use a class instead of a phase, to allow suppressing overtime style
|
||||||
&--finished {
|
&--finished {
|
||||||
color: $timer-finished-color;
|
color: var(--timer-overtime-color-override, $timer-finished-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-phase="warning"] {
|
||||||
|
color: var(--timer-warning-color-override, var(--phase-color));
|
||||||
|
}
|
||||||
|
&[data-phase="danger"] {
|
||||||
|
color: var(--timer-danger-color-override, var(--phase-color));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,14 +135,14 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
margin-top: 0.25em;
|
margin-top: 0.125em;
|
||||||
padding-block: 0.25em;
|
padding-block: 0.125em;
|
||||||
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--external-color-override, $external-color);
|
color: var(--external-color-override, $external-color);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
line-height: 0.9em;
|
line-height: 1em;
|
||||||
transition-property: opacity, height;
|
transition-property: opacity, height;
|
||||||
transition-duration: $viewer-transition-time;
|
transition-duration: $viewer-transition-time;
|
||||||
border-top: 1px solid color-mix(in srgb, var(--external-color-override, $external-color) 10%, transparent);
|
border-top: 1px solid color-mix(in srgb, var(--external-color-override, $external-color) 10%, transparent);
|
||||||
@@ -153,7 +154,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-container {
|
.progress-container {
|
||||||
grid-area: progress;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -195,10 +195,11 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =================== LOGO ===================*/
|
||||||
.logo {
|
.logo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2vw;
|
top: min(2vh, 16px);
|
||||||
left: 2vw;
|
left: min(2vw, 16px);
|
||||||
max-width: min(200px, 20vw);
|
max-width: min(200px, 20vw);
|
||||||
max-height: min(100px, 20vh);
|
max-height: min(100px, 20vh);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
CustomFields,
|
||||||
|
MessageState,
|
||||||
|
OntimeEvent,
|
||||||
|
ProjectData,
|
||||||
|
Settings,
|
||||||
|
SimpleTimerState,
|
||||||
|
ViewSettings,
|
||||||
|
} from 'ontime-types';
|
||||||
|
|
||||||
|
import { FitText } from '../../common/components/fit-text/FitText';
|
||||||
|
import MultiPartProgressBar from '../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||||
|
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||||
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
|
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||||
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
|
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||||
|
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||||
|
import { getFormattedTimer, getTimerByType } from '../../features/viewers/common/viewUtils';
|
||||||
|
import { useTranslation } from '../../translation/TranslationProvider';
|
||||||
|
|
||||||
|
import { MotionTitleCard, titleVariants } from './timer.animations';
|
||||||
|
import { getTimerOptions, useTimerOptions } from './timer.options';
|
||||||
|
import {
|
||||||
|
getCardData,
|
||||||
|
getEstimatedFontSize,
|
||||||
|
getIsPlaying,
|
||||||
|
getSecondaryDisplay,
|
||||||
|
getShowClock,
|
||||||
|
getShowMessage,
|
||||||
|
getShowModifiers,
|
||||||
|
getShowProgressBar,
|
||||||
|
getTimerColour,
|
||||||
|
getTotalTime,
|
||||||
|
} from './timer.utils';
|
||||||
|
|
||||||
|
import './Timer.scss';
|
||||||
|
|
||||||
|
interface TimerProps {
|
||||||
|
auxTimer: SimpleTimerState;
|
||||||
|
customFields: CustomFields;
|
||||||
|
eventNext: OntimeEvent | null;
|
||||||
|
eventNow: OntimeEvent | null;
|
||||||
|
general: ProjectData;
|
||||||
|
isMirrored: boolean;
|
||||||
|
message: MessageState;
|
||||||
|
settings: Settings | undefined;
|
||||||
|
time: ViewExtendedTimer;
|
||||||
|
viewSettings: ViewSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timer(props: TimerProps) {
|
||||||
|
const { auxTimer, customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } =
|
||||||
|
props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
hideClock,
|
||||||
|
hideCards,
|
||||||
|
hideProgress,
|
||||||
|
hideMessage,
|
||||||
|
hideExternal,
|
||||||
|
hideTimerSeconds,
|
||||||
|
removeLeadingZeros,
|
||||||
|
mainSource,
|
||||||
|
secondarySource,
|
||||||
|
timerType,
|
||||||
|
} = useTimerOptions();
|
||||||
|
|
||||||
|
const { getLocalizedString } = useTranslation();
|
||||||
|
const localisedMinutes = getLocalizedString('common.minutes');
|
||||||
|
|
||||||
|
useWindowTitle('Timer');
|
||||||
|
|
||||||
|
// gather modifiers
|
||||||
|
const viewTimerType = timerType ?? time.timerType;
|
||||||
|
const showOverlay = getShowMessage(message.timer);
|
||||||
|
const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers(
|
||||||
|
time.timerType,
|
||||||
|
time.countToEnd,
|
||||||
|
time.phase,
|
||||||
|
viewSettings,
|
||||||
|
);
|
||||||
|
const isPlaying = getIsPlaying(time.playback);
|
||||||
|
const showClock = !hideClock && getShowClock(viewTimerType);
|
||||||
|
const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType);
|
||||||
|
|
||||||
|
// gather card data
|
||||||
|
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
|
||||||
|
eventNow,
|
||||||
|
eventNext,
|
||||||
|
mainSource,
|
||||||
|
secondarySource,
|
||||||
|
time.playback,
|
||||||
|
time.phase,
|
||||||
|
);
|
||||||
|
|
||||||
|
// gather timer data
|
||||||
|
const totalTime = getTotalTime(time.duration, time.addedTime);
|
||||||
|
const clock = formatTime(time.clock);
|
||||||
|
const stageTimer = getTimerByType(viewSettings.freezeEnd, time, timerType);
|
||||||
|
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
|
||||||
|
removeSeconds: hideTimerSeconds,
|
||||||
|
removeLeadingZero: removeLeadingZeros,
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondaryContent = getSecondaryDisplay(
|
||||||
|
message,
|
||||||
|
auxTimer.current,
|
||||||
|
localisedMinutes,
|
||||||
|
hideTimerSeconds,
|
||||||
|
removeLeadingZeros,
|
||||||
|
hideExternal,
|
||||||
|
);
|
||||||
|
|
||||||
|
// gather presentation styles
|
||||||
|
const timerColour = getTimerColour(viewSettings, showWarning, showDanger);
|
||||||
|
const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent);
|
||||||
|
|
||||||
|
// gather option data
|
||||||
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
|
const timerOptions = getTimerOptions(defaultFormat, customFields);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(['stage-timer', isMirrored && 'mirror', showFinished && 'stage-timer--finished'])}
|
||||||
|
data-testid='timer-view'
|
||||||
|
>
|
||||||
|
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||||
|
|
||||||
|
<ViewParamsEditor viewOptions={timerOptions} />
|
||||||
|
|
||||||
|
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
|
||||||
|
|
||||||
|
{!hideMessage && (
|
||||||
|
<div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
|
||||||
|
<FitText mode='multi' min={32} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
|
||||||
|
{message.timer.text}
|
||||||
|
</FitText>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showClock && (
|
||||||
|
<div className='clock-container'>
|
||||||
|
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||||
|
<SuperscriptTime time={clock} className='clock' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
|
||||||
|
{showEndMessage ? (
|
||||||
|
<FitText mode='multi' min={64} max={256} className='end-message'>
|
||||||
|
{viewSettings.endMessage}
|
||||||
|
</FitText>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
||||||
|
style={{
|
||||||
|
fontSize: `${timerFontSize}vw`,
|
||||||
|
'--phase-color': timerColour,
|
||||||
|
}}
|
||||||
|
data-phase={time.phase}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}
|
||||||
|
style={{ fontSize: `${externalFontSize}vw` }}
|
||||||
|
>
|
||||||
|
{secondaryContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showProgressBar && (
|
||||||
|
<MultiPartProgressBar
|
||||||
|
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||||
|
now={time.current}
|
||||||
|
complete={totalTime}
|
||||||
|
normalColor={viewSettings.normalColor}
|
||||||
|
warning={eventNow?.timeWarning}
|
||||||
|
warningColor={viewSettings.warningColor}
|
||||||
|
danger={eventNow?.timeDanger}
|
||||||
|
dangerColor={viewSettings.dangerColor}
|
||||||
|
hideOvertime={!showFinished}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hideCards && (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{showNow && (
|
||||||
|
<MotionTitleCard
|
||||||
|
className='event now'
|
||||||
|
key='now'
|
||||||
|
variants={titleVariants}
|
||||||
|
initial='hidden'
|
||||||
|
animate='visible'
|
||||||
|
exit='exit'
|
||||||
|
label='now'
|
||||||
|
title={nowMain}
|
||||||
|
secondary={nowSecondary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showNext && (
|
||||||
|
<MotionTitleCard
|
||||||
|
className='event next'
|
||||||
|
key='next'
|
||||||
|
variants={titleVariants}
|
||||||
|
initial='hidden'
|
||||||
|
animate='visible'
|
||||||
|
exit='exit'
|
||||||
|
label='next'
|
||||||
|
title={nextMain}
|
||||||
|
secondary={nextSecondary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import TitleCard from '../../common/components/title-card/TitleCard';
|
||||||
|
|
||||||
|
export const titleVariants = {
|
||||||
|
hidden: {
|
||||||
|
x: -2500,
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
x: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
x: -2500,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MotionTitleCard = motion(TitleCard);
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { CustomFields, OntimeEvent, TimerType } from 'ontime-types';
|
||||||
|
import { validateTimerType } from 'ontime-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTimeOption,
|
||||||
|
hideTimerSeconds,
|
||||||
|
makeOptionsFromCustomFields,
|
||||||
|
showLeadingZeros,
|
||||||
|
} from '../../common/components/view-params-editor/constants';
|
||||||
|
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||||
|
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
|
// manually match the properties of TimerType excluding the None
|
||||||
|
const timerDisplayOptions = {
|
||||||
|
'no-overrides': 'No Overrides',
|
||||||
|
'count-up': 'Count up',
|
||||||
|
'count-down': 'Count down',
|
||||||
|
clock: 'Clock',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||||
|
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||||
|
const secondaryOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ section: 'Clock Options' },
|
||||||
|
getTimeOption(timeFormat),
|
||||||
|
{ section: 'Timer Options' },
|
||||||
|
hideTimerSeconds,
|
||||||
|
showLeadingZeros,
|
||||||
|
{
|
||||||
|
id: 'timerType',
|
||||||
|
title: 'Timer type',
|
||||||
|
description: 'Override the timer type',
|
||||||
|
type: 'option',
|
||||||
|
values: timerDisplayOptions,
|
||||||
|
defaultValue: 'no-overrides',
|
||||||
|
},
|
||||||
|
{ section: 'Data sources' },
|
||||||
|
{
|
||||||
|
id: 'main',
|
||||||
|
title: 'Main text',
|
||||||
|
description: 'Select the data source for the main text',
|
||||||
|
type: 'option',
|
||||||
|
values: mainOptions,
|
||||||
|
defaultValue: 'Title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'secondary-src',
|
||||||
|
title: 'Secondary text',
|
||||||
|
description: 'Select the data source for the secondary text',
|
||||||
|
type: 'option',
|
||||||
|
values: secondaryOptions,
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
{ section: 'Element visibility' },
|
||||||
|
{
|
||||||
|
id: 'hideClock',
|
||||||
|
title: 'Hide Time Now',
|
||||||
|
description: 'Hides the Time Now field',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideCards',
|
||||||
|
title: 'Hide Cards',
|
||||||
|
description: 'Hides the Now and Next cards',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideProgress',
|
||||||
|
title: 'Hide progress bar',
|
||||||
|
description: 'Hides the progress bar',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideMessage',
|
||||||
|
title: 'Hide Timer Message',
|
||||||
|
description: 'Prevents displaying fullscreen messages in the timer',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideExternal',
|
||||||
|
title: 'Hide Auxiliary timer / External message',
|
||||||
|
description: 'Prevents the screen from displaying the secondary timer field',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TimerOptions = {
|
||||||
|
hideClock: boolean;
|
||||||
|
hideCards: boolean;
|
||||||
|
hideProgress: boolean;
|
||||||
|
hideMessage: boolean;
|
||||||
|
hideExternal: boolean;
|
||||||
|
hideTimerSeconds: boolean;
|
||||||
|
removeLeadingZeros: boolean;
|
||||||
|
mainSource: keyof OntimeEvent | null;
|
||||||
|
secondarySource: keyof OntimeEvent | null;
|
||||||
|
timerType?: TimerType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility extract the view options from URL Params
|
||||||
|
* the names and fallbacks are manually matched with timerOptions
|
||||||
|
*/
|
||||||
|
function getOptionsFromParams(searchParams: URLSearchParams): TimerOptions {
|
||||||
|
const timerType = validateTimerType(searchParams.get('timerType'), TimerType.None);
|
||||||
|
// we manually make an object that matches the key above
|
||||||
|
return {
|
||||||
|
hideClock: isStringBoolean(searchParams.get('hideClock')),
|
||||||
|
hideCards: isStringBoolean(searchParams.get('hideCards')),
|
||||||
|
hideProgress: isStringBoolean(searchParams.get('hideProgress')),
|
||||||
|
hideMessage: isStringBoolean(searchParams.get('hideMessage')),
|
||||||
|
hideExternal: isStringBoolean(searchParams.get('hideExternal')),
|
||||||
|
hideTimerSeconds: isStringBoolean(searchParams.get('hideTimerSeconds')),
|
||||||
|
removeLeadingZeros: !isStringBoolean(searchParams.get('showLeadingZeros')),
|
||||||
|
|
||||||
|
mainSource: searchParams.get('main') as keyof OntimeEvent | null,
|
||||||
|
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
|
||||||
|
|
||||||
|
// none doesnt make sense as a configuration of the view
|
||||||
|
timerType: timerType === TimerType.None ? undefined : timerType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook exposes the timer view options
|
||||||
|
*/
|
||||||
|
export function useTimerOptions(): TimerOptions {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import {
|
||||||
|
MaybeNumber,
|
||||||
|
MessageState,
|
||||||
|
OntimeEvent,
|
||||||
|
Playback,
|
||||||
|
TimerMessage,
|
||||||
|
TimerPhase,
|
||||||
|
TimerType,
|
||||||
|
ViewSettings,
|
||||||
|
} from 'ontime-types';
|
||||||
|
import { isPlaybackActive } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { getFormattedTimer, getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a message should be shown
|
||||||
|
*/
|
||||||
|
export function getShowMessage(message: TimerMessage): boolean {
|
||||||
|
return message.text !== '' && message.visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the playback is playing
|
||||||
|
*/
|
||||||
|
export function getIsPlaying(playback: Playback): boolean {
|
||||||
|
return playback === Playback.Play || playback === Playback.Roll;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total time from the duration and added time of an event
|
||||||
|
*/
|
||||||
|
export function getTotalTime(duration: MaybeNumber, addedTime: MaybeNumber): number {
|
||||||
|
return (duration ?? 0) + (addedTime ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the progress bar should be shown for this timer type
|
||||||
|
*/
|
||||||
|
export function getShowProgressBar(timerType: TimerType) {
|
||||||
|
return timerType !== TimerType.None && timerType !== TimerType.Clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the clock should be shown with this timer type
|
||||||
|
*/
|
||||||
|
export function getShowClock(timerType: TimerType) {
|
||||||
|
return timerType !== TimerType.Clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontSizeMap: { [key: number]: number } = {
|
||||||
|
4: 28, // 9:01
|
||||||
|
5: 28, // -9:01, 10:01, 9 min
|
||||||
|
6: 25, // -10:01, 10 min
|
||||||
|
8: 20, // 23:01:01
|
||||||
|
9: 20, // -23:01:01
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a font size that fits the timer in the screen
|
||||||
|
* Unfortunately hand tweaked
|
||||||
|
*/
|
||||||
|
export function getEstimatedFontSize(stageTimer: string, secondaryContent?: string) {
|
||||||
|
const stageTimerCharacters = stageTimer.length;
|
||||||
|
let timerFontSize = (100 / (stageTimerCharacters - 1)) * 1.25;
|
||||||
|
|
||||||
|
if (fontSizeMap[stageTimerCharacters]) {
|
||||||
|
timerFontSize = fontSizeMap[stageTimerCharacters];
|
||||||
|
}
|
||||||
|
|
||||||
|
let externalFontSize = timerFontSize * 0.2;
|
||||||
|
if (secondaryContent) {
|
||||||
|
// we need to shrink the timer if the external is going to be there
|
||||||
|
// this number has been tweaked to fit in a landscape mobile screen
|
||||||
|
timerFontSize *= 0.6;
|
||||||
|
if (secondaryContent.length > 25) {
|
||||||
|
externalFontSize = (100 / (secondaryContent.length - 1)) * 1.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timerFontSize,
|
||||||
|
externalFontSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* which, if any, modifier should be shown at any time
|
||||||
|
*/
|
||||||
|
export function getShowModifiers(
|
||||||
|
timerType: TimerType,
|
||||||
|
countToEnd: boolean,
|
||||||
|
phase: TimerPhase,
|
||||||
|
viewSettings: ViewSettings,
|
||||||
|
) {
|
||||||
|
const showModifiers = timerType === TimerType.CountDown || countToEnd;
|
||||||
|
const finished = phase === TimerPhase.Overtime;
|
||||||
|
return {
|
||||||
|
showEndMessage: showModifiers && finished && viewSettings.endMessage,
|
||||||
|
showFinished: showModifiers && finished, // ????
|
||||||
|
showWarning: showModifiers && phase === TimerPhase.Warning,
|
||||||
|
showDanger: showModifiers && phase === TimerPhase.Danger,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which colour should the timer have at a given moment
|
||||||
|
*/
|
||||||
|
export function getTimerColour(viewSettings: ViewSettings, showWarning: boolean, showDanger: boolean) {
|
||||||
|
if (showWarning) return viewSettings.warningColor;
|
||||||
|
if (showDanger) return viewSettings.dangerColor;
|
||||||
|
return viewSettings.normalColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What, if anything, should be displayed in the secondary field
|
||||||
|
*/
|
||||||
|
export function getSecondaryDisplay(
|
||||||
|
message: MessageState,
|
||||||
|
currentAux: MaybeNumber,
|
||||||
|
localisedMinutes: string,
|
||||||
|
removeSeconds: boolean,
|
||||||
|
removeLeadingZero: boolean,
|
||||||
|
hideExternal: boolean,
|
||||||
|
): string | undefined {
|
||||||
|
if (hideExternal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.timer.secondarySource === 'aux') {
|
||||||
|
return getFormattedTimer(currentAux, TimerType.CountDown, localisedMinutes, {
|
||||||
|
removeSeconds,
|
||||||
|
removeLeadingZero,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (message.timer.secondarySource === 'external' && message.external) {
|
||||||
|
return message.external;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What should we be showing in the cards?
|
||||||
|
*/
|
||||||
|
export function getCardData(
|
||||||
|
eventNow: OntimeEvent | null,
|
||||||
|
eventNext: OntimeEvent | null,
|
||||||
|
mainSource: keyof OntimeEvent | null,
|
||||||
|
secondarySource: keyof OntimeEvent | null,
|
||||||
|
playback: Playback,
|
||||||
|
phase: TimerPhase,
|
||||||
|
) {
|
||||||
|
if (playback === Playback.Stop) {
|
||||||
|
return {
|
||||||
|
showNow: false,
|
||||||
|
nowMain: undefined,
|
||||||
|
nowSecondary: undefined,
|
||||||
|
showNext: false,
|
||||||
|
nextMain: undefined,
|
||||||
|
nextSecondary: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// pending roll timers would be classified as active
|
||||||
|
const hasActiveTimer = isPlaybackActive(playback) && phase !== TimerPhase.Pending;
|
||||||
|
|
||||||
|
// if we are loaded, we show the upcoming event as next
|
||||||
|
const nowMain = hasActiveTimer ? getPropertyValue(eventNow, mainSource ?? 'title') : undefined;
|
||||||
|
const nowSecondary = hasActiveTimer ? getPropertyValue(eventNow, secondarySource) : undefined;
|
||||||
|
const nextMain = hasActiveTimer
|
||||||
|
? getPropertyValue(eventNext, mainSource ?? 'title')
|
||||||
|
: getPropertyValue(eventNow, mainSource ?? 'title');
|
||||||
|
const nextSecondary = hasActiveTimer
|
||||||
|
? getPropertyValue(eventNext, secondarySource)
|
||||||
|
: getPropertyValue(eventNow, secondarySource);
|
||||||
|
|
||||||
|
return {
|
||||||
|
showNow: Boolean(nowMain) || Boolean(nowSecondary),
|
||||||
|
nowMain,
|
||||||
|
nowSecondary,
|
||||||
|
showNext: Boolean(nextMain) || Boolean(nextSecondary),
|
||||||
|
nextMain,
|
||||||
|
nextSecondary,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -42,7 +42,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: './src/setupTests.js',
|
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "3.10.3",
|
"version": "3.10.7",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "3.10.3",
|
"version": "3.10.7",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
"cookie": "^1.0.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^16.0.1",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { eventStore } from '../stores/EventStore.js';
|
|||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
||||||
import { generateId } from 'ontime-utils';
|
import { generateId } from 'ontime-utils';
|
||||||
|
import { authenticateSocket } from '../middleware/authenticate.js';
|
||||||
|
|
||||||
let instance: SocketServer | null = null;
|
let instance: SocketServer | null = null;
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export class SocketServer implements IAdapter {
|
|||||||
private wss: WebSocketServer | null;
|
private wss: WebSocketServer | null;
|
||||||
private readonly clients: Map<string, Client>;
|
private readonly clients: Map<string, Client>;
|
||||||
private lastConnection: Date | null = null;
|
private lastConnection: Date | null = null;
|
||||||
private isFirstEditor = true;
|
private shouldShowWelcome = true;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (instance) {
|
if (instance) {
|
||||||
@@ -47,10 +48,16 @@ export class SocketServer implements IAdapter {
|
|||||||
this.wss = null;
|
this.wss = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
init(server: Server, prefix?: string) {
|
init(server: Server, showWelcome: boolean, prefix?: string) {
|
||||||
|
this.shouldShowWelcome = showWelcome;
|
||||||
this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
|
this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
|
||||||
|
|
||||||
this.wss.on('connection', (ws) => {
|
this.wss.on('connection', (ws, req) => {
|
||||||
|
authenticateSocket(ws, req, (error) => {
|
||||||
|
if (error) {
|
||||||
|
ws.close(1008, 'Unauthorized');
|
||||||
|
}
|
||||||
|
});
|
||||||
const clientId = generateId();
|
const clientId = generateId();
|
||||||
|
|
||||||
this.clients.set(clientId, {
|
this.clients.set(clientId, {
|
||||||
@@ -136,8 +143,8 @@ export class SocketServer implements IAdapter {
|
|||||||
previousData.path = payload;
|
previousData.path = payload;
|
||||||
this.clients.set(clientId, previousData);
|
this.clients.set(clientId, previousData);
|
||||||
|
|
||||||
if (payload.includes('editor') && this.isFirstEditor) {
|
if (payload.includes('editor') && this.shouldShowWelcome) {
|
||||||
this.isFirstEditor = false;
|
this.shouldShowWelcome = false;
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
handleUploaded,
|
handleUploaded,
|
||||||
} from '../../services/project-service/projectServiceUtils.js';
|
} from '../../services/project-service/projectServiceUtils.js';
|
||||||
import * as projectService from '../../services/project-service/ProjectService.js';
|
import * as projectService from '../../services/project-service/ProjectService.js';
|
||||||
|
import { ensureJsonExtension } from '../../utils/fileManagement.js';
|
||||||
|
|
||||||
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
|
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
|
||||||
try {
|
try {
|
||||||
@@ -41,7 +42,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
|
|||||||
*/
|
*/
|
||||||
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
|
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
|
||||||
try {
|
try {
|
||||||
const newFileName = await projectService.createProject(req.body.filename, {
|
const newFileName = await projectService.createProject(req.body.filename || 'untitled', {
|
||||||
project: {
|
project: {
|
||||||
title: req.body?.title ?? '',
|
title: req.body?.title ?? '',
|
||||||
description: req.body?.description ?? '',
|
description: req.body?.description ?? '',
|
||||||
@@ -67,7 +68,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
|
|||||||
*/
|
*/
|
||||||
export async function quickProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
|
export async function quickProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
|
||||||
try {
|
try {
|
||||||
const filename = await projectService.createProject(req.body.project.title, req.body);
|
const filename = await projectService.createProject(req.body.project.title || 'untitled', req.body);
|
||||||
res.status(200).send({
|
res.status(200).send({
|
||||||
filename,
|
filename,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import sanitize from 'sanitize-filename';
|
|||||||
* @description Validates request for a new project.
|
* @description Validates request for a new project.
|
||||||
*/
|
*/
|
||||||
export const validateNewProject = [
|
export const validateNewProject = [
|
||||||
|
body('filename').optional().isString().trim(),
|
||||||
body('title').optional().isString().trim(),
|
body('title').optional().isString().trim(),
|
||||||
body('description').optional().isString().trim(),
|
body('description').optional().isString().trim(),
|
||||||
body('publicUrl').optional().isString().trim(),
|
body('publicUrl').optional().isString().trim(),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getLastRequest } from '../../api-integration/integration.controller.js'
|
|||||||
import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js';
|
import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js';
|
||||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||||
import { getNetworkInterfaces } from '../../utils/network.js';
|
import { getNetworkInterfaces } from '../../utils/network.js';
|
||||||
|
import { getTimezoneLabel } from '../../utils/time.js';
|
||||||
|
|
||||||
const startedAt = new Date();
|
const startedAt = new Date();
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export async function getSessionStats(): Promise<SessionStats> {
|
|||||||
lastRequest: lastRequest !== null ? lastRequest.toISOString() : null,
|
lastRequest: lastRequest !== null ? lastRequest.toISOString() : null,
|
||||||
projectName,
|
projectName,
|
||||||
playback,
|
playback,
|
||||||
timezone: startedAt.getTimezoneOffset(),
|
timezone: getTimezoneLabel(startedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Request, Response } from 'express';
|
|||||||
import { isDocker } from '../../externals.js';
|
import { isDocker } from '../../externals.js';
|
||||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
import * as appState from '../../services/app-state-service/AppStateService.js';
|
||||||
|
|
||||||
import { extractPin } from './settings.utils.js';
|
import { extractPin } from './settings.utils.js';
|
||||||
|
|
||||||
@@ -65,3 +66,8 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
|
|||||||
res.status(400).send({ message });
|
res.status(400).send({ message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function postWelcomeDialog(req: Request, res: Response) {
|
||||||
|
const show = await appState.setShowWelcomeDialog(req.body.show);
|
||||||
|
res.status(200).send({ show });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { getSettings, postSettings } from './settings.controller.js';
|
import { getSettings, postSettings, postWelcomeDialog } from './settings.controller.js';
|
||||||
import { validateSettings } from './settings.validation.js';
|
import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/welcomedialog', validateWelcomeDialog, postWelcomeDialog);
|
||||||
|
|
||||||
router.get('/', getSettings);
|
router.get('/', getSettings);
|
||||||
router.post('/', validateSettings, postSettings);
|
router.post('/', validateSettings, postSettings);
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { body, validationResult } from 'express-validator';
|
import { body, validationResult } from 'express-validator';
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Validates object for POST /ontime/settings/welcomedialog
|
||||||
|
*/
|
||||||
|
export const validateWelcomeDialog = [
|
||||||
|
body('show').exists().isBoolean(),
|
||||||
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Validates object for POST /ontime/settings
|
* @description Validates object for POST /ontime/settings
|
||||||
*/
|
*/
|
||||||
|
|||||||
+22
-19
@@ -5,16 +5,18 @@ import express from 'express';
|
|||||||
import http, { Server } from 'http';
|
import http, { Server } from 'http';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import serverTiming from 'server-timing';
|
import serverTiming from 'server-timing';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
|
// import utils
|
||||||
|
import { publicDir, srcDir } from './setup/index.js';
|
||||||
|
import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js';
|
||||||
|
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||||
|
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
|
||||||
|
|
||||||
// Import middleware configuration
|
// Import middleware configuration
|
||||||
import { bodyParser } from './middleware/bodyParser.js';
|
import { bodyParser } from './middleware/bodyParser.js';
|
||||||
import { compressedStatic } from './middleware/staticGZip.js';
|
import { compressedStatic } from './middleware/staticGZip.js';
|
||||||
|
import { loginRouter, makeAuthenticateMiddleware } from './middleware/authenticate.js';
|
||||||
// import utils
|
|
||||||
import { publicDir, srcDir, srcFiles } from './setup/index.js';
|
|
||||||
import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js';
|
|
||||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
|
||||||
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
|
|
||||||
|
|
||||||
// Import Routers
|
// Import Routers
|
||||||
import { appRouter } from './api-data/index.js';
|
import { appRouter } from './api-data/index.js';
|
||||||
@@ -44,6 +46,7 @@ import { clearUploadfolder } from './utils/upload.js';
|
|||||||
import { generateCrashReport } from './utils/generateCrashReport.js';
|
import { generateCrashReport } from './utils/generateCrashReport.js';
|
||||||
import { timerConfig } from './config/config.js';
|
import { timerConfig } from './config/config.js';
|
||||||
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
|
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
|
||||||
|
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
|
||||||
|
|
||||||
console.log('\n');
|
console.log('\n');
|
||||||
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||||
@@ -72,19 +75,18 @@ if (!isProduction) {
|
|||||||
}
|
}
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
|
|
||||||
// setup cors for all routes
|
|
||||||
app.use(cors());
|
|
||||||
|
|
||||||
// enable pre-flight cors
|
|
||||||
app.options('*', cors());
|
|
||||||
|
|
||||||
// Implement middleware
|
// Implement middleware
|
||||||
|
app.use(cors()); // setup cors for all routes
|
||||||
|
app.options('*', cors()); // enable pre-flight cors
|
||||||
|
|
||||||
app.use(bodyParser);
|
app.use(bodyParser);
|
||||||
app.use(prefix, compressedStatic);
|
app.use(cookieParser());
|
||||||
|
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware(prefix);
|
||||||
|
|
||||||
// Implement route endpoints
|
// Implement route endpoints
|
||||||
app.use(`${prefix}/data`, appRouter); // router for application data
|
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||||
app.use(`${prefix}/api`, integrationRouter); // router for integrations
|
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||||
|
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||||
|
|
||||||
// serve static external files
|
// serve static external files
|
||||||
app.use(`${prefix}/external`, express.static(publicDir.externalDir));
|
app.use(`${prefix}/external`, express.static(publicDir.externalDir));
|
||||||
@@ -94,9 +96,9 @@ app.use(`${prefix}/external`, (req, res) => {
|
|||||||
});
|
});
|
||||||
app.use(`${prefix}/user`, express.static(publicDir.userDir));
|
app.use(`${prefix}/user`, express.static(publicDir.userDir));
|
||||||
|
|
||||||
app.get(`${prefix}/*`, (_req, res) => {
|
// Base route for static files
|
||||||
res.sendFile(srcFiles.clientIndexHtml);
|
app.use(`${prefix}`, authenticateAndRedirect, compressedStatic);
|
||||||
});
|
app.use(`${prefix}/*`, authenticateAndRedirect, compressedStatic);
|
||||||
|
|
||||||
// Implement catch all
|
// Implement catch all
|
||||||
app.use((_error, response) => {
|
app.use((_error, response) => {
|
||||||
@@ -166,8 +168,9 @@ export const startServer = async (
|
|||||||
// the express server must be started before the socket otherwise the on error event listener will not attach properly
|
// the express server must be started before the socket otherwise the on error event listener will not attach properly
|
||||||
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
|
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
|
||||||
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
|
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
|
||||||
|
const showWelcome = await getShowWelcomeDialog();
|
||||||
|
|
||||||
socket.init(expressServer, prefix);
|
socket.init(expressServer, showWelcome, prefix);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module initialises the services and provides initial payload for the store
|
* Module initialises the services and provides initial payload for the store
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const environment = isTest ? 'test' : env;
|
|||||||
export const isDocker = env === 'docker';
|
export const isDocker = env === 'docker';
|
||||||
export const isProduction = isDocker || (env === 'production' && !isTest);
|
export const isProduction = isDocker || (env === 'production' && !isTest);
|
||||||
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
|
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
|
||||||
|
export const password = process.env.SESSION_PASSWORD;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the router prefix in the index.html file
|
* Updates the router prefix in the index.html file
|
||||||
* This is only needed in the cloud environment where the client is not at the root segment
|
* This is only needed in the cloud environment where the client is not at the root segment
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
|
||||||
|
<meta name="description" content="Login page for Ontime application" />
|
||||||
|
<title>Ontime Login</title>
|
||||||
|
<style>
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana,
|
||||||
|
sans-serif;
|
||||||
|
background: #101010;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
padding-top: 30vh;
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #ff7597;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
all: unset;
|
||||||
|
text-align: start;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
background-color: #fffffa;
|
||||||
|
color: black;
|
||||||
|
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: 2px solid #779be7;
|
||||||
|
outline-offset: 2px;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
all: unset;
|
||||||
|
color: #779be7;
|
||||||
|
background: #2d2d2d;
|
||||||
|
height: 2rem;
|
||||||
|
padding-inline: 2rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #404040;
|
||||||
|
}
|
||||||
|
button:focus {
|
||||||
|
outline: 2px solid #779be7;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<form method="post" class="form" autocomplete="off">
|
||||||
|
<h1>Welcome to Ontime</h1>
|
||||||
|
<input type="hidden" name="redirect" value="" id="redirect" />
|
||||||
|
<input type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const redirect = params.get('redirect');
|
||||||
|
if (redirect) {
|
||||||
|
// Validate redirect URL to prevent open redirect attacks
|
||||||
|
const isValidRedirect = redirect.startsWith('/') && !redirect.startsWith('//');
|
||||||
|
if (isValidRedirect) {
|
||||||
|
document.getElementById('redirect').value = redirect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { LogOrigin } from 'ontime-types';
|
||||||
|
|
||||||
|
import express, { type Request, type Response, type NextFunction } from 'express';
|
||||||
|
import type { IncomingMessage } from 'node:http';
|
||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
import { parse as parseCookie } from 'cookie';
|
||||||
|
|
||||||
|
import { hashPassword } from '../utils/hash.js';
|
||||||
|
import { srcFiles } from '../setup/index.js';
|
||||||
|
import { logger } from '../classes/Logger.js';
|
||||||
|
import { password } from '../externals.js';
|
||||||
|
import { noopMiddleware } from './noop.js';
|
||||||
|
|
||||||
|
export const hasPassword = Boolean(password);
|
||||||
|
const hashedPassword = hasPassword ? hashPassword(password) : '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of public assets that can be accessed without authentication
|
||||||
|
* should match the files in client/public
|
||||||
|
*/
|
||||||
|
const publicAssets = new Set([
|
||||||
|
'/favicon.ico',
|
||||||
|
'/manifest.json',
|
||||||
|
'/ontime-logo.png',
|
||||||
|
'/robots.txt',
|
||||||
|
'/site.webmanifest',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const loginRouter = express.Router();
|
||||||
|
|
||||||
|
// serve static files at root
|
||||||
|
loginRouter.use('/', express.static(srcFiles.login));
|
||||||
|
|
||||||
|
// verify password and set cookies + redirect appropriately
|
||||||
|
loginRouter.post('/', (req, res) => {
|
||||||
|
res.clearCookie('token');
|
||||||
|
const { password: reqPassword, redirect } = req.body;
|
||||||
|
|
||||||
|
if (hashPassword(reqPassword) === hashedPassword) {
|
||||||
|
setSessionCookie(res, hashedPassword);
|
||||||
|
res.redirect(redirect || '/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).send('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express middleware to authenticate requests
|
||||||
|
* @param {string} prefix - Prefix is used for the client hashes in Ontime Cloud
|
||||||
|
*/
|
||||||
|
export function makeAuthenticateMiddleware(prefix: string) {
|
||||||
|
// we dont need to initialise the authenticate middleware if there is no password
|
||||||
|
if (!hasPassword) {
|
||||||
|
return { authenticate: noopMiddleware, authenticateAndRedirect: noopMiddleware };
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const token = req.query.token || req.cookies?.token;
|
||||||
|
if (token && token === hashedPassword) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).send('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticateAndRedirect(req: Request, res: Response, next: NextFunction) {
|
||||||
|
// Allow access to specific public assets without authentication
|
||||||
|
if (publicAssets.has(req.originalUrl)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// we shouldnt be here in the login route
|
||||||
|
if (req.originalUrl.startsWith('/login')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// we expect the token to be in the cookies
|
||||||
|
if (req.cookies?.token === hashedPassword) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// we use query params for generating authenticated URLs and for clients like the companion module
|
||||||
|
// if the user gives is a token in the query params, we set the cookie to be used in further requests
|
||||||
|
if (req.query.token === hashedPassword) {
|
||||||
|
setSessionCookie(res, hashedPassword);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(`${prefix}/login?redirect=${req.originalUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authenticate, authenticateAndRedirect };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to authenticate a WebSocket connection with a token in the cookie
|
||||||
|
*/
|
||||||
|
export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (error?: Error) => void) {
|
||||||
|
if (!hasPassword) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the token is in the cookie
|
||||||
|
const cookieString = req.headers.cookie;
|
||||||
|
if (typeof cookieString === 'string') {
|
||||||
|
const cookies = parseCookie(cookieString);
|
||||||
|
if (cookies.token === hashedPassword) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if token is in the params
|
||||||
|
const url = new URL(req.url || '', `http://${req.headers.host}`);
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
if (token === hashedPassword) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warning(LogOrigin.Client, 'Unauthorized WebSocket connection attempt');
|
||||||
|
return next(new Error('Unauthorized'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionCookie(res: Response, token: string) {
|
||||||
|
res.cookie('token', token, {
|
||||||
|
httpOnly: false, // allow websocket to access cookie
|
||||||
|
secure: true,
|
||||||
|
path: '/', // allow cookie to be accessed from any path
|
||||||
|
sameSite: 'strict',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
export function noopMiddleware(_req: Request, _res: Response, next: NextFunction) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
|
import { dayInMs, millisToString } from 'ontime-utils';
|
||||||
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
getTotalDuration,
|
|
||||||
normaliseEndTime,
|
normaliseEndTime,
|
||||||
skippedOutOfEvent,
|
skippedOutOfEvent,
|
||||||
} from '../timerUtils.js';
|
} from '../timerUtils.js';
|
||||||
@@ -1047,40 +1046,6 @@ describe('getRuntimeOffset()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getTotalDuration()', () => {
|
|
||||||
it('calculates the duration of events in a single day', () => {
|
|
||||||
const start = MILLIS_PER_HOUR * 9;
|
|
||||||
const end = MILLIS_PER_HOUR * 17;
|
|
||||||
const daySpan = 0;
|
|
||||||
const duration = getTotalDuration(start, end, daySpan);
|
|
||||||
expect(duration).toBe(MILLIS_PER_HOUR * (17 - 9));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calculates the duration of events across days', () => {
|
|
||||||
const start = MILLIS_PER_HOUR * 9;
|
|
||||||
const end = MILLIS_PER_HOUR * 17;
|
|
||||||
const daySpan = 1;
|
|
||||||
const duration = getTotalDuration(start, end, daySpan);
|
|
||||||
expect(duration).toBe(MILLIS_PER_HOUR * (17 - 9) + dayInMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calculates the duration of events across days (2)', () => {
|
|
||||||
const start = new Date(0).setHours(12);
|
|
||||||
const end = new Date(0).setHours(8);
|
|
||||||
const daySpan = 1;
|
|
||||||
const duration = getTotalDuration(start, end, daySpan);
|
|
||||||
expect(millisToString(duration)).toBe('20:00:00');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calculates the duration of events across days (3)', () => {
|
|
||||||
const start = new Date(0).setHours(9);
|
|
||||||
const end = new Date(0).setHours(23);
|
|
||||||
const daySpan = 2;
|
|
||||||
const duration = getTotalDuration(start, end, daySpan);
|
|
||||||
expect(millisToString(duration)).toBe('62:00:00');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getTimerPhase()', () => {
|
describe('getTimerPhase()', () => {
|
||||||
it('should be None if the timer is not running', () => {
|
it('should be None if the timer is not running', () => {
|
||||||
const state = {
|
const state = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { shouldCrashDev } from '../../utils/development.js';
|
|||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
lastLoadedProject?: string;
|
lastLoadedProject?: string;
|
||||||
|
showWelcomeDialog?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = new JSONFile<AppState>(publicFiles.appState);
|
const adapter = new JSONFile<AppState>(publicFiles.appState);
|
||||||
@@ -34,3 +35,19 @@ export async function setLastLoadedProject(filename: string): Promise<void> {
|
|||||||
config.data.lastLoadedProject = filename;
|
config.data.lastLoadedProject = filename;
|
||||||
await config.write();
|
await config.write();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getShowWelcomeDialog(): Promise<boolean> {
|
||||||
|
// in test environment, we do not want the dialog
|
||||||
|
if (isTest) return false;
|
||||||
|
|
||||||
|
await config.read();
|
||||||
|
return config.data.showWelcomeDialog ?? true; // default to true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setShowWelcomeDialog(show: boolean): Promise<boolean> {
|
||||||
|
if (isTest) return;
|
||||||
|
|
||||||
|
config.data.showWelcomeDialog = show;
|
||||||
|
await config.write();
|
||||||
|
return show;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
appendToName,
|
appendToName,
|
||||||
dockerSafeRename,
|
dockerSafeRename,
|
||||||
ensureDirectory,
|
ensureDirectory,
|
||||||
|
ensureJsonExtension,
|
||||||
generateUniqueFileName,
|
generateUniqueFileName,
|
||||||
getFileNameFromPath,
|
getFileNameFromPath,
|
||||||
removeFileExtension,
|
removeFileExtension,
|
||||||
@@ -20,6 +21,7 @@ import { parseRundown } from '../../utils/parserFunctions.js';
|
|||||||
import { demoDb } from '../../models/demoProject.js';
|
import { demoDb } from '../../models/demoProject.js';
|
||||||
import { config } from '../../setup/config.js';
|
import { config } from '../../setup/config.js';
|
||||||
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
|
||||||
|
|
||||||
import { initRundown } from '../rundown-service/RundownService.js';
|
import { initRundown } from '../rundown-service/RundownService.js';
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +41,6 @@ import {
|
|||||||
moveCorruptFile,
|
moveCorruptFile,
|
||||||
parseJsonFile,
|
parseJsonFile,
|
||||||
} from './projectServiceUtils.js';
|
} from './projectServiceUtils.js';
|
||||||
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
|
|
||||||
|
|
||||||
// init dependencies
|
// init dependencies
|
||||||
init();
|
init();
|
||||||
@@ -267,7 +268,8 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
|
|||||||
export async function createProject(filename: string, initialData: Partial<DatabaseModel>) {
|
export async function createProject(filename: string, initialData: Partial<DatabaseModel>) {
|
||||||
const data = safeMerge(dbModel, initialData);
|
const data = safeMerge(dbModel, initialData);
|
||||||
|
|
||||||
const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, filename);
|
const fileNameWithExtension = ensureJsonExtension(filename);
|
||||||
|
const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, fileNameWithExtension);
|
||||||
const newFile = getPathToProject(uniqueFileName);
|
const newFile = getPathToProject(uniqueFileName);
|
||||||
|
|
||||||
// change LowDB to point to new file
|
// change LowDB to point to new file
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { SupportedEvent, OntimeEvent, OntimeDelay } from 'ontime-types';
|
|||||||
const baseEvent = {
|
const baseEvent = {
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
skip: false,
|
skip: false,
|
||||||
|
revision: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -156,26 +156,6 @@ export function getRuntimeOffset(state: RuntimeState): number {
|
|||||||
return startOffset - addedTime - pausedTime + overtime;
|
return startOffset - addedTime - pausedTime + overtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates total duration of a time span
|
|
||||||
* @param firstStart
|
|
||||||
* @param lastEnd
|
|
||||||
* @param daySpan
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export function getTotalDuration(firstStart: number, lastEnd: number, daySpan: number): number {
|
|
||||||
if (!lastEnd) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let correctDay = 0;
|
|
||||||
if (lastEnd < firstStart) {
|
|
||||||
correctDay = dayInMs;
|
|
||||||
daySpan -= 1;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line prettier/prettier -- we like the clarity
|
|
||||||
return lastEnd + correctDay + daySpan * dayInMs - firstStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the expected end of the rundown
|
* Calculates the expected end of the rundown
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export const srcFiles = {
|
|||||||
userReadme: join(srcDir.root, config.user, 'README.md'),
|
userReadme: join(srcDir.root, config.user, 'README.md'),
|
||||||
/** Path to bundled CSS readme */
|
/** Path to bundled CSS readme */
|
||||||
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
|
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
|
||||||
|
/** Path to login */
|
||||||
|
login: join(srcDir.root, 'html/login.html'),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,60 +5,62 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/** Background colour for the views */
|
/** Background colour for the views */
|
||||||
--background-color-override: #ececec;
|
--background-color-override: #ececec;
|
||||||
|
|
||||||
/** Main text colour for the views */
|
/** Main text colour for the views */
|
||||||
--color-override: #101010;
|
--color-override: #101010;
|
||||||
|
|
||||||
/** Text colour for the views */
|
/** Text colour for the views */
|
||||||
--secondary-color-override: #404040;
|
--secondary-color-override: #404040;
|
||||||
|
|
||||||
/** Accent text colour, used on active elements */
|
/** Accent text colour, used on active elements */
|
||||||
--accent-color-override: #fa5656;
|
--accent-color-override: #fa5656;
|
||||||
|
|
||||||
/** Label text colour, used on active elements */
|
/** Label text colour, used on active elements */
|
||||||
--label-color-override: #6c6c6c;
|
--label-color-override: #6c6c6c;
|
||||||
|
|
||||||
/** Timer text colour */
|
/** Timer text colour */
|
||||||
--timer-color-override: #202020;
|
--timer-color-override: #202020;
|
||||||
|
--timer-warning-color-override: #ffbc56;
|
||||||
|
--timer-danger-color-override: #e69000;
|
||||||
|
--timer-overtime-color-override: #fa5656;
|
||||||
|
|
||||||
/** Background for card elements on background */
|
/** Background for card elements on background */
|
||||||
--card-background-color-override: #fff;
|
--card-background-color-override: #fff;
|
||||||
|
|
||||||
/** Background highlight for blink behaviour, used only in /backstage */
|
/** Font used for all text in views */
|
||||||
--card-background-color-blink-override: #339e4e;
|
--font-family-override: 'Open Sans';
|
||||||
|
|
||||||
/** Font used for all text in views */
|
/** Font used for clock in /minimal and /clock views */
|
||||||
--font-family-override: "Open Sans";
|
--font-family-bold-override: 'Arial Black';
|
||||||
|
|
||||||
/** Font used for clock in /minimal and /clock views */
|
/** Colour used for external message and aux timer in /timer */
|
||||||
--font-family-bold-override: "Arial Black";
|
--external-color-override: #161616;
|
||||||
|
|
||||||
/** Colour used for progress bar background */
|
/** View specific features: /backstage */
|
||||||
--timer-progress-bg-override: #fff;
|
/** ---- Background highlight for blink behaviour */
|
||||||
|
--card-background-color-blink-override: #339e4e;
|
||||||
|
/** ---- Colour used for progress bar background */
|
||||||
|
--timer-progress-bg-override: #fff;
|
||||||
|
/** ---- Colour used for progress bar progress */
|
||||||
|
--timer-progress-override: #202020;
|
||||||
|
|
||||||
/** Colour used for progress bar progress */
|
/** View specific features: /studio */
|
||||||
--timer-progress-override: #202020;
|
--studio-active: #101010;
|
||||||
|
--studio-idle: #cfcfcf;
|
||||||
|
--studio-active-label: #101010;
|
||||||
|
--studio-idle-label: #595959;
|
||||||
|
--studio-overtime: #101010;
|
||||||
|
|
||||||
/** Colour used for external message and aux timer in /timer */
|
/** View specific features: /lower */
|
||||||
--external-color-override: #161616;
|
--lowerThird-font-family-override: 'Courier New';
|
||||||
|
--lowerThird-top-font-weight-override: bold;
|
||||||
/** View specific features: /studio */
|
--lowerThird-bottom-font-weight-override: bold;
|
||||||
--studio-active: #101010;
|
--lowerThird-top-font-style-override: normal;
|
||||||
--studio-idle: #cfcfcf;
|
--lowerThird-bottom-font-style-override: italic;
|
||||||
--studio-active-label: #101010;
|
--lowerThird-line-height-override: 1vh;
|
||||||
--studio-idle-label: #595959;
|
--lowerThird-text-align-override: end;
|
||||||
--studio-overtime: #101010;
|
|
||||||
|
|
||||||
/** View specific features: /lower */
|
|
||||||
--lowerThird-font-family-override: 'Courier New';
|
|
||||||
--lowerThird-top-font-weight-override: bold;
|
|
||||||
--lowerThird-bottom-font-weight-override: bold;
|
|
||||||
--lowerThird-top-font-style-override: normal;
|
|
||||||
--lowerThird-bottom-font-style-override: italic;
|
|
||||||
--lowerThird-line-height-override: 1vh;
|
|
||||||
--lowerThird-text-align-override: end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,5 +68,5 @@
|
|||||||
* In the below example, we change the colour of the overlay message in the stage-timer view.
|
* In the below example, we change the colour of the overlay message in the stage-timer view.
|
||||||
*/
|
*/
|
||||||
.stage-timer > .message-overlay--active > div {
|
.stage-timer > .message-overlay--active > div {
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a hash of the password that is URL safe
|
||||||
|
* @link https://stackoverflow.com/questions/17639645/websafe-encoding-of-hashed-string-in-nodejs
|
||||||
|
*/
|
||||||
|
export function hashPassword(password: string) {
|
||||||
|
return createHash('sha256').update(password).digest('base64url');
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, pad, parseUserTime, isISO8601 } from 'ontime-utils';
|
||||||
import { isISO8601 } from '../../../../packages/utils/src/date-utils/isTimeString.js';
|
|
||||||
|
|
||||||
export const timeFormat = 'HH:mm';
|
export const timeFormat = 'HH:mm';
|
||||||
export const timeFormatSeconds = 'HH:mm:ss';
|
export const timeFormatSeconds = 'HH:mm:ss';
|
||||||
@@ -46,3 +45,18 @@ export function parseExcelDate(excelDate: unknown): number {
|
|||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTimezoneLabel(date: Date): string {
|
||||||
|
const tz = date.getTimezoneOffset();
|
||||||
|
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
// timezone offset is inverted
|
||||||
|
const sign = tz < 0 ? '+' : '-';
|
||||||
|
const abs = Math.abs(tz);
|
||||||
|
|
||||||
|
// convert minutes to hours
|
||||||
|
const hours = Math.floor(abs / 60);
|
||||||
|
const minutes = abs % 60;
|
||||||
|
|
||||||
|
return `GMT ${sign}${pad(hours)}:${pad(minutes)} ${tzName}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Automations
|
||||||
|
|
||||||
|
The automation feature's purpose is to integrate ontime into users' workflows.
|
||||||
|
Ontime has a large amount of production information, which users need considerable effort to maintain. We want to allow tools so that:
|
||||||
|
|
||||||
|
- allow distribution of Ontime's and other production data
|
||||||
|
- allow surfacing Ontime events
|
||||||
|
- allow synchronizing with other tools
|
||||||
|
|
||||||
|
## Previous
|
||||||
|
|
||||||
|
Previous iterations imposed limitations on the number of integrations and target devices to evaluate performance concerns.
|
||||||
|
|
||||||
|
The feature was not as used as we had hoped, and users often escalated the integration to tools like Companion.
|
||||||
|
I believe this to be in part from a lack of clarity on the feature and the limitations imposed
|
||||||
|
|
||||||
|
- Lack of explicit filtering logic made the process hard to reason
|
||||||
|
- Users met limitations on target devices earlier than expected. We would want users to meet these limitations only when the project grew over a size where a show controller would be needed
|
||||||
|
- Building the template strings was complex and poorly documented
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- The user should be able to create as many automations as they want
|
||||||
|
- Automations are triggered by lifecycle events
|
||||||
|
- Each automation should go through a user-defined filtering process
|
||||||
|
- Each automation should be able to target multiple devices and multiple protocols
|
||||||
|
- We leverage HTTP / OSC as the main protocols ~~and add support for triggering Companion button presses~~
|
||||||
|
- To allow easier debugging and "learn" workflows, users should be able to test a message before saving the automation
|
||||||
|
- We should have inline documentation for the template strings
|
||||||
|
|
||||||
|
> ⚠️ **Companion module** \
|
||||||
|
> We have opted for removing the requirement for companion module integration. \
|
||||||
|
> We did not see as much friction that could be solved with a custom integration as initially thought. Triggering a button press in companion is done trivially over either OSC or HTTP and companion users are familiar with the process.
|
||||||
|
|
||||||
|
## Implementation details
|
||||||
|
|
||||||
|
- To simplify the usage of template strings, we will generate a list of template strings at runtime from the user project file
|
||||||
|
- To allow easier implementation and extensions, we want to keep the automations separate from triggers
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Automation = {
|
||||||
|
id: AutomationId;
|
||||||
|
name: string;
|
||||||
|
filter: Filter[];
|
||||||
|
output: Output[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Trigger = {
|
||||||
|
id: string;
|
||||||
|
event: TimerLifecycle;
|
||||||
|
automations: AutomationId[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extension
|
||||||
|
|
||||||
|
- Users have expressed a desire to have automation triggered by the lifecycle of a specific event. By keeping the trigger separate from the automation, we will allow this to be implemented in the future.
|
||||||
@@ -8,9 +8,6 @@ const fileToDownload = 'e2e/tests/fixtures/tmp/test-db.json';
|
|||||||
test('project file upload', async ({ page }) => {
|
test('project file upload', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/editor');
|
await page.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
// close the welcome modal if it is open
|
|
||||||
await page.keyboard.down('Escape');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Edit' }).click();
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||||
@@ -41,9 +38,6 @@ test('project file upload', async ({ page }) => {
|
|||||||
test('project file download', async ({ page }) => {
|
test('project file download', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/editor');
|
await page.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
// close the welcome modal if it is open
|
|
||||||
await page.keyboard.down('Escape');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'toggle settings' }).click();
|
await page.getByRole('button', { name: 'toggle settings' }).click();
|
||||||
await page.getByRole('button', { name: 'Project', exact: true }).click();
|
await page.getByRole('button', { name: 'Project', exact: true }).click();
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.10.3",
|
"version": "3.10.7",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules"
|
"cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.42.1",
|
"@playwright/test": "^1.49.1",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
"@typescript-eslint/parser": "catalog:",
|
"@typescript-eslint/parser": "catalog:",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface SessionStats {
|
|||||||
lastRequest: MaybeString;
|
lastRequest: MaybeString;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
playback: Playback;
|
playback: Playback;
|
||||||
timezone: number;
|
timezone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetInfo {
|
export interface GetInfo {
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ export {
|
|||||||
millisToSeconds,
|
millisToSeconds,
|
||||||
secondsInMillis,
|
secondsInMillis,
|
||||||
} from './src/date-utils/conversionUtils.js';
|
} from './src/date-utils/conversionUtils.js';
|
||||||
export { isTimeString } from './src/date-utils/isTimeString.js';
|
export { isISO8601, isTimeString } from './src/date-utils/isTimeString.js';
|
||||||
export {
|
export {
|
||||||
formatFromMillis,
|
formatFromMillis,
|
||||||
millisToString,
|
millisToString,
|
||||||
|
pad,
|
||||||
removeLeadingZero,
|
removeLeadingZero,
|
||||||
removeSeconds,
|
removeSeconds,
|
||||||
removeTrailingZero,
|
removeTrailingZero,
|
||||||
@@ -63,7 +64,9 @@ export { customFieldLabelToKey } from './src/customField-utils/customFieldLabelT
|
|||||||
export { deepmerge } from './src/externals/deepmerge.js';
|
export { deepmerge } from './src/externals/deepmerge.js';
|
||||||
|
|
||||||
// array utils
|
// array utils
|
||||||
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/array-utils/arrayUtils.js';
|
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/common/arrayUtils.js';
|
||||||
|
// object utils
|
||||||
|
export { getPropertyFromPath } from './src/common/objectUtils.js';
|
||||||
|
|
||||||
// generic utilities
|
// generic utilities
|
||||||
export { getErrorMessage } from './src/generic/generic.js';
|
export { getErrorMessage } from './src/generic/generic.js';
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Extracts a value from a nested object using a dot-separated path
|
||||||
|
*/
|
||||||
|
export function getPropertyFromPath<T extends object>(path: string, obj: T): any | undefined {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let result: any = obj;
|
||||||
|
|
||||||
|
// iterate through variable parts, and look for the property in the given object
|
||||||
|
for (const key of keys) {
|
||||||
|
if (result && typeof result === 'object' && key in result) {
|
||||||
|
result = result[key];
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { getPropertyFromPath } from './objectUtils';
|
||||||
|
|
||||||
|
describe('getNestedFromTemplate', () => {
|
||||||
|
it('should return the value of a nested property', () => {
|
||||||
|
expect(getPropertyFromPath('a', { a: 1 })).toBe(1);
|
||||||
|
expect(getPropertyFromPath('a.b', { a: { b: 1 } })).toBe(1);
|
||||||
|
expect(getPropertyFromPath('a.b.c', { a: { b: { c: 1 } } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should guard against values that do not exist', () => {
|
||||||
|
expect(getPropertyFromPath('c', {})).toBe(undefined);
|
||||||
|
expect(getPropertyFromPath('c', { a: 1 })).toBe(undefined);
|
||||||
|
expect(getPropertyFromPath('a.c', { a: { b: 1 } })).toBeUndefined();
|
||||||
|
expect(getPropertyFromPath('c.a', { a: { b: 1 } })).toBeUndefined();
|
||||||
|
expect(getPropertyFromPath('a.b.b', { a: { b: { c: 1 } } })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import type { MaybeNumber } from 'ontime-types';
|
|||||||
|
|
||||||
import { millisToSeconds, secondsToHours, secondsToMinutes } from './conversionUtils.js';
|
import { millisToSeconds, secondsToHours, secondsToMinutes } from './conversionUtils.js';
|
||||||
|
|
||||||
function pad(val: number): string {
|
export function pad(val: number): string {
|
||||||
return String(val).padStart(2, '0');
|
return String(val).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,4 +27,8 @@ describe('validateTimerType()', () => {
|
|||||||
expect(emptyType).toBe(TimerType.Clock);
|
expect(emptyType).toBe(TimerType.Clock);
|
||||||
expect(invalidType).toBe(TimerType.CountDown);
|
expect(invalidType).toBe(TimerType.CountDown);
|
||||||
});
|
});
|
||||||
|
it('handles a null value from params', () => {
|
||||||
|
const nullType = validateTimerType(null, TimerType.Clock);
|
||||||
|
expect(nullType).toBe(TimerType.Clock);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+127
-436
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user