Compare commits

..

49 Commits

Author SHA1 Message Date
arc-alex 6348defd42 test level db 2025-05-30 13:07:42 +02:00
Carlos Valente a89f5b93da refactor: improve reorder logic 2025-05-28 19:09:49 +02:00
Carlos Valente b919f00501 chore: simplify URLs 2025-05-28 19:09:49 +02:00
Carlos Valente 8b37f6cc99 refactor: order is single source of truth 2025-05-28 19:09:49 +02:00
Carlos Valente 9f27832b29 refactor: refetch targets is enum 2025-05-28 19:09:49 +02:00
Carlos Valente 256755bf02 refactor: small ux improvements
- rename dissolve > ungroup
- prevent ondrag when clicking
- add untitled as block title fallback
- move block action to context menu
2025-05-28 19:09:49 +02:00
Carlos Valente b0e42811a5 refactor: remove trivially inferred numEvents 2025-05-28 19:09:49 +02:00
Carlos Valente f68b3bf057 feat: duplicate groups 2025-05-28 19:09:49 +02:00
Carlos Valente 7d5291e5e5 feat: create group from entry selection 2025-05-28 19:09:49 +02:00
Carlos Valente 67f914bdc0 feat: create block from rundown empty 2025-05-28 19:09:49 +02:00
Carlos Valente 0a44bfe783 fix: collapsed blocks dont render children 2025-05-28 19:09:49 +02:00
Carlos Valente 4a83983161 refactor: type cleanup and test improvements 2025-05-28 19:09:49 +02:00
Carlos Valente 817f0dd4de feat: allow dissolving a block 2025-05-28 19:09:49 +02:00
Carlos Valente a6cb7e5b20 fix: uncontrolled prop on controlled component 2025-05-28 19:09:49 +02:00
Carlos Valente 8232e64cc6 refactor: improve return of reorder 2025-05-28 19:09:49 +02:00
Carlos Valente 1ee773a426 refactor: mutations on batch elements must have IDs 2025-05-28 19:09:49 +02:00
Carlos Valente 88a0bd62be chore: upgrade dependencies 2025-05-28 19:09:49 +02:00
Carlos Valente 918140086c refactor: extract utility to merge two arrays 2025-05-28 19:09:49 +02:00
Carlos Valente df55b0bf8d refactor: change network mode defaults 2025-05-28 19:09:49 +02:00
Carlos Valente 21353b5b00 fix: delete nested events 2025-05-28 19:09:49 +02:00
Carlos Valente d351778ef2 fix: add event at end of block 2025-05-28 19:09:49 +02:00
Carlos Valente deffbdaa43 refactor: make finder available in exported rundown 2025-05-22 08:38:06 +02:00
Alex Christoffer Rasmussen abd4369049 assert non null and update test (#1604) 2025-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen 75c6016f4e Fix project renumber (#1597)
* fix: generateUniqueFileName

* loadProject should not generate new names

* update comments

* extract and test getProjectNumber

* spell

* finish jsdoc

* use getProjectNumber

* cleanup loadProject

* create a `incrementProjectNumber` function

* spelling

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen bdcec7a472 Refactor: WebSocket from flush queue to one patch (#1595)
* change flush to one patch

* remove unused types

* create batch

* merge patch into eventStore

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen 0a596624d5 Refactor: ms for api calls (#1593)
* use ms for api calls

* change it in UI

* update comment

* rename variables

* refactor: addtime api logic
2025-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen 94b80d3b0b fix test (#1601)
* fix test

* add comment
2025-05-14 20:07:42 +02:00
Carlos Valente 92fbef3515 fix: rebase master 2025-05-14 20:07:42 +02:00
Carlos Valente 10c2f0cf6c chore: correct test path 2025-05-14 20:07:42 +02:00
Carlos Valente e30b4fa8d5 refactor: extract rundown parsing
refactor: implement groups in editor
2025-05-14 20:07:42 +02:00
Carlos Valente 52358d75d3 chore: improve convention entry <> event 2025-05-14 20:07:42 +02:00
Carlos Valente a55dfba4ef refactor: maintain flat orders 2025-05-14 20:07:42 +02:00
Carlos Valente ed7f8a0aa2 refactor: implement operations on nested events 2025-05-14 20:07:42 +02:00
Carlos Valente ba1292141b refactor: process events in rundown 2025-05-14 20:07:42 +02:00
Carlos Valente 57bd317dcf chore: improve convention entry <> event 2025-05-14 20:07:42 +02:00
Carlos Valente 0cae37d6c8 chore: rename currentBlock > parent 2025-05-14 20:07:42 +02:00
Carlos Valente 6c8ddeec23 refactor: fix delay positioning in gaps 2025-05-14 20:07:42 +02:00
Carlos Valente 3f3ecce7b2 refactor(e2e): skip flaky test 2025-05-14 20:07:42 +02:00
Carlos Valente 402d10055b refactor: improve project loading 2025-05-14 20:07:42 +02:00
Carlos Valente 8f5a4f4a0a refactor: gather group metadata 2025-05-14 20:07:42 +02:00
Carlos Valente 7a78ed4720 refactor: swap maintains schedule 2025-05-14 20:07:42 +02:00
Carlos Valente de0ac92052 chore: rename files 2025-05-14 20:07:42 +02:00
Carlos Valente 58023dce0d refactor: restructure model to contain an object of rundowns 2025-05-14 20:07:42 +02:00
Carlos Valente 0ae7178046 refactor: clearer relationship on rundown elements 2025-05-14 20:07:42 +02:00
Carlos Valente 6b01338118 refactor: use strict typing 2025-05-14 20:07:42 +02:00
Carlos Valente 7287cf8511 refactor: remove stop as a possible end action 2025-05-14 20:07:11 +02:00
Carlos Valente 6d17f188a6 refactor: restructure model to contain an object of rundowns 2025-05-14 20:07:11 +02:00
Carlos Valente 440a9b81b6 chore: remove IDE files 2025-05-14 19:58:30 +02:00
Carlos Valente 5fafa08723 refactor: remove unused and legacy code
- remove legacy migrations
- remove unused server code
- remove unused UI code
2025-05-14 19:58:30 +02:00
214 changed files with 6637 additions and 8183 deletions
+6 -6
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+2 -2
View File
@@ -20,13 +20,13 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1 +1 @@
v22.15.1 v20.15.1
+2 -2
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- __node__ (~22) - __node__ (~20)
- __pnpm__ (~10) - __pnpm__ (~9)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
+3 -3
View File
@@ -1,13 +1,13 @@
FROM node:22-bullseye AS builder FROM node:20-bullseye AS builder
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0 RUN npm install -g pnpm@9.5.0
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:22-alpine FROM node:20-alpine
# Set environment variables # Set environment variables
# Environment Variable to signal that we are running production # Environment Variable to signal that we are running production
+5 -5
View File
@@ -72,7 +72,7 @@
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
@@ -86,10 +86,10 @@
"prettier": "catalog:", "prettier": "catalog:",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "6.3.1", "vite": "^5.2.11",
"vite-plugin-compression2": "1.4.0", "vite-plugin-compression2": "^1.3.3",
"vite-plugin-svgr": "4.3.0", "vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "^4.3.1",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }
+11 -1
View File
@@ -25,10 +25,11 @@ const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./views/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('./views/countdown/Countdown')); const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage')); const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage')); const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Public = React.lazy(() => import('./views/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock')); const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo')); const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
@@ -39,6 +40,7 @@ const SClock = withPreset(withData(ClockView));
const SCountdown = withPreset(withData(Countdown)); const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage)); const SBackstage = withPreset(withData(Backstage));
const SProjectInfo = withPreset(withData(ProjectInfo)); const SProjectInfo = withPreset(withData(ProjectInfo));
const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower)); const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock)); const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline)); const STimeline = withPreset(withData(Timeline));
@@ -86,6 +88,14 @@ export default function AppRouter() {
</ViewLoader> </ViewLoader>
} }
/> />
<Route
path='/public'
element={
<ViewLoader>
<SPublic />
</ViewLoader>
}
/>
<Route <Route
path='/minimal' path='/minimal'
element={ element={
+5 -5
View File
@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types'; import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -24,15 +24,15 @@ export async function postCustomField(newField: CustomField): Promise<CustomFiel
/** /**
* Edits single custom field * Edits single custom field
*/ */
export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> { export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField }); const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
return res.data; return res.data;
} }
/** /**
* Deletes single custom field * Deletes single custom field
*/ */
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> { export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${key}`); const res = await axios.delete(`${customFieldsPath}/${label}`);
return res.data; return res.data;
} }
+2 -2
View File
@@ -51,7 +51,7 @@ type BatchEditEntry = {
/** /**
* HTTP request to edit multiple events * HTTP request to edit multiple events
*/ */
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> { export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.put(`${rundownPath}/batch`, data); return axios.put(`${rundownPath}/batch`, data);
} }
@@ -83,7 +83,7 @@ export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<M
/** /**
* HTTP request to request application of delay * HTTP request to request application of delay
*/ */
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`); return axios.patch(`${rundownPath}/applydelay/${delayId}`);
} }
@@ -1,61 +0,0 @@
@use '../../../theme/viewerDefs' as *;
.baseButton {
display: flex;
align-items: center;
gap: 0.5em;
width: fit-content;
padding-inline: 1em;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.subtle {
background: $gray-1050;
color: $blue-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.medium {
height: 2rem;
}
.large {
height: 3.5rem;
}
@@ -1,20 +0,0 @@
import { ButtonHTMLAttributes } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './Button.module.scss';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'subtle' | 'primary';
size?: 'medium' | 'large';
}
export default function Button(props: ButtonProps) {
const { className, children, variant = 'subtle', size = 'medium', ...buttonProps } = props;
return (
<button className={cx([style.baseButton, style[variant], style[size], className])} type='button' {...buttonProps}>
{children}
</button>
);
}
@@ -1,28 +1,21 @@
@use '../../../theme/viewerDefs' as *; .subtle {
.baseIconButton {
aspect-ratio: 1; aspect-ratio: 1;
height: 2rem; height: 2rem;
width: 2rem; height: 2rem;
display: grid; display: grid;
place-content: center; place-content: center;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
}
.subtle {
background: $gray-1050; background: $gray-1050;
color: $blue-400; color: $blue-400;
border: 1px solid transparent;
border-radius: 3px;
&:hover:not(:disabled):not(:active) { &:hover:not(:disabled):not(:active) {
background: $gray-1000; background: $gray-1000;
color: $blue-500; color: $blue-500;
} }
&:active:not(:disabled){ &:active {
background: $gray-1100; background: $gray-1100;
border-color: $gray-1250; border-color: $gray-1250;
} }
@@ -31,37 +24,3 @@
background: $gray-1050; background: $gray-1050;
} }
} }
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
@@ -4,15 +4,10 @@ import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss'; import style from './IconButton.module.scss';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
variant?: 'subtle' | 'subtle-white' | 'destructive'; const { className, children, ...buttonProps } = props;
}
export default function IconButton(props: IconButtonProps) {
const { className, children, variant = 'subtle', ...buttonProps } = props;
return ( return (
<button className={cx([style.baseIconButton, style[variant], className])} type='button' {...buttonProps}> <button className={cx([style.subtle, className])} {...buttonProps}>
{children} {children}
</button> </button>
); );
@@ -3,7 +3,6 @@ import { IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import IconButton from '../buttons/IconButton';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -18,24 +17,22 @@ export default function FloatingNavigation(props: FloatingNavigationProps) {
return ( return (
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}> <div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
<IconButton <button
variant='subtle-white'
className={style.navButton}
onClick={toggleMenu} onClick={toggleMenu}
aria-label='toggle menu' aria-label='toggle menu'
className={style.navButton}
data-testid='navigation__toggle-menu' data-testid='navigation__toggle-menu'
> >
<IoApps /> <IoApps />
</IconButton> </button>
<IconButton <button
variant='subtle-white' className={style.button}
className={style.navButton}
onClick={toggleSettings} onClick={toggleSettings}
aria-label='toggle settings' aria-label='toggle settings'
data-testid='navigation__toggle-settings' data-testid='navigation__toggle-settings'
> >
<IoSettingsOutline /> <IoSettingsOutline />
</IconButton> </button>
</div> </div>
); );
} }
@@ -3,6 +3,8 @@
$menu-hover-bg: $gray-1350; $menu-hover-bg: $gray-1350;
$menu-focus-bg: $gray-1300; $menu-focus-bg: $gray-1300;
$icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 3rem; $button-size: 3rem;
.fadeable { .fadeable {
@@ -13,7 +15,6 @@ $button-size: 3rem;
&.hidden { &.hidden {
opacity: 0; opacity: 0;
pointer-events: none;
} }
} }
@@ -40,10 +41,20 @@ $button-size: 3rem;
z-index: 12; z-index: 12;
} }
.navButton { .button {
font-size: 1.5rem; font-size: 1.5rem;
color: $icon-color;
background-color: $button-bg;
width: $button-size;
height: $button-size; height: $button-size;
width: $button-size;; display: grid;
place-content: center;
border-radius: 3px;
}
.navButton {
@extend .button;
z-index: 3;
} }
.link { .link {
@@ -49,11 +49,11 @@ export function useFlatRundown() {
// update data whenever the revision changes // update data whenever the revision changes
useEffect(() => { useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) { if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.flatOrder.map((id) => data.entries[id]); const flatRundown = data.order.map((id) => data.entries[id]);
setFlatRundown(flatRundown); setFlatRundown(flatRundown);
setPrevRevision(data.revision); setPrevRevision(data.revision);
} }
}, [data.entries, data.flatOrder, data.revision, prevRevision]); }, [data.entries, data.order, data.revision, prevRevision]);
// TODO: should we have a project id field? // TODO: should we have a project id field?
// invalidate current version if project changes // invalidate current version if project changes
+2 -25
View File
@@ -389,18 +389,8 @@ export const useEntryActions = () => {
// Return a context with the previous rundown // Return a context with the previous rundown
return { previousRundown }; return { previousRundown };
}, },
onSuccess: (response) => { onSettled: async () => {
if (!response.data) return; await queryClient.invalidateQueries({ queryKey: RUNDOWN });
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
}, },
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
@@ -531,19 +521,6 @@ export const useEntryActions = () => {
*/ */
const _applyDelayMutation = useMutation({ const _applyDelayMutation = useMutation({
mutationFn: requestApplyDelay, mutationFn: requestApplyDelay,
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
// Mutation finished, failed or successful // Mutation finished, failed or successful
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
@@ -2,38 +2,31 @@ import { useEffect, useState } from 'react';
import { throttle } from '../utils/throttle'; import { throttle } from '../utils/throttle';
const inactiveTime = 3000; // 3 seconds
/** export const useFadeOutOnInactivity = () => {
* Provides whether there has been mouse movement in the page in the last <inactiveTime> const [isMouseMoved, setIsMouseMoved] = useState(false);
*/
export const useFadeOutOnInactivity = (initialState = false) => {
const [isUserActive, setIsUserActive] = useState(initialState);
// show on mouse move // show on mouse move
useEffect(() => { useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null; let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => { const setShowMenuTrue = () => {
setIsUserActive(true); setIsMouseMoved(true);
if (fadeOut) { if (fadeOut) {
clearTimeout(fadeOut); clearTimeout(fadeOut);
} }
fadeOut = setTimeout(() => setIsUserActive(false), inactiveTime); fadeOut = setTimeout(() => setIsMouseMoved(false), 3000);
}; };
const throttledShowMenu = throttle(setShowMenuTrue, 1000); const throttledShowMenu = throttle(setShowMenuTrue, 1000);
document.addEventListener('mousemove', throttledShowMenu); document.addEventListener('mousemove', throttledShowMenu);
document.addEventListener('keydown', throttledShowMenu);
return () => { return () => {
document.removeEventListener('mousemove', throttledShowMenu); document.removeEventListener('mousemove', throttledShowMenu);
document.removeEventListener('keydown', throttledShowMenu);
if (fadeOut) { if (fadeOut) {
clearTimeout(fadeOut); clearTimeout(fadeOut);
} }
}; };
}, []); }, []);
return isUserActive; return isMouseMoved;
}; };
+1 -5
View File
@@ -164,15 +164,11 @@ export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset, offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
offsetMode: state.runtime.offsetMode, offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0, currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
actualStart: state.runtime.actualStart, actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart, plannedStart: state.runtime.plannedStart,
})); }));
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
currentDay: state.eventNow?.dayOffset ?? 0,
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({ export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset, offset: state.runtime.offset,
})); }));
@@ -8,5 +8,4 @@ export const projectDataPlaceholder: ProjectData = {
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
custom: [],
}; };
@@ -2,4 +2,4 @@
exports[`cx() > ignores falsy values 1`] = `""`; exports[`cx() > ignores falsy values 1`] = `""`;
exports[`cx() > merges styles 1`] = `"_test_d5d33f _another_d5d33f"`; exports[`cx() > merges styles 1`] = `"_test_98a1e0 _another_98a1e0"`;
@@ -28,7 +28,6 @@ describe('cloneEvent()', () => {
delay: 0, delay: 0,
dayOffset: 0, dayOffset: 0,
gap: 0, gap: 0,
triggers: [],
custom: { custom: {
lighting: '3', lighting: '3',
} as EntryCustomFields, } as EntryCustomFields,
@@ -37,7 +36,6 @@ describe('cloneEvent()', () => {
const cloned = cloneEvent(original); const cloned = cloneEvent(original);
expect(cloned).not.toBe(original); expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom); expect(cloned.custom).not.toBe(original.custom);
expect(cloned.triggers).not.toBe(original.triggers);
expect(cloned).toMatchObject({ expect(cloned).toMatchObject({
type: SupportedEntry.Event, type: SupportedEntry.Event,
@@ -61,8 +59,6 @@ describe('cloneEvent()', () => {
gap: 0, gap: 0,
timeWarning: original.timeWarning, timeWarning: original.timeWarning,
timeDanger: original.timeDanger, timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
}); });
}); });
}); });
+1 -2
View File
@@ -30,7 +30,6 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
gap: 0, gap: 0,
timeWarning: event.timeWarning, timeWarning: event.timeWarning,
timeDanger: event.timeDanger, timeDanger: event.timeDanger,
triggers: structuredClone(event.triggers), custom: { ...event.custom },
custom: structuredClone(event.custom),
}; };
}; };
+1 -1
View File
@@ -119,7 +119,7 @@ export function formatDuration(duration: number, hideSeconds = true): string {
} }
if (!hideSeconds) { if (!hideSeconds) {
const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND); const seconds = Math.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
if (seconds > 0) { if (seconds > 0) {
result += `${seconds}s`; result += `${seconds}s`;
} }
+4 -1
View File
@@ -49,7 +49,10 @@ function resolveUrl(protocol: 'http' | 'ws', path: string) {
url.pathname = baseURI ? `${baseURI}/${path}` : path; url.pathname = baseURI ? `${baseURI}/${path}` : path;
// in development mode, we use the React port for UI, but need the requests to target the server // in development mode, we use the React port for UI, but need the requests to target the server
// this is done with a proxy in the vite config to avoid CORS issues in the dev environment if (isDev) {
// this is used as a fallback port for development
url.port = '4001';
}
const result = url.toString(); const result = url.toString();
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5'; import { IoPencil, IoTrash } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react'; import { IconButton } from '@chakra-ui/react';
import { CustomField, CustomFieldKey } from 'ontime-types'; import { CustomField, CustomFieldLabel } from 'ontime-types';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch'; import Swatch from '../../../../../common/components/input/colour-input/Swatch';
@@ -17,8 +17,8 @@ interface CustomFieldEntryProps {
label: string; label: string;
fieldKey: string; fieldKey: string;
type: 'string' | 'image'; type: 'string' | 'image';
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>; onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
onDelete: (key: CustomFieldKey) => Promise<void>; onDelete: (label: CustomFieldLabel) => Promise<void>;
} }
export default function CustomFieldEntry(props: CustomFieldEntryProps) { export default function CustomFieldEntry(props: CustomFieldEntryProps) {
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { CustomField, CustomFieldKey } from 'ontime-types'; import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields'; import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
import Info from '../../../../../common/components/info/Info'; import Info from '../../../../../common/components/info/Info';
@@ -31,14 +31,14 @@ export default function CustomFields() {
setIsAdding(false); setIsAdding(false);
}; };
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => { const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
await editCustomField(key, customField); await editCustomField(label, customField);
refetch(); refetch();
}; };
const handleDelete = async (key: CustomFieldKey) => { const handleDelete = async (label: string) => {
try { try {
await deleteCustomField(key); await deleteCustomField(label);
refetch(); refetch();
} catch (_error) { } catch (_error) {
/** we do not handle errors here */ /** we do not handle errors here */
@@ -8,5 +8,7 @@
} }
.column { .column {
align-items: start;
display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -123,7 +123,7 @@ export default function ViewSettingsForm() {
onClick={onCodeEditorOpen} onClick={onCodeEditorOpen}
variant='ontime-subtle' variant='ontime-subtle'
size='sm' size='sm'
isDisabled={isSubmitting} isDisabled={!data.overrideStyles}
width='fit-content' width='fit-content'
> >
Edit CSS override Edit CSS override
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@@ -24,7 +23,6 @@ type ProjectCreateFormValues = {
publicUrl?: string; publicUrl?: string;
backstageInfo?: string; backstageInfo?: string;
backstageUrl?: string; backstageUrl?: string;
custom?: { title: string; value: string }[];
}; };
export default function ProjectCreateForm(props: ProjectCreateFromProps) { export default function ProjectCreateForm(props: ProjectCreateFromProps) {
@@ -36,7 +34,6 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
control,
formState: { isSubmitting, isValid }, formState: { isSubmitting, isValid },
setFocus, setFocus,
} = useForm<ProjectCreateFormValues>({ } = useForm<ProjectCreateFormValues>({
@@ -47,11 +44,6 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
}, },
}); });
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// set focus to first field // set focus to first field
useEffect(() => { useEffect(() => {
setFocus('title'); setFocus('title');
@@ -67,7 +59,6 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
...values, ...values,
filename, filename,
}); });
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST }); await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -75,10 +66,6 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
} }
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
return ( return (
<Panel.Section <Panel.Section
as='form' as='form'
@@ -164,42 +151,6 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
<Panel.Section>
<Panel.ListItem>
<Panel.Field title='Custom data' description='Add custom data for your project' />
<Button variant='ontime-subtle' onClick={handleAddCustom}>
+
</Button>
</Panel.ListItem>
{fields.map((field, idx) => (
<div key={field.id} className={style.customDataItem}>
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
placeholder={field.title}
autoComplete='off'
{...register(`custom.${idx}.title` as const)}
/>
</label>
<label>
Value
<Input
variant='ontime-filled'
size='sm'
placeholder={field.value}
autoComplete='off'
{...register(`custom.${idx}.value` as const)}
/>
</label>
<Button variant='ontime-ghosted' onClick={() => remove(idx)}>
<IoTrash />
</Button>
</div>
))}
</Panel.Section>
</Panel.Section> </Panel.Section>
</Panel.Section> </Panel.Section>
); );
@@ -1,6 +1,6 @@
import { ChangeEvent, useEffect, useRef } from 'react'; import { ChangeEvent, useEffect, useRef } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5'; import { IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { type ProjectData } from 'ontime-types'; import { type ProjectData } from 'ontime-types';
@@ -25,7 +25,6 @@ export default function ProjectData() {
formState: { isSubmitting, isValid, isDirty, errors }, formState: { isSubmitting, isValid, isDirty, errors },
setError, setError,
watch, watch,
control,
setValue, setValue,
} = useForm({ } = useForm({
defaultValues: data, defaultValues: data,
@@ -33,12 +32,6 @@ export default function ProjectData() {
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
mode: 'onChange',
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
}); });
// reset form values if data changes // reset form values if data changes
@@ -84,10 +77,6 @@ export default function ProjectData() {
}); });
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
const onSubmit = async (formData: ProjectData) => { const onSubmit = async (formData: ProjectData) => {
try { try {
await postProjectData(formData); await postProjectData(formData);
@@ -242,69 +231,6 @@ export default function ProjectData() {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
<Panel.Section style={{ marginTop: 0 }}>
<Panel.ListItem>
<Panel.Field title='Custom data' description='' />
<Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
Add
</Button>
</Panel.ListItem>
{fields.length > 0 &&
fields.map((field, idx) => {
const rowErrors = errors.custom?.[idx] as
| {
title?: { message?: string };
value?: { message?: string };
}
| undefined;
return (
<div key={field.id} className={style.customDataItem}>
<div>
<div className={style.titleRow}>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
defaultValue={field.title}
placeholder='Title of your custom data'
autoComplete='off'
{...register(`custom.${idx}.title`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
</label>
<Button
size='sm'
variant='ontime-subtle'
color='#FA5656' // $red-500
onClick={() => remove(idx)}
leftIcon={<IoTrash />}
>
Delete Entry
</Button>
</div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
</div>
<label>
Value
<Textarea
variant='ontime-filled'
resize='none'
size='sm'
defaultValue={field.value}
autoComplete='off'
placeholder='Text of your custom data'
{...register(`custom.${idx}.value`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
</label>
</div>
);
})}
</Panel.Section>
</Panel.Section> </Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
@@ -57,18 +57,3 @@
height: auto; height: auto;
} }
} }
.customDataItem {
display: contents;
width: 100%;
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
@@ -118,8 +118,7 @@
padding-inline: 0.25rem; padding-inline: 0.25rem;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
// allow multi-line text but trim before white-space: pre;
white-space: pre-line;
} }
} }
@@ -78,7 +78,7 @@ function OperatorEvent(props: OperatorEventProps) {
<span className={style.mainField}>{main}</span> <span className={style.mainField}>{main}</span>
<span className={style.schedule}> <span className={style.schedule}>
<ClockTime value={timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' /> <ClockTime value={timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
-
<ClockTime value={timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' /> <ClockTime value={timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
</span> </span>
@@ -36,8 +36,8 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
}, },
{ {
id: 'subscribe', id: 'subscribe',
title: 'Highlight Fields', title: 'Highlight Field',
description: 'Choose custom fields to highlight', description: 'Choose a custom field to highlight',
type: 'multi-option', type: 'multi-option',
values: customFieldSelect, values: customFieldSelect,
}, },
@@ -45,10 +45,6 @@
@include ellipsis-overflow; @include ellipsis-overflow;
} }
.offset {
color: $muted-gray;
}
.ahead { .ahead {
color: $playback-ahead; color: $playback-ahead;
} }
@@ -1,5 +1,4 @@
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react'; import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
@@ -164,10 +163,10 @@ function ProgressOverview() {
} }
function RuntimeOverview() { function RuntimeOverview() {
const { clock, offset, playback } = useRuntimePlaybackOverview(); const { clock, offset } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset); const offsetText = getOffsetText(offset);
const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]); const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead;
return ( return (
<> <>
@@ -1,5 +1,5 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { OntimeEvent } from 'ontime-types'; import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
@@ -14,8 +14,7 @@ import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss'; import style from './EventEditor.module.scss';
// any of the titles + custom field labels export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
interface EventEditorProps { interface EventEditorProps {
event: OntimeEvent; event: OntimeEvent;
@@ -14,12 +14,12 @@ import style from './EventEditorTriggers.module.scss';
interface EventEditorTriggersProps { interface EventEditorTriggersProps {
eventId: string; eventId: string;
triggers: Trigger[]; triggers?: Trigger[];
} }
export default function EventEditorTriggers(props: EventEditorTriggersProps) { export default function EventEditorTriggers(props: EventEditorTriggersProps) {
const { triggers, eventId } = props; const { triggers, eventId } = props;
const showTriggers = triggers.length > 0; const showTriggers = triggers !== undefined && triggers.length > 0;
return ( return (
<> <>
@@ -81,7 +81,7 @@ export function getFormattedTimer(
localisedMinutes: string, localisedMinutes: string,
options: FormattingOptions, options: FormattingOptions,
): string { ): string {
if (timer == null || timerType === TimerType.None) { if (timer == null) {
return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder; return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder;
} }
@@ -98,7 +98,7 @@ export function getFormattedTimer(
} }
} }
let display = millisToString(timeToParse, { direction: timerType }); let display = millisToString(timeToParse);
if (options.removeLeadingZero) { if (options.removeLeadingZero) {
display = removeLeadingZero(display); display = removeLeadingZero(display);
} }
@@ -20,7 +20,7 @@ import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getFormattedTimer, isStringBoolean } from '../common/viewUtils'; import { getFormattedTimer, isStringBoolean } from '../common/viewUtils';
import { getSubscriptionDisplayData, getTimerItems, TimerMessage } from '../../../views/countdown/countdown.utils'; import { fetchTimerData, getTimerItems, TimerMessage } from './countdown.helpers';
import { getCountdownOptions } from './countdown.options'; import { getCountdownOptions } from './countdown.options';
import CountdownSelect from './CountdownSelect'; import CountdownSelect from './CountdownSelect';
@@ -78,7 +78,7 @@ export default function Countdown(props: CountdownProps) {
} }
}, [backstageEvents, searchParams]); }, [backstageEvents, searchParams]);
const { message: runningMessage, timer: runningTimer } = getSubscriptionDisplayData(time, follow, selectedId, runtime.offset); const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id; const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
const finished = time.phase === TimerPhase.Overtime; const finished = time.phase === TimerPhase.Overtime;
@@ -4,7 +4,8 @@ import { OntimeEntry, OntimeEvent, SupportedEntry } from 'ontime-types';
import Empty from '../../../common/components/state/Empty'; import Empty from '../../../common/components/state/Empty';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import { sanitiseTitle } from '../../../views/countdown/countdown.utils';
import { sanitiseTitle } from './countdown.helpers';
import './Countdown.scss'; import './Countdown.scss';
@@ -1,6 +1,6 @@
import { dayInMs } from 'ontime-utils'; import { dayInMs } from 'ontime-utils';
import { getSubscriptionDisplayData, sanitiseTitle, TimerMessage } from '../../../../views/countdown/countdown.utils'; import { fetchTimerData, sanitiseTitle, TimerMessage } from '../countdown.helpers';
describe('sanitiseTitle() function', () => { describe('sanitiseTitle() function', () => {
it('should return a title when valid', () => { it('should return a title when valid', () => {
@@ -26,7 +26,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: followId }; const follow = { id: followId };
const time = { current: currentMockValue }; const time = { current: currentMockValue };
const { message, timer } = getSubscriptionDisplayData(time, follow, followId); const { message, timer } = fetchTimerData(time, follow, followId);
expect(message).toBe(TimerMessage.running); expect(message).toBe(TimerMessage.running);
expect(timer).toBe(currentMockValue); expect(timer).toBe(currentMockValue);
}); });
@@ -37,7 +37,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: 'anotherevent', timeStart: startMockValue }; const follow = { id: 'anotherevent', timeStart: startMockValue };
const time = { clock: timeNow }; const time = { clock: timeNow };
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent'); const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.toStart); expect(message).toBe(TimerMessage.toStart);
expect(timer).toBe(startMockValue - timeNow); expect(timer).toBe(startMockValue - timeNow);
}); });
@@ -50,7 +50,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue }; const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: endMockValue - startMockValue }; const time = { clock: timeNow, current: endMockValue - startMockValue };
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent'); const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.waiting); expect(message).toBe(TimerMessage.waiting);
expect(timer).toBe(endMockValue - startMockValue); expect(timer).toBe(endMockValue - startMockValue);
}); });
@@ -63,7 +63,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue }; const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: endMockValue - startMockValue }; const time = { clock: timeNow, current: endMockValue - startMockValue };
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent'); const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.ended); expect(message).toBe(TimerMessage.ended);
expect(timer).toBe(endMockValue); expect(timer).toBe(endMockValue);
}); });
@@ -76,7 +76,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue }; const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue }; const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent'); const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.waiting); expect(message).toBe(TimerMessage.waiting);
expect(timer).toBe(dayInMs + endMockValue - startMockValue); expect(timer).toBe(dayInMs + endMockValue - startMockValue);
}); });
@@ -89,7 +89,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue }; const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue }; const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
const { message, timer } = getSubscriptionDisplayData(time, follow, followId); const { message, timer } = fetchTimerData(time, follow, followId);
expect(message).toBe(TimerMessage.running); expect(message).toBe(TimerMessage.running);
expect(timer).toBe(dayInMs + endMockValue - startMockValue); expect(timer).toBe(dayInMs + endMockValue - startMockValue);
}); });
@@ -102,7 +102,7 @@ describe('fetchTimerData() function', () => {
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue }; const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue }; const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent'); const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.toStart); expect(message).toBe(TimerMessage.toStart);
expect(timer).toBe(startMockValue - timeNow); expect(timer).toBe(startMockValue - timeNow);
}); });
@@ -0,0 +1,106 @@
import { OntimeEvent, Playback } from 'ontime-types';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { isStringBoolean } from '../common/viewUtils';
export enum TimerMessage {
toStart = 'to_start',
waiting = 'waiting',
running = 'running',
ended = 'ended',
unhandled = '',
}
/**
* Parses string as a title
*/
export const sanitiseTitle = (title: string | null) => (title ? title : '{no title}');
/**
* Returns a parsed timer and relevant status message
*/
export const fetchTimerData = (
time: ViewExtendedTimer,
follow: OntimeEvent | null,
selectedId: string | null,
offset: number,
): { message: TimerMessage; timer: number } => {
if (follow === null) {
return { message: TimerMessage.unhandled, timer: 0 };
}
if (selectedId === follow.id) {
// if it is selected, it may not be running
return {
message: time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running,
timer: time.current ?? 0,
};
}
const showProjected = getShouldShowProjected();
const addedTime = showProjected ? offset : 0;
if (time.clock < follow.timeStart) {
// if it hasnt started, we count to start
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
}
if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
// if it has started, we show running timer
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
}
// running timer timer is not the one we are following
// ends day after
if (follow.timeStart > follow.timeEnd) {
if (follow.timeStart > time.clock) {
// if it hasnt started, we count to start
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
}
if (follow.timeStart <= time.clock) {
// if it has started, we show running timer
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
}
// if it has ended, we show how long ago
return { message: TimerMessage.ended, timer: follow.timeEnd };
}
// if it has ended, we show how long ago
return { message: TimerMessage.ended, timer: follow.timeEnd };
};
/**
* Gets values for the timer items
*/
export function getTimerItems(start: number | undefined, end: number | undefined, delay: number, offset: number) {
if (start == null || end == null) {
return {
scheduledStart: '',
scheduledEnd: '',
projectedStart: '',
projectedEnd: '',
};
}
const showProjected = getShouldShowProjected();
const scheduledStart = formatTime(start + delay);
const scheduledEnd = formatTime(end + delay);
const projectedStart = showProjected ? formatTime(start + delay - offset) : '';
const projectedEnd = showProjected ? formatTime(end + delay - offset) : '';
return {
scheduledStart,
scheduledEnd,
projectedStart,
projectedEnd,
};
}
/**
* Gets from the URL whether the showProjected option is active
*/
function getShouldShowProjected() {
const params = new URL(document.location.href).searchParams;
return isStringBoolean(params.get('showProjected'));
}
+1 -1
View File
@@ -8,7 +8,7 @@ $component-border-radius-sm: 2px;
$component-border-radius-full: 99px; $component-border-radius-full: 99px;
// semantic colours // semantic colours
$action-blue: $blue-500; $action-blue: #3182ce;
$action-text-color: $blue-400; $action-text-color: $blue-400;
$ontime-color: #ff7597; $ontime-color: #ff7597;
$error-red: $red-500; $error-red: $red-500;
+3 -3
View File
@@ -13,7 +13,7 @@ $timer-label-size: clamp(12px, 1.25vw, 20px);
$base-font-size: clamp(15px, 1.5vw, 28px); $base-font-size: clamp(15px, 1.5vw, 28px);
$title-font-size: clamp(18px, 2.25vw, 42px); $title-font-size: clamp(18px, 2.25vw, 42px);
$timer-value-size: clamp(24px, 2.5vw, 48px); $timer-value-size: clamp(24px, 2.5vw, 48px);
$header-font-size: clamp(24px, 2.5vw, 48px); $header-font-size: clamp(28px, 3.5vw, 64px);
// General styling // General styling
$accent-color: $red-500; // --accent-color-override $accent-color: $red-500; // --accent-color-override
@@ -24,8 +24,8 @@ $viewer-label-color: rgba(white, 25%);
$viewer-background-color: $ui-black; // --background-color-override $viewer-background-color: $ui-black; // --background-color-override
$viewer-color: rgba(white, 80%); // --color-override $viewer-color: rgba(white, 80%); // --color-override
$viewer-secondary-color: rgba(white, 45%); // --secondary-color-override $viewer-secondary-color: rgba(white, 45%); // --secondary-color-override
$viewer-card-bg-color: rgba(white, 5%); // --card-background-color-override $viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
$element-border-radius: 4px; $element-border-radius: 8px;
// Generic element sizes // Generic element sizes
$view-element-gap: min(2vh, 16px); $view-element-gap: min(2vh, 16px);
+4 -4
View File
@@ -10,12 +10,12 @@ export const ontimeCheckboxOnDark = {
opacity: 0.6, opacity: 0.6,
}, },
_checked: { _checked: {
borderColor: '#578AF4', // $blue-500 borderColor: '#3182ce', // $action-blue
backgroundColor: '#578AF4', // $blue-500 backgroundColor: '#3182ce', //$action-blue
_disabled: { _disabled: {
color: 'white', color: 'white',
borderColor: '#578AF4', // $blue-500 borderColor: '#3182ce', // $action-blue
backgroundColor: '#578AF4', // $blue-500 backgroundColor: '#3182ce', //$action-blue
opacity: 0.6, opacity: 0.6,
}, },
}, },
-1
View File
@@ -27,7 +27,6 @@ export const ontimeModal = {
footer: { footer: {
padding: '1rem', padding: '1rem',
display: 'flex', display: 'flex',
alignItems: 'left',
gap: '0.5rem', gap: '0.5rem',
}, },
}; };
+4 -4
View File
@@ -25,12 +25,12 @@ export const ontimeBlockRadio = {
backgroundColor: '#262626', // $gray-1200 backgroundColor: '#262626', // $gray-1200
_checked: { _checked: {
borderColor: '#262626', // $gray-1200 borderColor: '#262626', // $gray-1200
color: '#578AF4', // $blue-500 color: '#3182ce', // $action-blue
backgroundColor: '#578AF4', // $blue-500 backgroundColor: '#3182ce', // $action-blue
}, },
_hover: { _hover: {
color: '#578AF4', // $blue-500 color: '#3182ce', // $action-blue
backgroundColor: '#578AF4', // $blue-500 backgroundColor: '#3182ce', // $action-blue
outline: 'none', outline: 'none',
}, },
}, },
@@ -28,10 +28,8 @@ const translationsList = {
zh: langZhCn, zh: langZhCn,
}; };
export type TranslationKey = keyof typeof langEn;
interface TranslationContextValue { interface TranslationContextValue {
getLocalizedString: (key: TranslationKey, lang?: string) => string; getLocalizedString: (key: keyof typeof langEn, lang?: string) => string;
} }
const TranslationContext = createContext<TranslationContextValue>({ const TranslationContext = createContext<TranslationContextValue>({
@@ -42,7 +40,7 @@ export const TranslationProvider = ({ children }: PropsWithChildren) => {
const { data } = useSettings(); const { data } = useSettings();
const getLocalizedString = useCallback( const getLocalizedString = useCallback(
(key: TranslationKey, lang = data?.language || 'en'): string => { (key: keyof typeof langEn, lang = data?.language || 'en'): string => {
if (lang in translationsList) { if (lang in translationsList) {
if (key in translationsList[lang as keyof typeof translationsList]) { if (key in translationsList[lang as keyof typeof translationsList]) {
return translationsList[lang as keyof typeof translationsList][key]; return translationsList[lang as keyof typeof translationsList][key];
@@ -111,7 +111,7 @@ export default function Backstage(props: BackstageProps) {
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'> <div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<ViewParamsEditor viewOptions={backstageOptions} /> <ViewParamsEditor viewOptions={backstageOptions} />
<div className='project-header'> <div className='project-header'>
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />} {general?.projectLogo ? <ViewLogo name={general.projectLogo} className='logo' /> : <div className='logo' />}
<div className='title'>{general.title}</div> <div className='title'>{general.title}</div>
<div className='clock-container'> <div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div> <div className='label'>{getLocalizedString('common.time_now')}</div>
@@ -176,7 +176,7 @@ export default function Backstage(props: BackstageProps) {
)} )}
</div> </div>
{showSchedule && <ScheduleExport selectedId={selectedId} />} {showSchedule && <ScheduleExport selectedId={selectedId} isBackstage />}
<div className={cx(['info', !showSchedule && 'info--stretch'])}> <div className={cx(['info', !showSchedule && 'info--stretch'])}>
{general.backstageUrl && <QRCode value={general.backstageUrl} size={qrSize} level='L' className='qr' />} {general.backstageUrl && <QRCode value={general.backstageUrl} size={qrSize} level='L' className='qr' />}
@@ -1,16 +1,18 @@
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { getScheduledTimes } from './schedule.utils';
import { useSchedule } from './ScheduleContext'; import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem'; import ScheduleItem from './ScheduleItem';
import './Schedule.scss'; import './Schedule.scss';
interface ScheduleProps { interface ScheduleProps {
isProduction?: boolean;
className?: string; className?: string;
} }
export default function Schedule({ className }: ScheduleProps) { export default function Schedule({ isProduction, className }: ScheduleProps) {
const { events, containerRef } = useSchedule(); const { events, isBackstage, containerRef } = useSchedule();
if (events?.length < 1) { if (events?.length < 1) {
return null; return null;
@@ -19,15 +21,18 @@ export default function Schedule({ className }: ScheduleProps) {
return ( return (
<ul className={cx(['schedule', className])} ref={containerRef}> <ul className={cx(['schedule', className])} ref={containerRef}>
{events.map((event) => { {events.map((event) => {
const { timeStart, timeEnd, delay } = getScheduledTimes(event, isProduction);
return ( return (
<ScheduleItem <ScheduleItem
key={event.id} key={event.id}
timeStart={event.timeStart} timeStart={timeStart}
timeEnd={event.timeEnd} timeEnd={timeEnd}
title={event.title} title={event.title}
colour={event.colour} colour={isBackstage ? event.colour : undefined}
backstageEvent={!event.isPublic}
skip={event.skip} skip={event.skip}
delay={event.skip ? 0 : event.delay} delay={delay}
/> />
); );
})} })}
@@ -7,15 +7,16 @@ import ScheduleNav from './ScheduleNav';
interface ScheduleExportProps { interface ScheduleExportProps {
selectedId: MaybeString; selectedId: MaybeString;
isBackstage?: boolean;
} }
export default memo(ScheduleExport); export default memo(ScheduleExport);
function ScheduleExport(props: ScheduleExportProps) { function ScheduleExport(props: ScheduleExportProps) {
const { selectedId } = props; const { selectedId, isBackstage } = props;
return ( return (
<ScheduleProvider selectedEventId={selectedId}> <ScheduleProvider selectedEventId={selectedId} isBackstage={isBackstage}>
<ScheduleNav className='schedule-nav-container' /> <ScheduleNav className='schedule-nav-container' />
<Schedule className='schedule-container' /> <Schedule isProduction={isBackstage} className='schedule-container' />
</ScheduleProvider> </ScheduleProvider>
); );
} }
@@ -16,13 +16,14 @@ interface ScheduleItemProps {
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
title: string; title: string;
backstageEvent: boolean;
colour?: string; colour?: string;
skip?: boolean; skip?: boolean;
delay: number; delay: number;
} }
export default function ScheduleItem(props: ScheduleItemProps) { export default function ScheduleItem(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props; const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props;
const { showProjected } = useScheduleOptions(); const { showProjected } = useScheduleOptions();
if (showProjected) { if (showProjected) {
@@ -32,6 +33,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
timeEnd={timeEnd} timeEnd={timeEnd}
title={title} title={title}
colour={colour} colour={colour}
backstageEvent={backstageEvent}
skip={skip} skip={skip}
delay={delay} delay={delay}
/> />
@@ -45,6 +47,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
timeEnd={timeEnd} timeEnd={timeEnd}
title={title} title={title}
colour={colour} colour={colour}
backstageEvent={backstageEvent}
skip={skip} skip={skip}
delay={delay} delay={delay}
/> />
@@ -60,6 +63,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
<SuperscriptTime time={start} /> <SuperscriptTime time={start} />
<SuperscriptTime time={end} /> <SuperscriptTime time={end} />
{backstageEvent && '*'}
</div> </div>
<div className='entry-title'>{title}</div> <div className='entry-title'>{title}</div>
</li> </li>
@@ -67,7 +71,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
} }
function DelayedScheduleItem(props: ScheduleItemProps) { function DelayedScheduleItem(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props; const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props;
const start = formatTime(timeStart, formatOptions); const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions); const end = formatTime(timeEnd, formatOptions);
@@ -82,11 +86,13 @@ function DelayedScheduleItem(props: ScheduleItemProps) {
<SuperscriptTime time={start} /> <SuperscriptTime time={start} />
<SuperscriptTime time={end} /> <SuperscriptTime time={end} />
{backstageEvent && '*'}
</span> </span>
<span className='entry-times--delay'> <span className='entry-times--delay'>
<SuperscriptTime time={delayedStart} /> <SuperscriptTime time={delayedStart} />
<SuperscriptTime time={delayedEnd} /> <SuperscriptTime time={delayedEnd} />
{backstageEvent && '*'}
</span> </span>
</div> </div>
<div className='entry-title'>{title}</div> <div className='entry-title'>{title}</div>
@@ -95,7 +101,7 @@ function DelayedScheduleItem(props: ScheduleItemProps) {
} }
function ProjectedScheduleItem(props: ScheduleItemProps) { function ProjectedScheduleItem(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, colour, skip, delay } = props; const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props;
return ( return (
<li className={cx(['entry', skip && 'entry--skip'])}> <li className={cx(['entry', skip && 'entry--skip'])}>
@@ -104,6 +110,7 @@ function ProjectedScheduleItem(props: ScheduleItemProps) {
<ProjectedTime time={timeStart} delay={delay} /> <ProjectedTime time={timeStart} delay={delay} />
<ProjectedTime time={timeEnd} delay={delay} /> <ProjectedTime time={timeEnd} delay={delay} />
{backstageEvent && '*'}
</div> </div>
<div className='entry-title'>{title}</div> <div className='entry-title'>{title}</div>
</li> </li>
@@ -19,14 +19,14 @@ export const scheduleOptions: ViewOption = {
{ {
id: 'cycleInterval', id: 'cycleInterval',
title: 'Cycle interval', title: 'Cycle interval',
description: 'How long (in seconds) should each schedule page be shown', description: 'How long (in seconds) should each schedule page be shown.',
type: 'number', type: 'number',
defaultValue: 10, defaultValue: 10,
}, },
{ {
id: 'showProjected', id: 'showProjected',
title: 'Show projected time', title: 'Show projected time',
description: 'Whether scheduled times should account for runtime offset', description: 'Whether scheduled times should account for runtime offset.',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
@@ -0,0 +1,19 @@
import { OntimeEvent } from 'ontime-types';
/**
* Gather rules for how to present scheduled times
*/
export function getScheduledTimes(event: OntimeEvent, isProduction?: boolean) {
if (isProduction) {
return {
timeStart: event.timeStart,
timeEnd: event.timeEnd,
delay: event.skip ? 0 : event.delay,
};
}
return {
timeStart: event.timeStart,
timeEnd: event.timeEnd,
delay: 0,
};
}
@@ -1,197 +0,0 @@
@use '../../theme/viewerDefs' as *;
$item-height: 3.5rem;
.countdown {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
display: flex;
flex-direction: column;
gap: $view-element-gap;
padding: $view-outer-padding;
font-size: $base-font-size;
/* =================== HEADER + EXTRAS ===================*/
.project-header {
font-size: $header-font-size;
font-weight: 600;
display: flex;
gap: 1rem;
}
.logo {
max-width: min(200px, 20vw);
}
.title {
line-height: 1.1em;
}
.clock-container {
margin-left: auto;
font-weight: 600;
.label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
}
.fab-container {
position: fixed;
bottom: 1.5rem;
bottom: calc(1.5rem + env(safe-area-inset-bottom));
right: $view-inline-padding;
display: flex;
justify-content: end;
gap: 2rem;
transition-property: opacity;
transition-duration: 0.3s;
}
.fab-container--hidden {
opacity: 0;
pointer-events: none;
}
/* ========================= LIST ========================*/
.empty-container {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
button {
margin-top: 2rem;
}
}
.list-container {
display: flex;
flex-direction: column;
overflow-y: auto;
}
/* ====================== LIST-ITEM ======================*/
.sub {
margin: 2px;
flex: 1;
display: grid;
grid-template-columns: 1rem 1fr auto;
grid-template-areas:
'binder schedule status'
'binder title timer'
'binder secondary secondary';
column-gap: 1rem;
background: $viewer-card-bg-color;
padding-right: 1rem;
border-radius: $element-border-radius;
overflow: clip;
&:hover {
.sub__label {
color: $ui-white;
}
}
}
.sub--selected {
background: $blue-700;
}
.sub--live {
background-color: $green-700;
}
.sub__binder {
background: var(--user-color, $viewer-card-bg-color);
grid-area: binder;
}
.sub__schedule {
grid-area: schedule;
padding-top: 0.5rem;
display: flex;
gap: 0.25em;
font-size: $timer-label-size;
}
.sub__schedule--delayed {
color: $delay-color;
}
.sub__schedule--ahead {
color: $green-500;
}
.sub__schedule--behind {
color: $orange-500;
}
.sub__title {
grid-area: title;
padding-bottom: 0.5rem;
font-size: $title-font-size;
line-height: 1.1em;
}
.sub__secondary {
grid-area: secondary;
padding-bottom: 0.5rem;
font-size: $base-font-size;
line-height: 1.1em;
// allow multi-line text but trim before
white-space: pre-line;
}
.sub__status {
grid-area: status;
padding-top: 0.5rem;
font-size: $timer-label-size;
text-align: right;
}
.sub__label {
grid-area: status;
padding-top: 0.5rem;
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-align: right;
}
.sub__timer {
grid-area: timer;
font-size: $timer-value-size;
line-height: 1.1em;
font-weight: 600;
text-align: right;
}
/* ====================== MODIFIERS ======================*/
.subdued {
opacity: 0.6;
}
}
@@ -1,125 +0,0 @@
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import {
CustomFields,
EntryId,
isOntimeEvent,
isPlayableEvent,
OntimeEvent,
ProjectData,
Settings,
} from 'ontime-types';
import Button from '../../common/components/buttons/Button';
import Empty from '../../common/components/state/Empty';
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 SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { useTranslation } from '../../translation/TranslationProvider';
import { getCountdownOptions, useCountdownOptions } from './countdown.options';
import CountdownSelect from './CountdownSelect';
import CountdownSubscriptions from './CountdownSubscriptions';
import './Countdown.scss';
interface CountdownProps {
backstageEvents: OntimeEvent[];
customFields: CustomFields;
general: ProjectData;
time: ViewExtendedTimer;
isMirrored: boolean;
selectedId: EntryId | null;
settings: Settings | undefined;
}
export default function Countdown(props: CountdownProps) {
const { backstageEvents, customFields, general, time, isMirrored, selectedId, settings } = props;
const { getLocalizedString } = useTranslation();
const { subscriptions } = useCountdownOptions();
const [editMode, setEditMode] = useState(false);
useWindowTitle('Countdown');
// gather rundown data
const playableEvents = backstageEvents.filter((event) => isOntimeEvent(event) && isPlayableEvent(event));
// gather timer data
const clock = formatTime(time.clock);
// gather presentation data
const hasEvents = playableEvents.length > 0;
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const countdownOptions = getCountdownOptions(defaultFormat, customFields);
return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<ViewParamsEditor viewOptions={countdownOptions} />
<div className='project-header'>
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
<div className='title'>{general.title}</div>
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='time' />
</div>
</div>
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
{hasEvents && editMode && (
<CountdownSelect events={playableEvents} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} />
)}
{hasEvents && !editMode && (
<CountdownContents
playableEvents={playableEvents}
subscriptions={subscriptions}
time={time}
goToEditMode={() => setEditMode(true)}
selectedId={selectedId}
/>
)}
</div>
);
}
interface CountdownContentsProps {
playableEvents: OntimeEvent[];
selectedId: EntryId | null;
subscriptions: EntryId[];
time: ViewExtendedTimer;
goToEditMode: () => void;
}
function CountdownContents(props: CountdownContentsProps) {
const { playableEvents, selectedId, subscriptions, time, goToEditMode } = props;
const { getLocalizedString } = useTranslation();
if (subscriptions.length === 0) {
return (
<div className='empty-container'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-container' />
<Button variant='primary' size='large' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
</div>
);
}
return (
<CountdownSubscriptions
events={playableEvents}
selectedId={selectedId}
subscriptions={subscriptions}
time={time}
goToEditMode={goToEditMode}
/>
);
}
@@ -1,96 +0,0 @@
import { useState } from 'react';
import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom';
import { EntryId, OntimeEvent } from 'ontime-types';
import Button from '../../common/components/buttons/Button';
import { cx } from '../../common/utils/styleUtils';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
import { makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss';
interface CountdownSelectProps {
events: OntimeEvent[];
subscriptions: EntryId[];
disableEdit: () => void;
}
export default function CountdownSelect(props: CountdownSelectProps) {
const { events, subscriptions, disableEdit } = props;
const [selected, setSelected] = useState<EntryId[]>(subscriptions);
const navigate = useNavigate();
/**
* Toggles an entry from the selected set
*/
const toggleSelect = (entryId: EntryId) => {
setSelected((prev) => {
if (prev.includes(entryId)) {
// If the entry is already selected, remove it
return prev.filter((id) => id !== entryId);
}
return [...prev, entryId];
});
};
/**
* Creates a URL with the selected subscriptions
* and navigates to it
*/
const applySelection = () => {
const url = makeSubscriptionsUrl(window.location.href, selected);
disableEdit();
navigate(url.search.toString());
};
// make a copy of the selected array for quick lookup
const selectedIds = new Set(selected);
return (
<div className='list-container'>
{events.map((event: OntimeEvent, index: number) => {
const title = event.title || '{no title}';
const isSelected = selectedIds.has(event.id);
return (
<div
key={index}
role='button'
tabIndex={0}
onClick={() => toggleSelect(event.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
toggleSelect(event.id);
e.stopPropagation();
}
}}
className={cx(['sub', isSelected && 'sub--selected'])}
>
<div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} />
<div className='sub__schedule'>
<ClockTime value={event.timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
<ClockTime value={event.timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
</div>
<div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div>
<div className='sub__title'>{title}</div>
</div>
);
})}
<div className='fab-container'>
<Button variant='subtle' size='large' onClick={disableEdit}>
<IoArrowBack /> Go back
</Button>
<Button variant='subtle' size='large' onClick={() => setSelected([])} disabled={selected.length === 0}>
<IoClose /> Clear
</Button>
<Button variant='primary' size='large' disabled={events.length < 1} onClick={applySelection}>
<IoSaveOutline /> Save
</Button>
</div>
</div>
);
}
@@ -1,160 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5';
import { EntryId, OntimeEvent, Playback } from 'ontime-types';
import Button from '../../common/components/buttons/Button';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useCurrentDay, useRuntimeOffset } from '../../common/hooks/useSocket';
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
import { cx } from '../../common/utils/styleUtils';
import FollowButton from '../../features/operator/follow-button/FollowButton';
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
import { useTranslation } from '../../translation/TranslationProvider';
import { useCountdownOptions } from './countdown.options';
import { getOrderedSubscriptions, getSubscriptionDisplayData, sanitiseTitle, timerProgress } from './countdown.utils';
import './Countdown.scss';
interface CountdownSubscriptionsProps {
events: OntimeEvent[];
selectedId: EntryId | null;
subscriptions: EntryId[];
time: ViewExtendedTimer;
goToEditMode: () => void;
}
export default function CountdownSubscriptions(props: CountdownSubscriptionsProps) {
const { time, events, selectedId, goToEditMode } = props;
const { followSelected, secondarySource, subscriptions, showProjected } = useCountdownOptions();
const showFab = useFadeOutOnInactivity(true);
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const scrollToComponent = useFollowComponent({
followRef: selectedRef,
scrollRef,
doFollow: !lockAutoScroll,
topOffset: 0,
});
// reset scroll if nothing is selected
useEffect(() => {
if (!selectedId) {
if (!lockAutoScroll) {
scrollRef.current?.scrollTo(0, 0);
}
}
}, [selectedId, lockAutoScroll, scrollRef]);
// scroll to component if user clicks the Follow button
const handleOffset = () => {
if (selectedId) {
scrollToComponent();
}
setLockAutoScroll(false);
};
// gather data
const subscribedEvents = getOrderedSubscriptions(subscriptions, events);
return (
<div className='list-container'>
{subscribedEvents.map((event) => {
const secondaryData = getPropertyValue(event, secondarySource);
const isLive = event.id === selectedId && time.playback !== Playback.Armed;
return (
<div key={event.id} className={cx(['sub', isLive && 'sub--live'])}>
<div className='sub__binder' style={{ '--user-color': event.colour }} />
<div className={cx(['sub__schedule', event.delay > 0 && 'sub__schedule--delayed'])}>
{showProjected ? (
<ProjectedSchedule timeStart={event.timeStart} timeEnd={event.timeEnd} delay={event.delay} />
) : (
<>
<ClockTime value={event.timeStart + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
<ClockTime value={event.timeEnd + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
</>
)}
</div>
<SubscriptionStatus key={event.id} event={event} selectedId={selectedId} time={time} />
<div className={cx(['sub__title', !event.title && 'subdued'])}>{sanitiseTitle(event.title)}</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
})}
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
<Button variant='primary' size='large' disabled={events.length < 1} onClick={goToEditMode}>
<IoPencil /> Edit
</Button>
</div>
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div>
);
}
interface ProjectedScheduleProps {
timeStart: number;
timeEnd: number;
delay: number;
}
function ProjectedSchedule(props: ProjectedScheduleProps) {
const { timeStart, timeEnd, delay } = props;
const { offset } = useRuntimeOffset();
// offset is negative if we are ahead
const projectedOffset = offset - delay;
const classes = cx([projectedOffset > 0 && 'sub__schedule--ahead', projectedOffset < 0 && 'sub__schedule--behind']);
return (
<>
<ClockTime
value={timeStart - projectedOffset}
className={classes}
preferredFormat12='h:mm'
preferredFormat24='HH:mm'
/>
<ClockTime value={timeEnd - projectedOffset} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
</>
);
}
interface SubscriptionStatusProps {
time: ViewExtendedTimer;
event: OntimeEvent;
selectedId: EntryId | null;
}
function SubscriptionStatus(props: SubscriptionStatusProps) {
const { time, event, selectedId } = props;
const { getLocalizedString } = useTranslation();
const { currentDay } = useCurrentDay();
const { offset } = useRuntimeOffset();
const { showProjected } = useCountdownOptions();
// TODO: use reporter values as in the event block chip
const { status, timer } = getSubscriptionDisplayData(
time,
event,
selectedId,
offset,
currentDay,
getLocalizedString('common.minutes'),
showProjected,
);
return (
<>
<div className='sub__status'>{getLocalizedString(timerProgress[status])}</div>
<div className='sub__timer'>{timer}</div>
</>
);
}
@@ -1,107 +0,0 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, EntryId, OntimeEvent } from 'ontime-types';
import {
getTimeOption,
makeOptionsFromCustomFields,
OptionTitle,
} from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
export const getCountdownOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'sub',
title: 'Event subscription',
description: 'The events to follow',
value: '',
type: 'persist',
},
{
// TODO: adding a secondary source is removing the subscriptions
// this seems to be a bug with persist assuming that the property has a single entry
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in the card',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
],
},
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'followSelected',
title: 'Follow selected event',
description: 'Whether the view should automatically scroll to the selected event',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
],
},
{
title: OptionTitle.BehaviourOptions,
collapsible: true,
options: [
{
id: 'showProjected',
title: 'Show projected time',
description: 'Whether scheduled times should account for runtime offset',
type: 'boolean',
defaultValue: false,
},
],
},
];
};
type CountdownOptions = {
subscriptions: EntryId[];
secondarySource: keyof OntimeEvent | null;
showProjected: boolean;
followSelected: boolean;
hidePast: boolean;
};
/**
* Utility extract the view options from URL Params
* the names and fallback are manually matched with timerOptions
*/
function getOptionsFromParams(searchParams: URLSearchParams): CountdownOptions {
// we manually make an object that matches the key above
return {
subscriptions: searchParams.getAll('sub') as EntryId[],
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
showProjected: isStringBoolean(searchParams.get('showProjected')),
followSelected: isStringBoolean(searchParams.get('followSelected')),
hidePast: isStringBoolean(searchParams.get('hidePast')),
};
}
/**
* Hook exposes the backstage view options
*/
export function useCountdownOptions(): CountdownOptions {
const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
return options;
}
@@ -1,194 +0,0 @@
import { EntryId, OntimeEvent, Playback, TimerType } from 'ontime-types';
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
import { getFormattedTimer } from '../../features/viewers/common/viewUtils';
import type { TranslationKey } from '../../translation/TranslationProvider';
/**
* Parses string as a title
*/
export const sanitiseTitle = (title: string | null) => (title ? title : '{no title}');
const subscriptionTimerDisplayOptions = {
removeSeconds: true,
removeLeadingZero: true,
} as const;
type TimerMessage = Record<string, TranslationKey>;
export type ProgressStatus = 'future' | 'due' | 'live' | 'done';
export const timerProgress: TimerMessage = {
future: 'countdown.to_start',
due: 'timeline.due',
live: 'timeline.live',
done: 'countdown.ended',
};
/**
* Returns a parsed timer and relevant status message
* Handles events in different days but disregards whether an event has actually played
* TODO: get data from reporter and check if the event has played
* TODO: get timer data granularly
*/
export function getSubscriptionDisplayData(
time: ViewExtendedTimer,
subscribedEvent: OntimeEvent,
selectedId: EntryId | null,
offset: number,
currentDay: number,
minutesString: string,
showProjected = false,
): { status: ProgressStatus; timer: string } {
const addedTime = showProjected ? offset : 0;
if (selectedId === subscribedEvent.id) {
// 1. An event that is loaded but not running is {'due': <countdown | overtime>}
if (time.playback === Playback.Armed) {
// if we are following the event, but it is not running, we show the armed timer
return {
status: 'due',
timer: getFormattedTimer(
subscribedEvent.timeStart + addedTime,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
};
}
// 2. An event with a time-to-start lower than 0 is {'due': <countdown | scheduledStart>}
return {
status: 'live',
timer: getFormattedTimer(time.current, TimerType.CountDown, minutesString, subscriptionTimerDisplayOptions),
};
}
/**
* If the running timer is not the one we are following
* we can be in future, due or have ended
*/
// 3. event is the day after
if (subscribedEvent.dayOffset > currentDay) {
return {
status: 'future',
timer: getFormattedTimer(
subscribedEvent.timeStart - time.clock - addedTime,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
};
}
// 4. event is the before after, show the scheduled end
if (subscribedEvent.dayOffset < currentDay) {
return {
status: 'done',
timer: getFormattedTimer(
subscribedEvent.timeEnd,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
};
}
// 5. if event is in future, we count to the scheduled start
if (time.clock < subscribedEvent.timeStart) {
return {
status: 'future',
timer: getFormattedTimer(
subscribedEvent.timeStart - time.clock - addedTime,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
};
}
// 6. if event has ended, we count to the scheduled end
if (time.clock > subscribedEvent.timeEnd) {
return {
status: 'done',
timer: getFormattedTimer(
subscribedEvent.timeEnd,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
};
}
// the event here has to be due
return {
status: 'due',
timer: getFormattedTimer(
subscribedEvent.timeStart + addedTime,
TimerType.CountDown,
minutesString,
subscriptionTimerDisplayOptions,
),
};
}
/**
* Adds a set of subscriptions to the URL parameters
*/
export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) {
const url = new URL(urlRef);
const newParams = new URLSearchParams();
// copy existing parameters except for 'sub'
for (const [key, value] of url.searchParams.entries()) {
if (key !== 'sub') {
newParams.append(key, value);
}
}
// add new subscriptions
subscriptions.forEach((id) => {
newParams.append('sub', id);
});
url.search = newParams.toString();
return url;
}
/**
* Returns an array of events subscribed events ordered by scheduled
* Since the original array is already ordered, we simply filter out the events
* which are not in the subscriptions list.
*/
export function getOrderedSubscriptions(subscriptions: EntryId[], playableEvents: OntimeEvent[]): OntimeEvent[] {
return playableEvents.filter((event) => subscriptions.includes(event.id));
}
/**
* Checks through the rundown whether the current event is linked to the loaded event
*/
export function isLinkedToLoadedEvent(events: OntimeEvent[], loadedId: EntryId | null, currentId: EntryId): boolean {
// if nothing is loaded, we return true to simplify the logic
if (!loadedId) {
return true;
}
const loadedIndex = events.findIndex((event) => event.id === loadedId);
if (loadedIndex === -1) {
return true;
}
for (let i = loadedIndex; i < events.length; i++) {
const event = events[i];
if (event.id === currentId) {
return true;
}
if (event.linkStart === null) {
return false;
}
}
return true;
}
@@ -96,12 +96,11 @@ describe('makeTable()', () => {
"Is Public? (x)", "Is Public? (x)",
"Skip?", "Skip?",
"lighting", "lighting",
"Type",
], ],
[ [
"00:00:00", "00:00:00",
"00:00:00", "00:00:00",
"", "...",
"", "",
"", "",
"", "",
@@ -110,7 +109,6 @@ describe('makeTable()', () => {
"x", "x",
"", "",
"", "",
"",
], ],
] ]
`); `);
@@ -20,7 +20,7 @@ type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
export const parseField = (field: CsvHeaderKey, data: unknown): string => { export const parseField = (field: CsvHeaderKey, data: unknown): string => {
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') { if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
return millisToString(data as MaybeNumber, { fallback: '' }); return millisToString(data as MaybeNumber);
} }
if (field === 'isPublic' || field === 'skip') { if (field === 'isPublic' || field === 'skip') {
@@ -55,7 +55,6 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeEntry[], custo
'isPublic', 'isPublic',
'skip', 'skip',
...customFieldKeys, ...customFieldKeys,
'type',
]; ];
const fieldTitles = [ const fieldTitles = [
@@ -70,7 +69,6 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeEntry[], custo
'Is Public? (x)', 'Is Public? (x)',
'Skip?', 'Skip?',
...customFieldLabels, ...customFieldLabels,
'Type',
]; ];
// add header row to data // add header row to data
@@ -25,9 +25,7 @@
flex: 1; flex: 1;
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
width: min(calc(100vw - 4rem), 960px); width: min(calc(100vw - 4rem), 800px);
padding-bottom: 10vh;
} }
.info__label { .info__label {
@@ -35,15 +33,11 @@
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase; text-transform: uppercase;
margin-top: $view-element-gap; margin-top: $view-element-gap;
} white-space: pre;
.info__value {
white-space: break-spaces;
} }
a.info__value { a.info__value {
color: $action-text-color; color: $action-text-color;
&:hover { &:hover {
color: $ontime-color; color: $ontime-color;
} }
@@ -8,7 +8,6 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { useTranslation } from '../../translation/TranslationProvider'; import { useTranslation } from '../../translation/TranslationProvider';
import BackstageInfo from './backstage-info/BackstageInfo'; import BackstageInfo from './backstage-info/BackstageInfo';
import CustomInfo from './custom-info/CustomInfo';
import PublicInfo from './public-info/PublicInfo'; import PublicInfo from './public-info/PublicInfo';
import { projectInfoOptions } from './projectInfo.options'; import { projectInfoOptions } from './projectInfo.options';
@@ -67,7 +66,6 @@ export default function ProjectInfo(props: ProjectInfoProps) {
)} )}
<BackstageInfo general={general} /> <BackstageInfo general={general} />
<PublicInfo general={general} /> <PublicInfo general={general} />
<CustomInfo general={general} />
</div> </div>
</div> </div>
); );
@@ -1,36 +0,0 @@
import { Fragment } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ProjectData } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface CustomInfoProps {
general: ProjectData;
}
export default function CustomInfo(props: CustomInfoProps) {
const { general } = props;
const [searchParams] = useSearchParams();
const showCustom = isStringBoolean(searchParams.get('showCustom'));
if (!showCustom || general.custom === undefined || general.custom.length === 0) {
return null;
}
return (
<>
{general.custom.map((info, idx) => {
if (!info.title || !info.value) {
return null;
}
return (
<Fragment key={`${info.title}-${idx}`}>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</Fragment>
);
})}
</>
);
}
@@ -9,21 +9,14 @@ export const projectInfoOptions: ViewOption[] = [
{ {
id: 'showBackstage', id: 'showBackstage',
title: 'Show backstage Data', title: 'Show backstage Data',
description: 'Whether to show fields related to the backstage views', description: 'Weather to show fields related to the backstage views',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
{ {
id: 'showPublic', id: 'showPublic',
title: 'Show Public Data', title: 'Show Public Data',
description: 'Whether to show fields related to the public views', description: 'Weather to show fields related to the public views',
type: 'boolean',
defaultValue: false,
},
{
id: 'showCustom',
title: 'Show Custom Data',
description: 'Whether to show fields related to the custom data',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
+204
View File
@@ -0,0 +1,204 @@
@use '../../theme/viewerDefs' as *;
.public-screen {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
gap: $view-element-gap;
padding: $view-outer-padding;
font-size: $base-font-size;
display: grid;
grid-template-columns: 1fr 40vw;
grid-template-rows: auto 12px 1fr auto;
grid-template-areas:
'header header'
'now schedule-nav'
'info schedule';
.empty-container {
position: absolute;
top: 0;
left: 0;
right: 0;
margin-top: 25vh;
color: var(--label-color-override, $viewer-label-color);
}
/* =================== HEADER + EXTRAS ===================*/
.project-header {
grid-area: header;
font-size: $header-font-size;
font-weight: 600;
display: flex;
gap: 1rem;
}
.logo {
max-width: min(200px, 20vw);
}
.title {
line-height: 1.1em;
}
.clock-container {
margin-left: auto;
font-weight: 600;
.label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
}
/* =================== MAIN - NOW ===================*/
.card-container {
grid-area: now;
display: flex;
flex-direction: column;
gap: $view-element-gap;
}
.event {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: $view-card-padding;
border-radius: $element-border-radius;
}
.timer-group {
border-top: 2px solid var(--background-color-override, $viewer-background-color);
margin-top: max(1vh, 16px);
padding-top: max(1vh, 16px);
display: flex;
row-gap: 0.5em;
}
.time-entry {
&__label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
font-weight: 600;
text-transform: uppercase;
}
&__value {
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
&--pending {
color: var(--timer-pending-color-override, $ontime-roll);
}
}
/* =================== MAIN - SCHEDULE ===================*/
$schedule-left-spacing: clamp(16px, 4vw, 64px);
.schedule-container {
grid-area: schedule;
overflow: hidden;
height: 100%;
padding-left: $schedule-left-spacing;
}
.schedule-nav-container {
grid-area: schedule-nav;
padding-left: $schedule-left-spacing;
}
/* =================== MAIN - INFO ===================*/
.info {
grid-area: info;
display: flex;
gap: max(1vw, 16px);
align-self: flex-end;
overflow: hidden;
align-items: end;
&__message {
font-size: $base-font-size;
line-height: 1.2em;
white-space: pre-line;
overflow: hidden;
flex: 1;
}
.qr {
padding: 0.5rem;
background-color: $ui-white;
border-radius: 2px;
}
}
}
/* =================== MOBILE ===================*/
@media screen and (max-width: 768px) {
.public-screen {
display: flex;
flex-direction: column;
overflow-y: auto;
.project-header {
flex-direction: column;
position: relative;
gap: 0.5rem;
}
.logo img{
height: min(50px, 10vh);
}
.timer-group {
flex-wrap: wrap;
}
.clock-container {
position: absolute;
top: 0;
right: 0;
}
.schedule-nav-container {
padding-left: 0;
justify-content: center;
}
.schedule-container {
padding-left: 0;
flex: 1;
}
.info {
width: 100%;
text-align: right;
}
.qr {
display: none;
}
.info--stretch {
flex: 1;
}
}
}
+126
View File
@@ -0,0 +1,126 @@
import QRCode from 'react-qr-code';
import { useViewportSize } from '@mantine/hooks';
import { CustomFields, OntimeEvent, ProjectData, Settings } from 'ontime-types';
import Empty from '../../common/components/state/Empty';
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 { cx } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { useTranslation } from '../../translation/TranslationProvider';
import { getIsPendingStart } from '../backstage/backstage.utils';
import ScheduleExport from '../common/schedule/ScheduleExport';
import { getPublicOptions, usePublicOptions } from './public.options';
import { getCardData, getFirstStartTime } from './public.utils';
import './Public.scss';
interface BackstageProps {
customFields: CustomFields;
events: OntimeEvent[];
general: ProjectData;
isMirrored: boolean;
publicEventNow: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
time: ViewExtendedTimer;
publicSelectedId: string | null;
settings: Settings | undefined;
}
export default function Public(props: BackstageProps) {
const {
customFields,
events,
general,
isMirrored,
publicEventNow,
publicEventNext,
time,
publicSelectedId,
settings,
} = props;
const { getLocalizedString } = useTranslation();
const { secondarySource } = usePublicOptions();
const { height: screenHeight } = useViewportSize();
useWindowTitle('Public Schedule');
// gather card data
const hasEvents = events.length > 0;
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
publicEventNow,
publicEventNext,
'title',
secondarySource,
time.playback,
);
// gather timer data
const clock = formatTime(time.clock);
const isPendingStart = getIsPendingStart(time.playback, time.phase);
const scheduledStart = (() => {
if (showNow) return undefined;
if (!hasEvents) return undefined;
return getFirstStartTime(events[0]);
})();
// gather presentation styles
const qrSize = Math.max(window.innerWidth / 15, 72);
const showSchedule = hasEvents && screenHeight > 700; // in vertical screens we may not have space
const showPending = scheduledStart !== undefined;
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const publicOptions = getPublicOptions(defaultFormat, customFields);
return (
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<ViewParamsEditor viewOptions={publicOptions} />
<div className='project-header'>
{general?.projectLogo ? <ViewLogo name={general.projectLogo} className='logo' /> : <div className='logo' />}
<div className='title'>{general.title}</div>
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='time' />
</div>
</div>
{!hasEvents && <Empty text={getLocalizedString('countdown.waiting')} className='empty-container' />}
<div className='card-container'>
{showNow && hasEvents && (
<TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />
)}
{showPending && (
<div className='event'>
<div className='title-card__placeholder'>{getLocalizedString('countdown.waiting')}</div>
<div className='timer-group'>
<div className='time-entry'>
<div className={cx(['time-entry__label', isPendingStart && 'time-entry--pending'])}>
{getLocalizedString('common.scheduled_start')}
</div>
<SuperscriptTime time={formatTime(scheduledStart)} className='time-entry__value' />
</div>
</div>
</div>
)}
{showNext && hasEvents && (
<TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />
)}
</div>
{showSchedule && <ScheduleExport selectedId={publicSelectedId} />}
<div className={cx(['info', !showSchedule && 'info--stretch'])}>
{general.publicUrl && <QRCode value={general.publicUrl} size={qrSize} level='L' className='qr' />}
{general.publicInfo && <div className='info__message'>{general.publicInfo}</div>}
</div>
</div>
);
}
@@ -0,0 +1,58 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, OntimeEvent } from 'ontime-types';
import {
getTimeOption,
makeOptionsFromCustomFields,
OptionTitle,
} from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
import { scheduleOptions } from '../common/schedule/schedule.options';
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
],
},
scheduleOptions,
];
};
type PublicOptions = {
secondarySource: keyof OntimeEvent | null;
};
/**
* Utility extract the view options from URL Params
* the names and fallback are manually matched with timerOptions
*/
function getOptionsFromParams(searchParams: URLSearchParams): PublicOptions {
// we manually make an object that matches the key above
return {
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
};
}
/**
* Hook exposes the backstage view options
*/
export function usePublicOptions(): PublicOptions {
const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
return options;
}
@@ -0,0 +1,45 @@
import { OntimeEvent, Playback } from 'ontime-types';
import { enDash } from '../../common/utils/styleUtils';
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
/**
* 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,
) {
if (playback === Playback.Stop) {
return {
showNow: false,
nowMain: undefined,
nowSecondary: undefined,
showNext: false,
nextMain: undefined,
nextSecondary: undefined,
};
}
// if we are loaded, we show the upcoming event as next
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title') || enDash;
const nowSecondary = getPropertyValue(eventNow, secondarySource);
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash;
const nextSecondary = getPropertyValue(eventNext, secondarySource);
return {
showNow: eventNow !== null,
nowMain,
nowSecondary,
showNext: eventNext !== null,
nextMain,
nextSecondary,
};
}
export function getFirstStartTime(firstPublicEvent: OntimeEvent | null): number | undefined {
return firstPublicEvent?.timeStart;
}
+33 -3
View File
@@ -1,3 +1,4 @@
import { AnimatePresence } from 'framer-motion';
import { import {
CustomFields, CustomFields,
MessageState, MessageState,
@@ -10,7 +11,6 @@ import {
import { FitText } from '../../common/components/fit-text/FitText'; import { FitText } from '../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../common/components/multi-part-progress-bar/MultiPartProgressBar'; 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 ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
@@ -21,6 +21,7 @@ import SuperscriptTime from '../../features/viewers/common/superscript-time/Supe
import { getFormattedTimer, getTimerByType } from '../../features/viewers/common/viewUtils'; import { getFormattedTimer, getTimerByType } from '../../features/viewers/common/viewUtils';
import { useTranslation } from '../../translation/TranslationProvider'; import { useTranslation } from '../../translation/TranslationProvider';
import { MotionTitleCard, titleVariants } from './timer.animations';
import { getTimerOptions, useTimerOptions } from './timer.options'; import { getTimerOptions, useTimerOptions } from './timer.options';
import { import {
getCardData, getCardData,
@@ -188,8 +189,37 @@ export default function Timer(props: TimerProps) {
{!hideCards && ( {!hideCards && (
<> <>
{showNow && <TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />} <AnimatePresence>
{showNext && <TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />} {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> </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);
+3 -32
View File
@@ -39,29 +39,6 @@ export default defineConfig({
], ],
server: { server: {
port: 3000, port: 3000,
proxy: {
'^/login*': {
target: 'http://localhost:4001/',
changeOrigin: true,
configure: logProxyRequests,
},
'^/data*': {
target: 'http://localhost:4001/',
changeOrigin: true,
configure: logProxyRequests,
},
'^/api*': {
target: 'http://localhost:4001/',
changeOrigin: true,
configure: logProxyRequests,
},
'^/ws*': {
target: 'http://localhost:4001/',
changeOrigin: true,
configure: logProxyRequests,
ws: true,
},
},
}, },
test: { test: {
globals: true, globals: true,
@@ -93,17 +70,11 @@ export default defineConfig({
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
additionalData: ` additionalData: `
@use '@/theme/ontimeColours' as *; @use './src/theme/ontimeColours' as *;
@use '@/theme/ontimeStyles' as *; @use './src/theme/ontimeStyles' as *;
@use '@/theme/mixins' as *; @use './src/theme/mixins' as *;
`, `,
}, },
}, },
}, },
}); });
function logProxyRequests(proxy) {
proxy.on('proxyReq', (_proxyReq, req, _res) => {
console.log('Proxy:', req.method, req.url);
});
}
+5 -3
View File
@@ -12,8 +12,8 @@
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"main": "src/main.js", "main": "src/main.js",
"devDependencies": { "devDependencies": {
"electron": "36.3.1", "electron": "^31.2.0",
"electron-builder": "26.0.12", "electron-builder": "^24.13.3",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",
@@ -38,7 +38,9 @@
"icon": "icon.icns" "icon": "icon.icns"
}, },
"mac": { "mac": {
"notarize": true, "notarize": {
"teamId": "MDAU6QK6R4"
},
"hardenedRuntime": true, "hardenedRuntime": true,
"gatekeeperAssess": false, "gatekeeperAssess": false,
"entitlements": "./entitlements.plist", "entitlements": "./entitlements.plist",
+12 -11
View File
@@ -6,18 +6,19 @@
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
"cookie": "1.0.2", "classic-level": "^3.0.0",
"cookie-parser": "1.4.7", "cookie": "^1.0.2",
"cors": "2.8.5", "cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"express": "5.1.0", "express": "^4.21.1",
"express-static-gzip": "3.0.0", "express-static-gzip": "^2.2.0",
"express-validator": "7.2.1", "express-validator": "^7.2.0",
"multer": "2.0.1",
"fast-equals": "^5.0.1", "fast-equals": "^5.0.1",
"google-auth-library": "^9.4.2", "google-auth-library": "^9.4.2",
"got": "^14.4.5", "got": "^14.4.5",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",
"multer": "^1.4.5-lts.1",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"osc-min": "2.1.2", "osc-min": "2.1.2",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
@@ -26,10 +27,10 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/cookie-parser": "1.4.9", "@types/cookie-parser": "^1.4.8",
"@types/cors": "2.8.19", "@types/cors": "^2.8.17",
"@types/express": "5.0.3", "@types/express": "^4.17.17",
"@types/multer": "1.4.13", "@types/multer": "^1.4.11",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/websocket": "^1.0.5", "@types/websocket": "^1.0.5",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
+1 -1
View File
@@ -5,7 +5,7 @@ import * as dgram from 'node:dgram';
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 { isOntimeCloud } from '../setup/environment.js'; import { isOntimeCloud } from '../externals.js';
import { integrationPayloadFromPath } from './utils/parse.js'; import { integrationPayloadFromPath } from './utils/parse.js';
import type { IAdapter } from './IAdapter.js'; import type { IAdapter } from './IAdapter.js';
@@ -0,0 +1,41 @@
import { defaultCss } from '../../user/styles/bundledCss.js';
import type { Request, Response } from 'express';
import { readCssFile, writeCssFile } from './assets.service.js';
/**
* Exposes the contents of the cssOverride.css file
*/
export async function getCssOverride(_req: Request, res: Response) {
try {
const data = await readCssFile();
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: error });
}
}
/**
* Allows modifying the cssOverride.css file
*/
export async function postCssOverride(req: Request, res: Response) {
const { css } = req.body;
try {
await writeCssFile(css);
res.status(204).send();
} catch (error) {
res.status(500).send({ message: error });
}
}
/**
* Restores the default cssOverride.css file
*/
export async function restoreCss(_req: Request, res: Response) {
try {
await writeCssFile(defaultCss);
res.status(200).send(defaultCss);
} catch (error) {
res.status(500).send({ message: error });
}
}
@@ -1,40 +1,10 @@
import express from 'express'; import express from 'express';
import type { Request, Response } from 'express';
import type { ErrorResponse } from 'ontime-types'; import { getCssOverride, postCssOverride, restoreCss } from './assets.controller.js';
import { validatePostCss } from './assets.validation.js'; import { validatePostCss } from './assets.validation.js';
import { readCssFile, writeCssFile } from './assets.service.js';
import { getErrorMessage } from 'ontime-utils';
import { defaultCss } from '../../user/styles/bundledCss.js';
export const router = express.Router(); export const router = express.Router();
router.get('/css', async (_req: Request, res: Response<string | ErrorResponse>) => { router.get('/css', getCssOverride);
try { router.post('/css', validatePostCss, postCssOverride);
const data = await readCssFile(); router.post('/css/restore', restoreCss);
res.status(200).send(data);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
router.post('/css', validatePostCss, async (req: Request, res: Response<never | ErrorResponse>) => {
const { css } = req.body;
try {
await writeCssFile(css);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
router.post('/css/restore', async (_req: Request, res: Response<string | ErrorResponse>) => {
try {
await writeCssFile(defaultCss);
res.status(200).send(defaultCss);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
@@ -1,4 +1,12 @@
import { body } from 'express-validator'; import { Request, Response, NextFunction } from 'express';
import { requestValidationFunction } from '../validation-utils/validationFunction.js'; import { body, validationResult } from 'express-validator';
export const validatePostCss = [body('css').isString().trim(), requestValidationFunction]; export const validatePostCss = [
body('css').exists().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -1,6 +1,4 @@
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation, EntryId } from 'ontime-types'; import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation } from 'ontime-types';
import { makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import { import {
addTrigger, addTrigger,
@@ -14,7 +12,6 @@ import {
getAutomationTriggers, getAutomationTriggers,
getAutomations, getAutomations,
} from '../automation.dao.js'; } from '../automation.dao.js';
import { makeOSCAction, makeHTTPAction } from './testUtils.js'; import { makeOSCAction, makeHTTPAction } from './testUtils.js';
beforeAll(() => { beforeAll(() => {
@@ -189,9 +186,11 @@ describe('editAutomation()', async () => {
}); });
describe('deleteAutomation()', () => { describe('deleteAutomation()', () => {
// saving the ID of the added automation
let firstAutomation: Automation;
beforeEach(async () => { beforeEach(async () => {
await deleteAll(); await deleteAll();
await addAutomation({ firstAutomation = await addAutomation({
title: 'test-osc', title: 'test-osc',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
@@ -199,15 +198,35 @@ describe('deleteAutomation()', () => {
}); });
}); });
it('should remove an automation from the list', async () => { it('should remove m automation from the list', async () => {
const automations = getAutomations(); const automations = getAutomations();
expect(Object.keys(automations).length).toEqual(1); expect(Object.keys(automations).length).toEqual(1);
const rundown = makeRundown({}); await deleteAutomation(Object.keys(automations)[0]);
const timedEventOrder: EntryId[] = [];
await deleteAutomation(rundown, timedEventOrder, Object.keys(automations)[0]);
const removed = getAutomations(); const removed = getAutomations();
expect(Object.keys(removed).length).toEqual(0); expect(Object.keys(removed).length).toEqual(0);
}); });
it('should not remove an automation which is in use', async () => {
const automations = getAutomations();
await addTrigger({
title: 'test-automation',
trigger: TimerLifeCycle.onLoad,
automationId: firstAutomation.id,
});
const automationKeys = Object.keys(automations);
const automationId = automationKeys[0];
expect(automationId).toEqual(firstAutomation.id);
expect(automationKeys.length).toEqual(1);
expect(automations[automationId]).toMatchObject({
id: automationId,
title: 'test-osc',
filterRule: 'all',
filters: expect.any(Array),
outputs: expect.any(Array),
});
await expect(deleteAutomation(automationId)).rejects.toThrowError();
});
}); });
@@ -1,7 +1,7 @@
import { PlayableEvent, TimerLifeCycle } from 'ontime-types'; import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js'; import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js'; import { makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js'; import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
import { testConditions, triggerAutomations } from '../automation.service.js'; import { testConditions, triggerAutomations } from '../automation.service.js';
@@ -1,6 +1,4 @@
import { TimerLifeCycle } from 'ontime-types'; import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import { isAutomationUsed, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
describe('parseTemplateNested()', () => { describe('parseTemplateNested()', () => {
it('parses string with a single-level variable name', () => { it('parses string with a single-level variable name', () => {
@@ -247,53 +245,3 @@ describe('test stringToOSCArgs()', () => {
expect(stringToOSCArgs(test)).toStrictEqual(expected); expect(stringToOSCArgs(test)).toStrictEqual(expected);
}); });
}); });
describe('isAutomationUsed()', () => {
it('returns the first event which uses an automation', () => {
const rundown = makeRundown({
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [
{
id: 'trigger-1',
title: 'Trigger 1',
trigger: TimerLifeCycle.onClock,
automationId: 'test-automation',
},
],
}),
},
});
const timedEventOrder = ['1'];
const automationId = 'test-automation';
const result = isAutomationUsed(rundown, timedEventOrder, automationId);
expect(result).toBe('1');
});
it('returns returns undefined if there are no matches', () => {
const rundown = makeRundown({
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [
{
id: 'trigger-1',
title: 'Trigger 1',
trigger: TimerLifeCycle.onClock,
automationId: 'test-automation',
},
],
}),
},
});
const timedEventOrder = ['1'];
const automationId = 'does-not-exist';
const result = isAutomationUsed(rundown, timedEventOrder, automationId);
expect(result).toBeUndefined();
});
});
@@ -5,14 +5,12 @@ import type { Request, Response } from 'express';
import { oscServer } from '../../adapters/OscAdapter.js'; import { oscServer } from '../../adapters/OscAdapter.js';
import { getCurrentRundown, getRundownMetadata } from '../rundown/rundown.dao.js';
import * as automationDao from './automation.dao.js'; import * as automationDao from './automation.dao.js';
import * as automationService from './automation.service.js'; import * as automationService from './automation.service.js';
import { parseOutput } from './automation.validation.js'; import { parseOutput } from './automation.validation.js';
export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) { export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) {
res.status(200).json(automationDao.getAutomationSettings()); res.json(automationDao.getAutomationSettings());
} }
export async function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) { export async function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) {
@@ -108,10 +106,7 @@ export async function editAutomation(req: Request, res: Response<Automation | Er
export async function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) { export async function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) {
try { try {
const rundown = getCurrentRundown(); await automationDao.deleteAutomation(req.params.id);
const { timedEventOrder } = getRundownMetadata();
await automationDao.deleteAutomation(rundown, timedEventOrder, req.params.id);
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
@@ -2,17 +2,14 @@ import type {
Automation, Automation,
AutomationDTO, AutomationDTO,
AutomationSettings, AutomationSettings,
EntryId,
NormalisedAutomation, NormalisedAutomation,
Rundown,
Trigger, Trigger,
TriggerDTO, TriggerDTO,
} from 'ontime-types'; } from 'ontime-types';
import { deleteAtIndex, generateId } from 'ontime-utils'; import { deleteAtIndex, generateId } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { getTimedEvents } from '../../services/rundown-service/rundownUtils.js';
import { isAutomationUsed } from './automation.utils.js';
/** /**
* Gets a copy of the stored automation settings * Gets a copy of the stored automation settings
@@ -136,7 +133,7 @@ export async function editAutomation(id: string, newAutomation: AutomationDTO):
/** /**
* Deletes a automation given its ID * Deletes a automation given its ID
*/ */
export async function deleteAutomation(rundown: Rundown, timedEventOrder: EntryId[], id: string): Promise<void> { export async function deleteAutomation(id: string): Promise<void> {
const automations = getAutomations(); const automations = getAutomations();
// ignore request if automation does not exist // ignore request if automation does not exist
if (!Object.hasOwn(automations, id)) { if (!Object.hasOwn(automations, id)) {
@@ -152,9 +149,13 @@ export async function deleteAutomation(rundown: Rundown, timedEventOrder: EntryI
} }
// prevent deleting a automation that is in use in events // prevent deleting a automation that is in use in events
const isInUse = isAutomationUsed(rundown, timedEventOrder, id); const events = getTimedEvents().filter(
if (isInUse) { (event) => event.triggers && event.triggers.some((trigger) => trigger.automationId === id),
throw new Error(`Unable to delete automation used in event with ID ${isInUse}`); );
if (events.length) {
throw new Error(
`Unable to delete automation used in event: ${events[0].id}${events.length > 1 ? ` and ${events.length - 1} more` : ''}`,
);
} }
delete automations[id]; delete automations[id];
@@ -1,7 +1,7 @@
import { DatabaseModel, AutomationSettings, NormalisedAutomation, Trigger } from 'ontime-types'; import { DatabaseModel, AutomationSettings, NormalisedAutomation, Trigger } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js'; import { dbModel } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js'; import type { ErrorEmitter } from '../../utils/parser.js';
interface LegacyData extends Partial<DatabaseModel> { interface LegacyData extends Partial<DatabaseModel> {
http?: unknown; http?: unknown;
@@ -12,6 +12,7 @@ import {
testOutput, testOutput,
} from './automation.controller.js'; } from './automation.controller.js';
import { import {
paramContainsId,
validateAutomationSettings, validateAutomationSettings,
validateAutomation, validateAutomation,
validateAutomationPatch, validateAutomationPatch,
@@ -19,7 +20,6 @@ import {
validateTrigger, validateTrigger,
validateTriggerPatch, validateTriggerPatch,
} from './automation.validation.js'; } from './automation.validation.js';
import { paramsWithId } from '../validation-utils/validationFunction.js';
export const router = express.Router(); export const router = express.Router();
@@ -28,10 +28,10 @@ router.post('/', validateAutomationSettings, postAutomationSettings);
router.post('/trigger', validateTrigger, postTrigger); router.post('/trigger', validateTrigger, postTrigger);
router.put('/trigger/:id', validateTriggerPatch, putTrigger); router.put('/trigger/:id', validateTriggerPatch, putTrigger);
router.delete('/trigger/:id', paramsWithId, deleteTrigger); router.delete('/trigger/:id', paramContainsId, deleteTrigger);
router.post('/automation', validateAutomation, postAutomation); router.post('/automation', validateAutomation, postAutomation);
router.put('/automation/:id', validateAutomationPatch, editAutomation); router.put('/automation/:id', validateAutomationPatch, editAutomation);
router.delete('/automation/:id', paramsWithId, deleteAutomation); router.delete('/automation/:id', paramContainsId, deleteAutomation);
router.post('/test', validateTestPayload, testOutput); router.post('/test', validateTestPayload, testOutput);
@@ -12,7 +12,7 @@ import { getPropertyFromPath } from 'ontime-utils';
import { logger } from '../../classes/Logger.js'; import { logger } from '../../classes/Logger.js';
import { getState, type RuntimeState } from '../../stores/runtimeState.js'; import { getState, type RuntimeState } from '../../stores/runtimeState.js';
import { isOntimeCloud } from '../../setup/environment.js'; import { isOntimeCloud } from '../../externals.js';
import { emitOSC } from './clients/osc.client.js'; import { emitOSC } from './clients/osc.client.js';
import { emitHTTP } from './clients/http.client.js'; import { emitHTTP } from './clients/http.client.js';
@@ -1,4 +1,4 @@
import { EntryId, FilterRule, isOntimeEvent, MaybeNumber, OntimeAction, Rundown } from 'ontime-types'; import { FilterRule, MaybeNumber, OntimeAction } from 'ontime-types';
import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils'; import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils';
import type { OscArgOrArrayInput, OscArgInput } from 'osc-min'; import type { OscArgOrArrayInput, OscArgInput } from 'osc-min';
@@ -195,25 +195,3 @@ export function isBooleanEquals(a: boolean, b: string): boolean {
} }
return false; return false;
} }
/**
* Checks is an automation is used in a rundown
* TODO(v4): this currently only checks the current rundown, we will need to check all rundowns in the future
*/
export function isAutomationUsed(
rundown: Rundown,
timedEventOrder: EntryId[],
automationId: string,
): EntryId | undefined {
for (let i = 0; i < timedEventOrder.length; i++) {
const eventId = timedEventOrder[i];
const event = rundown.entries[eventId];
if (isOntimeEvent(event) && event.triggers) {
for (const trigger of event.triggers) {
if (trigger.automationId === automationId) {
return eventId;
}
}
}
}
}
@@ -10,50 +10,84 @@ import {
} from 'ontime-types'; } from 'ontime-types';
import { parseUserTime } from 'ontime-utils'; import { parseUserTime } from 'ontime-utils';
import { body, oneOf, param } from 'express-validator'; import type { Request, Response, NextFunction } from 'express';
import { body, oneOf, param, validationResult } from 'express-validator';
import * as assert from '../../utils/assert.js'; import * as assert from '../../utils/assert.js';
import { isFilterOperator, isFilterRule, isOntimeActionAction } from './automation.utils.js'; import { isFilterOperator, isFilterRule, isOntimeActionAction } from './automation.utils.js';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const paramContainsId = [
param('id').exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateAutomationSettings = [ export const validateAutomationSettings = [
body('enabledAutomations').isBoolean(), body('enabledAutomations').exists().isBoolean(),
body('enabledOscIn').isBoolean(), body('enabledOscIn').exists().isBoolean(),
body('oscPortIn').isPort(), body('oscPortIn').exists().isPort(),
body('triggers').optional().isArray(), body('triggers').optional().isArray(),
body('triggers.*.title').optional().isString().trim(), body('triggers.*.title').optional().isString().trim(),
body('triggers.*.trigger').optional().isIn(timerLifecycleValues), body('triggers.*.trigger').optional().isIn(timerLifecycleValues),
body('triggers.*.automationId').optional().isString().trim(), body('triggers.*.automationId').optional().isString().trim(),
body('automations').optional().custom(parseAutomation), body('automations').optional().custom(parseAutomation),
requestValidationFunction, (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
export const validateTrigger = [ export const validateTrigger = [
body('title').isString().trim().notEmpty(), body('title').exists().isString().trim(),
body('trigger').isIn(timerLifecycleValues), body('trigger').exists().isIn(timerLifecycleValues),
body('automationId').isString().trim().notEmpty(), body('automationId').exists().isString().trim(),
requestValidationFunction, (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
export const validateTriggerPatch = [ export const validateTriggerPatch = [
param('id').isString().notEmpty(), param('id').exists(),
body('title').optional().isString().trim().notEmpty(), body('title').optional().isString().trim(),
body('trigger').optional().isIn(timerLifecycleValues), body('trigger').optional().isIn(timerLifecycleValues),
body('automationId').optional().isString().trim().notEmpty(), body('automationId').optional().isString().trim(),
requestValidationFunction, (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
export const validateAutomation = [body().custom(parseAutomation), requestValidationFunction]; export const validateAutomation = [
export const validateAutomationPatch = [
param('id').isString().notEmpty(),
body().custom(parseAutomation), body().custom(parseAutomation),
requestValidationFunction, (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateAutomationPatch = [
param('id').exists(),
body().custom(parseAutomation),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
/** /**
@@ -106,7 +140,7 @@ function validateOutput(output: Array<unknown>): output is AutomationOutput[] {
} }
export const validateTestPayload = [ export const validateTestPayload = [
body('type').isIn(['osc', 'http', 'ontime']), body('type').exists().isIn(['osc', 'http', 'ontime']),
// validation for OSC message // validation for OSC message
oneOf([ oneOf([
@@ -128,7 +162,11 @@ export const validateTestPayload = [
body('visible').if(body('type').equals('ontime')).optional().isString().trim(), body('visible').if(body('type').equals('ontime')).optional().isString().trim(),
body('secondarySource').if(body('type').equals('ontime')).optional().isString().trim(), body('secondarySource').if(body('type').equals('ontime')).optional().isString().trim(),
requestValidationFunction, (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
/** /**
@@ -0,0 +1,51 @@
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
import type { Request, Response } from 'express';
import { getErrorMessage } from 'ontime-utils';
import {
createCustomField,
editCustomField,
getCustomFields as getCustomFieldsFromCache,
removeCustomField,
} from '../../services/rundown-service/rundownCache.js';
export async function getCustomFields(_req: Request, res: Response<CustomFields>) {
const customFields = getCustomFieldsFromCache();
res.json(customFields);
}
export async function postCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try {
const newField = req.body as CustomField;
const allFields = await createCustomField(newField);
res.status(201).send(allFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function putCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try {
const oldLabel = req.params.label;
const { colour, type, label } = req.body;
const newFields = await editCustomField(oldLabel, { label, colour, type });
res.status(200).send(newFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
// Expects { label: <label> }
export async function deleteCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try {
const fieldToDelete = req.params.label;
await removeCustomField(fieldToDelete);
res.sendStatus(204);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -1,64 +0,0 @@
import { DatabaseModel, CustomFields, CustomField } from 'ontime-types';
import { isAlphanumericWithSpace, customFieldLabelToKey } from 'ontime-utils';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
/**
* Parse customFields entry
*/
export function parseCustomFields(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): CustomFields {
if (typeof data.customFields !== 'object') {
emitError?.('No data found to import');
return {};
}
console.log('Found Custom Fields, importing...');
const customFields = sanitiseCustomFields(data.customFields);
if (Object.keys(customFields).length !== Object.keys(data.customFields).length) {
emitError?.('Skipped invalid custom fields');
}
return customFields;
}
export function sanitiseCustomFields(data: object): CustomFields {
const newCustomFields: CustomFields = {};
for (const [_originalKey, field] of Object.entries(data)) {
if (!isValidField(field)) {
continue;
}
if (!isAlphanumericWithSpace(field.label)) {
continue;
}
// the key is always made from the label
const key = customFieldLabelToKey(field.label);
if (key in newCustomFields) {
continue;
}
newCustomFields[key] = {
type: field.type,
colour: field.colour,
label: field.label,
};
}
function isValidField(data: unknown): data is CustomField {
return (
typeof data === 'object' &&
data !== null &&
'label' in data &&
data.label !== '' &&
'colour' in data &&
typeof data.colour === 'string' &&
'type' in data &&
(data.type === 'string' || data.type === 'image')
);
}
return newCustomFields;
}
@@ -1,49 +1,14 @@
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import express from 'express'; import express from 'express';
import type { Request, Response } from 'express';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js';
import { deleteCustomField, getCustomFields, postCustomField, putCustomField } from './customFields.controller.js';
import { validateCustomField, validateDeleteCustomField, validateEditCustomField } from './customFields.validation.js'; import { validateCustomField, validateDeleteCustomField, validateEditCustomField } from './customFields.validation.js';
export const router = express.Router(); export const router = express.Router();
router.get('/', async (_req: Request, res: Response<CustomFields>) => { router.get('/', getCustomFields);
const customFields = getProjectCustomFields();
res.status(200).json(customFields);
});
router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => { router.post('/', validateCustomField, postCustomField);
try {
const newFields = await createCustomField(req.body as CustomField);
res.status(201).send(newFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => { router.put('/:label', validateEditCustomField, putCustomField);
try {
const currentKey = req.params.key;
const { colour, type, label } = req.body;
const newFields = await editCustomField(currentKey, { label, colour, type });
res.status(200).send(newFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => { router.delete('/:label', validateDeleteCustomField, deleteCustomField);
try {
const customFields = await deleteCustomField(req.params.key);
res.status(200).send(customFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
@@ -1,35 +1,51 @@
import { isAlphanumericWithSpace } from 'ontime-utils'; import { isAlphanumericWithSpace } from 'ontime-utils';
import { body, param } from 'express-validator'; import type { Request, Response, NextFunction } from 'express';
import { requestValidationFunction } from '../validation-utils/validationFunction.js'; import { body, param, validationResult } from 'express-validator';
export const validateCustomField = [ export const validateCustomField = [
body('label') body('label')
.exists()
.isString() .isString()
.trim() .trim()
.notEmpty()
.custom((value) => { .custom((value) => {
return isAlphanumericWithSpace(value); return isAlphanumericWithSpace(value);
}), }),
body('type').isIn(['string', 'image']), body('type').exists().isIn(['string', 'image']),
body('colour').isString().trim(), body('colour').exists().isString().trim(),
requestValidationFunction, (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
export const validateEditCustomField = [ export const validateEditCustomField = [
param('key').isString().trim().notEmpty(), param('label').exists().isString().trim(),
body('label') body('label')
.exists()
.isString() .isString()
.trim() .trim()
.notEmpty()
.custom((value) => { .custom((value) => {
return isAlphanumericWithSpace(value); return isAlphanumericWithSpace(value);
}), }),
body('type').isIn(['string', 'image']), body('type').exists().isIn(['string', 'image']),
body('colour').isString().trim(), body('colour').exists().isString().trim(),
requestValidationFunction, (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
export const validateDeleteCustomField = [param('key').isString().notEmpty(), requestValidationFunction]; export const validateDeleteCustomField = [
param('label').exists().isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -1,56 +0,0 @@
/* eslint-disable no-console -- we are mocking the console */
import { demoDb } from '../../../models/demoProject.js';
import { parseDatabaseModel } from '../db.parser.js';
// mock data provider
beforeAll(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setRundown: vi.fn().mockImplementation((newData) => newData),
setCustomFields: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('test parseDatabaseModel() with demo project (valid)', () => {
const filteredDemoProject = structuredClone(demoDb);
const { data } = parseDatabaseModel(filteredDemoProject);
it('has 17 events with 12 top level events', () => {
expect(data.rundowns.default.order.length).toBe(12);
expect(Object.keys(data.rundowns.default.entries).length).toBe(17);
});
it('is the same as the demo project since all data is valid', () => {
// @ts-expect-error -- its ok
delete filteredDemoProject.settings.version;
// @ts-expect-error -- its ok
delete data.settings.version;
expect(data).toMatchObject(filteredDemoProject);
});
});
describe('test parseDatabaseModel() edge cases', () => {
it('skips unknown app and version settings', () => {
console.log = vi.fn();
const testData = {
settings: {
osc_port: 8888,
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel(testData)).toThrow();
});
it('fails with invalid JSON', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel('some random dataset')).toThrow();
});
});
+9 -18
View File
@@ -58,7 +58,6 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
backstageUrl: req.body?.backstageUrl ?? '', backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '', backstageInfo: req.body?.backstageInfo ?? '',
projectLogo: req.body?.projectLogo ?? null, projectLogo: req.body?.projectLogo ?? null,
custom: req.body?.custom ?? [],
}, },
}); });
@@ -106,8 +105,7 @@ export async function projectDownload(req: Request, res: Response) {
const { filename } = req.body; const { filename } = req.body;
const pathToFile = doesProjectExist(filename); const pathToFile = doesProjectExist(filename);
if (!pathToFile) { if (!pathToFile) {
res.status(404).send({ message: `Project ${filename} not found.` }); return res.status(404).send({ message: `Project ${filename} not found.` });
return;
} }
res.download(pathToFile, filename, (error) => { res.download(pathToFile, filename, (error) => {
@@ -139,8 +137,7 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
if (message.startsWith('Project file')) { if (message.startsWith('Project file')) {
res.status(403).send({ message }); return res.status(403).send({ message });
return;
} }
res.status(400).send({ message }); res.status(400).send({ message });
} }
@@ -197,8 +194,7 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
if (message.startsWith('Project file')) { if (message.startsWith('Project file')) {
res.status(403).send({ message }); return res.status(403).send({ message });
return;
} }
res.status(500).send({ message }); res.status(500).send({ message });
} }
@@ -207,7 +203,7 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
/** /**
* Loads the demo project * Loads the demo project
*/ */
export async function loadDemo(_req: Request, res: Response<MessageResponse | ErrorResponse>) { export async function loadDemo(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try { try {
const projectName = await projectService.loadDemoProject(); const projectName = await projectService.loadDemoProject();
@@ -217,8 +213,7 @@ export async function loadDemo(_req: Request, res: Response<MessageResponse | Er
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
if (message.startsWith('Project file')) { if (message.startsWith('Project file')) {
res.status(403).send({ message }); return res.status(403).send({ message });
return;
} }
res.status(500).send({ message }); res.status(500).send({ message });
} }
@@ -249,8 +244,7 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
if (message.startsWith('Project file')) { if (message.startsWith('Project file')) {
res.status(403).send({ message }); return res.status(403).send({ message });
return;
} }
res.status(500).send({ message }); res.status(500).send({ message });
@@ -280,8 +274,7 @@ export async function renameProjectFile(req: Request, res: Response<MessageRespo
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
if (message.startsWith('Project file')) { if (message.startsWith('Project file')) {
res.status(403).send({ message }); return res.status(403).send({ message });
return;
} }
res.status(500).send({ message }); res.status(500).send({ message });
@@ -309,12 +302,10 @@ export async function deleteProjectFile(req: Request, res: Response<MessageRespo
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
if (message === 'Cannot delete currently loaded project') { if (message === 'Cannot delete currently loaded project') {
res.status(403).send({ message }); return res.status(403).send({ message });
return;
} }
if (message === 'Project file not found') { if (message === 'Project file not found') {
res.status(404).send({ message }); return res.status(404).send({ message });
return;
} }
res.status(500).send({ message }); res.status(500).send({ message });
+2 -1
View File
@@ -1,10 +1,11 @@
import type { Request } from 'express'; import type { Request } from 'express';
import multer, { type FileFilterCallback } from 'multer'; import multer, { type FileFilterCallback } from 'multer';
import { JSON_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js'; import { storage } from '../../utils/upload.js';
const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => { const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes('application/json')) { if (file.mimetype.includes(JSON_MIME)) {
cb(null, true); cb(null, true);
} else { } else {
cb(null, false); cb(null, false);
-49
View File
@@ -1,49 +0,0 @@
import { DatabaseModel, LogOrigin } from 'ontime-types';
import { logger } from '../../classes/Logger.js';
import { parseAutomationSettings } from '../automation/automation.parser.js';
import { parseProjectData } from '../project-data/projectData.parser.js';
import { parseRundowns } from '../rundown/rundown.parser.js';
import { parseSettings } from '../settings/settings.parser.js';
import { parseUrlPresets } from '../url-presets/urlPresets.parser.js';
import { parseViewSettings } from '../view-settings/viewSettings.parser.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
type ParsingError = {
context: string;
message: string;
};
/**
* @description handles parsing of ontime project file
* @param {object} jsonData - project file to be parsed
* @returns {object} - parsed object
*/
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: DatabaseModel; errors: ParsingError[] } {
// we need to parse settings first to make sure the data is ours
// this may throw
const settings = parseSettings(jsonData);
const errors: ParsingError[] = [];
const makeEmitError = (context: string) => (message: string) => {
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
errors.push({ context, message });
};
// we need to parse the custom fields first so they can be used in validating events
const customFields = parseCustomFields(jsonData, makeEmitError('Custom Fields'));
const rundowns = parseRundowns(jsonData, customFields, makeEmitError('Rundowns'));
const data: DatabaseModel = {
rundowns,
project: parseProjectData(jsonData, makeEmitError('Project')),
settings,
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
urlPresets: parseUrlPresets(jsonData, makeEmitError('URL Presets')),
customFields,
automation: parseAutomationSettings(jsonData),
};
return { data, errors };
}

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