Compare commits

..

80 Commits

Author SHA1 Message Date
Carlos Valente 1a95bc43a3 folow selected 2025-06-17 06:31:46 +02:00
Carlos Valente 1604762f2b style tweaks 2025-06-17 06:31:46 +02:00
Carlos Valente 2f8f4ad129 wip: countdown view redesign 2025-06-17 06:31:46 +02:00
Carlos Valente 0c7c90cae4 refactor: update flat rundown 2025-06-17 06:31:46 +02:00
Carlos Valente 1148ac1924 refactor: remove public view 2025-06-17 06:31:46 +02:00
Carlos Valente 100fbabfc6 refactor: inactivity checks keyboard and has initial state 2025-06-17 06:31:46 +02:00
Carlos Valente 42810a97ca refactor: small behaviour tweaks 2025-06-17 06:31:46 +02:00
Carlos Valente 05a9b2af1a refactor: action-blue is from blue scale 2025-06-17 06:31:46 +02:00
Carlos Valente 1bac3a7fd0 refactor: style tweaks to view styles 2025-06-17 06:31:46 +02:00
Carlos Valente 8281314ede refactor: use ontime button in view 2025-06-17 06:31:46 +02:00
Carlos Valente 3e0b3f100e refactor: create reusable buttons 2025-06-17 06:31:46 +02:00
google-labs-jules[bot] fb93063b47 refactor: style tweaks to edit css modal 2025-06-13 10:36:48 +02:00
google-labs-jules[bot] 24d3bf05ca refactor: remove usages of framer-motion 2025-06-13 10:36:26 +02:00
Alex Christoffer Rasmussen a4b15970de Upgrade expressjs (#1633)
* upgrade expressjs

* migration

* reenable test

* extend timeout on download test

* fixup! migration

* move empty body test from controller to validator

* enusre not empty

* extract validation function

* fixup! reenable test

* remove thin controllers

* disable e2e test of project file download
2025-06-12 15:02:35 +02:00
Alex Christoffer Rasmussen 2a5b053d97 Refactor: require trigger in all events objects (#1636)
* reqire trigger in all events

* use structuredClone when cloning an event
2025-06-12 09:54:57 +02:00
google-labs-jules[bot] 02c30c9681 Fix: Correct boundary condition in applyDelay
The `applyDelay` function had a condition that incorrectly used `rundown.order.length` instead of `rundownMetadata.flatEntryOrder.length` to check if a delay entry was the last in the sequence. `rundown.order` only contains top-level entries, while `flatEntryOrder` contains all entries, including those within blocks, which is the relevant list for this check.

This commit corrects the condition to use `rundownMetadata.flatEntryOrder.length`.

Existing tests in `rundown.dao.test.ts` (specifically the test `removes delays in last position without applying changes`) already cover this scenario and pass with the correction, ensuring the fix behaves as expected.
2025-06-10 21:37:40 +02:00
Alex Christoffer Rasmussen 2de7221653 let vite be the proxy to the dev server (#1630) 2025-06-09 14:12:59 +02:00
Carlos Valente 2498e59156 refactor: migrate custom fields to transactions
refactor: extract functions to api domain

refactor: strict custom field parsing

refactor: remove rundown cache utilities

refactor: directory restructure
2025-06-09 14:12:59 +02:00
Carlos Valente f3b4ea0155 refactor: simplify validations 2025-06-09 14:12:58 +02:00
Carlos Valente aebf949883 refactor: create transaction system and apply to adding entry (#1620)
* refactor: create transaction system and apply to adding entry

* refactor: migrate edit mutations to transaction

* refactor: migrate delete mutation to transaction

* refactor: migrate reorder mutation to transaction

* refactor: migrate apply delay to transaction

* refactor: migrate swapEvents to transaction

* refactor: migrate clone to transaction

* refactor: migrate group/ungroup transactions

* refactor: simplify mutations

* refactor: migrate getters

* chore: add tests to processRundown()
2025-06-09 14:12:58 +02:00
Alex Christoffer Rasmussen 33ac05ebee Refactor: better rounding (#1594) 2025-06-09 14:12:58 +02:00
Carlos Valente 421183fe55 refactor: improve reorder logic 2025-06-09 14:12:58 +02:00
Carlos Valente b66c19769d chore: simplify URLs 2025-06-09 14:12:57 +02:00
Carlos Valente 7b8445597f refactor: order is single source of truth 2025-06-09 14:12:57 +02:00
Carlos Valente 0b719c485f refactor: refetch targets is enum 2025-06-09 14:12:57 +02:00
Carlos Valente 17deeddb87 refactor: small ux improvements
- rename dissolve > ungroup
- prevent ondrag when clicking
- add untitled as block title fallback
- move block action to context menu
2025-06-09 14:12:57 +02:00
Carlos Valente 2546159a94 refactor: remove trivially inferred numEvents 2025-06-09 14:12:56 +02:00
Carlos Valente 2f26f20db5 feat: duplicate groups 2025-06-09 14:12:56 +02:00
Carlos Valente 11e0215530 feat: create group from entry selection 2025-06-09 14:12:56 +02:00
Carlos Valente ea54953298 feat: create block from rundown empty 2025-06-09 14:12:56 +02:00
Carlos Valente d21f132674 fix: collapsed blocks dont render children 2025-06-09 14:12:56 +02:00
Carlos Valente c7b2f0e89c refactor: type cleanup and test improvements 2025-06-09 14:12:56 +02:00
Carlos Valente a1505606cf feat: allow dissolving a block 2025-06-09 14:12:56 +02:00
Carlos Valente 9edff55bd5 fix: uncontrolled prop on controlled component 2025-06-09 14:12:56 +02:00
Carlos Valente e287215179 refactor: improve return of reorder 2025-06-09 14:12:56 +02:00
Carlos Valente 776649c997 refactor: mutations on batch elements must have IDs 2025-06-09 14:12:56 +02:00
Carlos Valente 9de5e0ac48 chore: upgrade dependencies 2025-06-09 14:12:55 +02:00
Carlos Valente c7d99073f6 refactor: extract utility to merge two arrays 2025-06-09 14:12:55 +02:00
Carlos Valente 5c1694d802 refactor: change network mode defaults 2025-06-09 14:12:55 +02:00
Carlos Valente 7cd319a6b3 fix: delete nested events 2025-06-09 14:12:55 +02:00
Carlos Valente 2c9b6918e6 fix: add event at end of block 2025-06-09 14:12:55 +02:00
Carlos Valente a50cfad7ef refactor: make finder available in exported rundown 2025-06-09 14:12:55 +02:00
Alex Christoffer Rasmussen 66cd1decde assert non null and update test (#1604) 2025-06-09 14:12:55 +02:00
Alex Christoffer Rasmussen c9bf6d9812 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-06-09 14:12:55 +02:00
Alex Christoffer Rasmussen 8557f64382 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-06-09 14:12:55 +02:00
Alex Christoffer Rasmussen 8e994fb0df Refactor: ms for api calls (#1593)
* use ms for api calls

* change it in UI

* update comment

* rename variables

* refactor: addtime api logic
2025-06-09 14:12:55 +02:00
Alex Christoffer Rasmussen 5c47a834b5 fix test (#1601)
* fix test

* add comment
2025-06-09 14:12:55 +02:00
Carlos Valente f356afef43 fix: rebase master 2025-06-09 14:12:55 +02:00
Carlos Valente eba5252e40 chore: correct test path 2025-06-09 14:12:54 +02:00
Carlos Valente 72c4ad789a refactor: extract rundown parsing
refactor: implement groups in editor
2025-06-09 14:12:54 +02:00
Carlos Valente 3118c20719 chore: improve convention entry <> event 2025-06-09 14:12:54 +02:00
Carlos Valente 3754c374c3 refactor: maintain flat orders 2025-06-09 14:12:54 +02:00
Carlos Valente dd6d74121c refactor: implement operations on nested events 2025-06-09 14:12:54 +02:00
Carlos Valente 6fb42b989e refactor: process events in rundown 2025-06-09 14:12:54 +02:00
Carlos Valente 82dc1bc56b chore: improve convention entry <> event 2025-06-09 14:12:54 +02:00
Carlos Valente d2c7b34142 chore: rename currentBlock > parent 2025-06-09 14:12:53 +02:00
Carlos Valente 918f8d1b08 refactor: fix delay positioning in gaps 2025-06-09 14:12:53 +02:00
Carlos Valente fcbcf70e01 refactor(e2e): skip flaky test 2025-06-09 14:12:53 +02:00
Carlos Valente 5c6b06a0d4 refactor: improve project loading 2025-06-09 14:12:53 +02:00
Carlos Valente fc4c1bf22e refactor: gather group metadata 2025-06-09 14:12:53 +02:00
Carlos Valente 84fac739f0 refactor: swap maintains schedule 2025-06-09 14:12:53 +02:00
Carlos Valente ef73c87e2f chore: rename files 2025-06-09 14:12:53 +02:00
Carlos Valente 26ee5768a5 refactor: restructure model to contain an object of rundowns 2025-06-09 14:12:53 +02:00
Carlos Valente 27dcf06c73 refactor: clearer relationship on rundown elements 2025-06-09 14:12:52 +02:00
Carlos Valente d48b2ae506 refactor: use strict typing 2025-06-09 14:12:52 +02:00
Carlos Valente 14a7c04d3b refactor: remove stop as a possible end action 2025-06-09 14:12:52 +02:00
Carlos Valente abd9b127db refactor: restructure model to contain an object of rundowns 2025-06-09 14:12:51 +02:00
Carlos Valente 0a80d6db31 chore: remove IDE files 2025-06-09 14:12:51 +02:00
Carlos Valente 977c99e587 refactor: remove unused and legacy code
- remove legacy migrations
- remove unused server code
- remove unused UI code
2025-06-09 14:12:51 +02:00
jwetzell fe8bbd5003 add electron and esbuild to allow list for postinstall builds 2025-06-06 20:25:46 +02:00
jwetzell c6e38a2b4a upgrade vite to v6 (#1626) 2025-06-05 06:27:59 +02:00
jwetzell 56f18fe71c update node and pnpm versions in README 2025-06-05 06:13:04 +02:00
jwetzell 0b9306de82 update pnpm to latest v10 2025-06-05 06:13:04 +02:00
jwetzell 3b1e6cf33d update node version in workflows 2025-06-05 06:13:04 +02:00
jwetzell dda219ebbe update NodeJS to v22 2025-06-05 06:13:04 +02:00
jwetzell 8bb771e5c6 upgrade electron and electron-builder 2025-06-05 06:13:04 +02:00
Shobhit Nagpal 8723bcbd33 refactor: block event xlsx import (#1623) 2025-06-02 06:56:46 +02:00
Shobhit Nagpal 0dcea4f2d7 fix: pass empty string fallback when parsing fields for export (#1616) 2025-05-25 19:58:41 +02:00
Shobhit Nagpal 696c016c90 feat: custom data for projects (#1571) 2025-05-23 09:04:57 +02:00
Shobhit Nagpal eed6373dbf refactor: change offset classes depending on playback state (#1611)
* refactor: assign offsetClasses based on playback state
2025-05-23 09:04:57 +02:00
214 changed files with 8193 additions and 6647 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: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- 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: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- 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: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- 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: 20 node-version: 22
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: 9 version: 10
- 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: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- 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: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1 +1 @@
v20.15.1 v22.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__ (~20) - __node__ (~22)
- __pnpm__ (~9) - __pnpm__ (~10)
- __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:20-bullseye AS builder FROM node:22-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@9.5.0 RUN npm install -g pnpm@10.11.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:20-alpine FROM node:22-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.2.1", "@vitejs/plugin-react": "4.5.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": "^5.2.11", "vite": "6.3.1",
"vite-plugin-compression2": "^1.3.3", "vite-plugin-compression2": "1.4.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "^4.3.1", "vite-tsconfig-paths": "5.1.4",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }
+1 -11
View File
@@ -25,11 +25,10 @@ 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('./features/viewers/countdown/Countdown')); const Countdown = React.lazy(() => import('./views/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'));
@@ -40,7 +39,6 @@ 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));
@@ -88,14 +86,6 @@ 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, CustomFieldLabel, CustomFields } from 'ontime-types'; import { CustomField, CustomFieldKey, 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(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> { export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField }); const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
return res.data; return res.data;
} }
/** /**
* Deletes single custom field * Deletes single custom field
*/ */
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> { export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${label}`); const res = await axios.delete(`${customFieldsPath}/${key}`);
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<MessageResponse>> { export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
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<MessageResponse>> { export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`); return axios.patch(`${rundownPath}/applydelay/${delayId}`);
} }
@@ -0,0 +1,61 @@
@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;
}
@@ -0,0 +1,20 @@
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,21 +1,28 @@
.subtle { @use '../../../theme/viewerDefs' as *;
.baseIconButton {
aspect-ratio: 1; aspect-ratio: 1;
height: 2rem; height: 2rem;
height: 2rem; width: 2rem;
display: grid; display: grid;
place-content: center; place-content: center;
background: $gray-1050;
color: $blue-400;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 3px; border-radius: 3px;
cursor: pointer;
}
.subtle {
background: $gray-1050;
color: $blue-400;
&:hover:not(:disabled):not(:active) { &:hover:not(:disabled):not(:active) {
background: $gray-1000; background: $gray-1000;
color: $blue-500; color: $blue-500;
} }
&:active { &:active:not(:disabled){
background: $gray-1100; background: $gray-1100;
border-color: $gray-1250; border-color: $gray-1250;
} }
@@ -24,3 +31,37 @@
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,10 +4,15 @@ import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss'; import style from './IconButton.module.scss';
export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) { interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
const { className, children, ...buttonProps } = props; variant?: 'subtle' | 'subtle-white' | 'destructive';
}
export default function IconButton(props: IconButtonProps) {
const { className, children, variant = 'subtle', ...buttonProps } = props;
return ( return (
<button className={cx([style.subtle, className])} {...buttonProps}> <button className={cx([style.baseIconButton, style[variant], className])} type='button' {...buttonProps}>
{children} {children}
</button> </button>
); );
@@ -3,6 +3,7 @@ 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';
@@ -17,22 +18,24 @@ 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])}>
<button <IconButton
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 />
</button> </IconButton>
<button <IconButton
className={style.button} variant='subtle-white'
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 />
</button> </IconButton>
</div> </div>
); );
} }
@@ -3,8 +3,6 @@
$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 {
@@ -15,6 +13,7 @@ $button-size: 3rem;
&.hidden { &.hidden {
opacity: 0; opacity: 0;
pointer-events: none;
} }
} }
@@ -41,20 +40,10 @@ $button-size: 3rem;
z-index: 12; z-index: 12;
} }
.button {
font-size: 1.5rem;
color: $icon-color;
background-color: $button-bg;
width: $button-size;
height: $button-size;
display: grid;
place-content: center;
border-radius: 3px;
}
.navButton { .navButton {
@extend .button; font-size: 1.5rem;
z-index: 3; height: $button-size;
width: $button-size;;
} }
.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.order.map((id) => data.entries[id]); const flatRundown = data.flatOrder.map((id) => data.entries[id]);
setFlatRundown(flatRundown); setFlatRundown(flatRundown);
setPrevRevision(data.revision); setPrevRevision(data.revision);
} }
}, [data.entries, data.order, data.revision, prevRevision]); }, [data.entries, data.flatOrder, 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
+25 -2
View File
@@ -389,8 +389,18 @@ export const useEntryActions = () => {
// Return a context with the previous rundown // Return a context with the previous rundown
return { previousRundown }; return { previousRundown };
}, },
onSettled: async () => { onSuccess: (response) => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN }); if (!response.data) return;
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);
@@ -521,6 +531,19 @@ 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,31 +2,38 @@ import { useEffect, useState } from 'react';
import { throttle } from '../utils/throttle'; import { throttle } from '../utils/throttle';
const inactiveTime = 3000; // 3 seconds
export const useFadeOutOnInactivity = () => { /**
const [isMouseMoved, setIsMouseMoved] = useState(false); * Provides whether there has been mouse movement in the page in the last <inactiveTime>
*/
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 = () => {
setIsMouseMoved(true); setIsUserActive(true);
if (fadeOut) { if (fadeOut) {
clearTimeout(fadeOut); clearTimeout(fadeOut);
} }
fadeOut = setTimeout(() => setIsMouseMoved(false), 3000); fadeOut = setTimeout(() => setIsUserActive(false), inactiveTime);
}; };
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 isMouseMoved; return isUserActive;
}; };
+5 -1
View File
@@ -164,11 +164,15 @@ 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, //The day of the currently running event currentDay: state.eventNow?.dayOffset ?? 0,
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,4 +8,5 @@ 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_98a1e0 _another_98a1e0"`; exports[`cx() > merges styles 1`] = `"_test_d5d33f _another_d5d33f"`;
@@ -28,6 +28,7 @@ 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,
@@ -36,6 +37,7 @@ 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,
@@ -59,6 +61,8 @@ describe('cloneEvent()', () => {
gap: 0, gap: 0,
timeWarning: original.timeWarning, timeWarning: original.timeWarning,
timeDanger: original.timeDanger, timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
}); });
}); });
}); });
+2 -1
View File
@@ -30,6 +30,7 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
gap: 0, gap: 0,
timeWarning: event.timeWarning, timeWarning: event.timeWarning,
timeDanger: event.timeDanger, timeDanger: event.timeDanger,
custom: { ...event.custom }, triggers: structuredClone(event.triggers),
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.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND); const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
if (seconds > 0) { if (seconds > 0) {
result += `${seconds}s`; result += `${seconds}s`;
} }
+1 -4
View File
@@ -49,10 +49,7 @@ 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
if (isDev) { // this is done with a proxy in the vite config to avoid CORS issues in the dev environment
// 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, CustomFieldLabel } from 'ontime-types'; import { CustomField, CustomFieldKey } 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: (label: CustomFieldLabel, patch: CustomField) => Promise<void>; onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
onDelete: (label: CustomFieldLabel) => Promise<void>; onDelete: (key: CustomFieldKey) => 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, CustomFieldLabel } from 'ontime-types'; import { CustomField, CustomFieldKey } 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 (label: CustomFieldLabel, customField: CustomField) => { const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(label, customField); await editCustomField(key, customField);
refetch(); refetch();
}; };
const handleDelete = async (label: string) => { const handleDelete = async (key: CustomFieldKey) => {
try { try {
await deleteCustomField(label); await deleteCustomField(key);
refetch(); refetch();
} catch (_error) { } catch (_error) {
/** we do not handle errors here */ /** we do not handle errors here */
@@ -8,7 +8,5 @@
} }
.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={!data.overrideStyles} isDisabled={isSubmitting}
width='fit-content' width='fit-content'
> >
Edit CSS override Edit CSS override
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@@ -23,6 +24,7 @@ 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) {
@@ -34,6 +36,7 @@ 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>({
@@ -44,6 +47,11 @@ 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');
@@ -59,6 +67,7 @@ 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) {
@@ -66,6 +75,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
} }
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
return ( return (
<Panel.Section <Panel.Section
as='form' as='form'
@@ -151,6 +164,42 @@ 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 { useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoDownloadOutline, IoTrash } from 'react-icons/io5'; import { IoAdd, 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,6 +25,7 @@ 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,
@@ -32,6 +33,12 @@ 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
@@ -77,6 +84,10 @@ 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);
@@ -231,6 +242,69 @@ 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,3 +57,18 @@
height: auto; height: auto;
} }
} }
.customDataItem {
display: contents;
width: 100%;
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
@@ -118,7 +118,8 @@
padding-inline: 0.25rem; padding-inline: 0.25rem;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
white-space: pre; // allow multi-line text but trim before
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 Field', title: 'Highlight Fields',
description: 'Choose a custom field to highlight', description: 'Choose custom fields to highlight',
type: 'multi-option', type: 'multi-option',
values: customFieldSelect, values: customFieldSelect,
}, },
@@ -45,6 +45,10 @@
@include ellipsis-overflow; @include ellipsis-overflow;
} }
.offset {
color: $muted-gray;
}
.ahead { .ahead {
color: $playback-ahead; color: $playback-ahead;
} }
@@ -1,4 +1,5 @@
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';
@@ -163,10 +164,10 @@ function ProgressOverview() {
} }
function RuntimeOverview() { function RuntimeOverview() {
const { clock, offset } = useRuntimePlaybackOverview(); const { clock, offset, playback } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset); const offsetText = getOffsetText(offset);
const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead; const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
return ( return (
<> <>
@@ -1,5 +1,5 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { CustomFieldLabel, OntimeEvent } from 'ontime-types'; import { 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,7 +14,8 @@ import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss'; import style from './EventEditor.module.scss';
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel; // any of the titles + custom field labels
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 !== undefined && triggers.length > 0; const showTriggers = 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) { if (timer == null || timerType === TimerType.None) {
return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder; return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder;
} }
@@ -98,7 +98,7 @@ export function getFormattedTimer(
} }
} }
let display = millisToString(timeToParse); let display = millisToString(timeToParse, { direction: timerType });
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 { fetchTimerData, getTimerItems, TimerMessage } from './countdown.helpers'; import { getSubscriptionDisplayData, getTimerItems, TimerMessage } from '../../../views/countdown/countdown.utils';
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 } = fetchTimerData(time, follow, selectedId, runtime.offset); const { message: runningMessage, timer: runningTimer } = getSubscriptionDisplayData(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,8 +4,7 @@ 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 { fetchTimerData, sanitiseTitle, TimerMessage } from '../countdown.helpers'; import { getSubscriptionDisplayData, sanitiseTitle, TimerMessage } from '../../../../views/countdown/countdown.utils';
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 } = fetchTimerData(time, follow, followId); const { message, timer } = getSubscriptionDisplayData(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 } = fetchTimerData(time, follow, 'notthesameevent'); const { message, timer } = getSubscriptionDisplayData(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 } = fetchTimerData(time, follow, 'notthesameevent'); const { message, timer } = getSubscriptionDisplayData(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 } = fetchTimerData(time, follow, 'notthesameevent'); const { message, timer } = getSubscriptionDisplayData(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 } = fetchTimerData(time, follow, 'notthesameevent'); const { message, timer } = getSubscriptionDisplayData(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 } = fetchTimerData(time, follow, followId); const { message, timer } = getSubscriptionDisplayData(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 } = fetchTimerData(time, follow, 'notthesameevent'); const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.toStart); expect(message).toBe(TimerMessage.toStart);
expect(timer).toBe(startMockValue - timeNow); expect(timer).toBe(startMockValue - timeNow);
}); });
@@ -1,106 +0,0 @@
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: #3182ce; $action-blue: $blue-500;
$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(28px, 3.5vw, 64px); $header-font-size: clamp(24px, 2.5vw, 48px);
// 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, 7%); // --card-background-color-override $viewer-card-bg-color: rgba(white, 5%); // --card-background-color-override
$element-border-radius: 8px; $element-border-radius: 4px;
// 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: '#3182ce', // $action-blue borderColor: '#578AF4', // $blue-500
backgroundColor: '#3182ce', //$action-blue backgroundColor: '#578AF4', // $blue-500
_disabled: { _disabled: {
color: 'white', color: 'white',
borderColor: '#3182ce', // $action-blue borderColor: '#578AF4', // $blue-500
backgroundColor: '#3182ce', //$action-blue backgroundColor: '#578AF4', // $blue-500
opacity: 0.6, opacity: 0.6,
}, },
}, },
+1
View File
@@ -27,6 +27,7 @@ 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: '#3182ce', // $action-blue color: '#578AF4', // $blue-500
backgroundColor: '#3182ce', // $action-blue backgroundColor: '#578AF4', // $blue-500
}, },
_hover: { _hover: {
color: '#3182ce', // $action-blue color: '#578AF4', // $blue-500
backgroundColor: '#3182ce', // $action-blue backgroundColor: '#578AF4', // $blue-500
outline: 'none', outline: 'none',
}, },
}, },
@@ -28,8 +28,10 @@ const translationsList = {
zh: langZhCn, zh: langZhCn,
}; };
export type TranslationKey = keyof typeof langEn;
interface TranslationContextValue { interface TranslationContextValue {
getLocalizedString: (key: keyof typeof langEn, lang?: string) => string; getLocalizedString: (key: TranslationKey, lang?: string) => string;
} }
const TranslationContext = createContext<TranslationContextValue>({ const TranslationContext = createContext<TranslationContextValue>({
@@ -40,7 +42,7 @@ export const TranslationProvider = ({ children }: PropsWithChildren) => {
const { data } = useSettings(); const { data } = useSettings();
const getLocalizedString = useCallback( const getLocalizedString = useCallback(
(key: keyof typeof langEn, lang = data?.language || 'en'): string => { (key: TranslationKey, 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' /> : <div className='logo' />} {general?.projectLogo && <ViewLogo name={general.projectLogo} 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} isBackstage />} {showSchedule && <ScheduleExport selectedId={selectedId} />}
<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,18 +1,16 @@
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({ isProduction, className }: ScheduleProps) { export default function Schedule({ className }: ScheduleProps) {
const { events, isBackstage, containerRef } = useSchedule(); const { events, containerRef } = useSchedule();
if (events?.length < 1) { if (events?.length < 1) {
return null; return null;
@@ -21,18 +19,15 @@ export default function Schedule({ isProduction, 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={timeStart} timeStart={event.timeStart}
timeEnd={timeEnd} timeEnd={event.timeEnd}
title={event.title} title={event.title}
colour={isBackstage ? event.colour : undefined} colour={event.colour}
backstageEvent={!event.isPublic}
skip={event.skip} skip={event.skip}
delay={delay} delay={event.skip ? 0 : event.delay}
/> />
); );
})} })}
@@ -7,16 +7,15 @@ 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, isBackstage } = props; const { selectedId } = props;
return ( return (
<ScheduleProvider selectedEventId={selectedId} isBackstage={isBackstage}> <ScheduleProvider selectedEventId={selectedId}>
<ScheduleNav className='schedule-nav-container' /> <ScheduleNav className='schedule-nav-container' />
<Schedule isProduction={isBackstage} className='schedule-container' /> <Schedule className='schedule-container' />
</ScheduleProvider> </ScheduleProvider>
); );
} }
@@ -16,14 +16,13 @@ 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, backstageEvent, colour, skip, delay } = props; const { timeStart, timeEnd, title, colour, skip, delay } = props;
const { showProjected } = useScheduleOptions(); const { showProjected } = useScheduleOptions();
if (showProjected) { if (showProjected) {
@@ -33,7 +32,6 @@ 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}
/> />
@@ -47,7 +45,6 @@ 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}
/> />
@@ -63,7 +60,6 @@ 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>
@@ -71,7 +67,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
} }
function DelayedScheduleItem(props: ScheduleItemProps) { function DelayedScheduleItem(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props; const { timeStart, timeEnd, title, colour, skip, delay } = props;
const start = formatTime(timeStart, formatOptions); const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions); const end = formatTime(timeEnd, formatOptions);
@@ -86,13 +82,11 @@ 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>
@@ -101,7 +95,7 @@ function DelayedScheduleItem(props: ScheduleItemProps) {
} }
function ProjectedScheduleItem(props: ScheduleItemProps) { function ProjectedScheduleItem(props: ScheduleItemProps) {
const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props; const { timeStart, timeEnd, title, colour, skip, delay } = props;
return ( return (
<li className={cx(['entry', skip && 'entry--skip'])}> <li className={cx(['entry', skip && 'entry--skip'])}>
@@ -110,7 +104,6 @@ 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,
}, },
@@ -1,19 +0,0 @@
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,
};
}
@@ -0,0 +1,197 @@
@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;
}
}
@@ -0,0 +1,125 @@
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}
/>
);
}
@@ -0,0 +1,96 @@
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>
);
}
@@ -0,0 +1,160 @@
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>
</>
);
}
@@ -0,0 +1,107 @@
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;
}
@@ -0,0 +1,194 @@
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,11 +96,12 @@ 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",
"...", "",
"", "",
"", "",
"", "",
@@ -109,6 +110,7 @@ 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); return millisToString(data as MaybeNumber, { fallback: '' });
} }
if (field === 'isPublic' || field === 'skip') { if (field === 'isPublic' || field === 'skip') {
@@ -55,6 +55,7 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeEntry[], custo
'isPublic', 'isPublic',
'skip', 'skip',
...customFieldKeys, ...customFieldKeys,
'type',
]; ];
const fieldTitles = [ const fieldTitles = [
@@ -69,6 +70,7 @@ 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,7 +25,9 @@
flex: 1; flex: 1;
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
width: min(calc(100vw - 4rem), 800px); width: min(calc(100vw - 4rem), 960px);
padding-bottom: 10vh;
} }
.info__label { .info__label {
@@ -33,11 +35,15 @@
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,6 +8,7 @@ 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';
@@ -66,6 +67,7 @@ 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>
); );
@@ -0,0 +1,36 @@
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,14 +9,21 @@ export const projectInfoOptions: ViewOption[] = [
{ {
id: 'showBackstage', id: 'showBackstage',
title: 'Show backstage Data', title: 'Show backstage Data',
description: 'Weather to show fields related to the backstage views', description: 'Whether 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: 'Weather to show fields related to the public views', description: 'Whether 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
@@ -1,204 +0,0 @@
@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
@@ -1,126 +0,0 @@
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>
);
}
@@ -1,58 +0,0 @@
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;
}
@@ -1,45 +0,0 @@
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;
}
+3 -33
View File
@@ -1,4 +1,3 @@
import { AnimatePresence } from 'framer-motion';
import { import {
CustomFields, CustomFields,
MessageState, MessageState,
@@ -11,6 +10,7 @@ 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,7 +21,6 @@ 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,
@@ -189,37 +188,8 @@ export default function Timer(props: TimerProps) {
{!hideCards && ( {!hideCards && (
<> <>
<AnimatePresence> {showNow && <TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />}
{showNow && ( {showNext && <TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />}
<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>
@@ -1,20 +0,0 @@
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);
+32 -3
View File
@@ -39,6 +39,29 @@ 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,
@@ -70,11 +93,17 @@ export default defineConfig({
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
additionalData: ` additionalData: `
@use './src/theme/ontimeColours' as *; @use '@/theme/ontimeColours' as *;
@use './src/theme/ontimeStyles' as *; @use '@/theme/ontimeStyles' as *;
@use './src/theme/mixins' as *; @use '@/theme/mixins' as *;
`, `,
}, },
}, },
}, },
}); });
function logProxyRequests(proxy) {
proxy.on('proxyReq', (_proxyReq, req, _res) => {
console.log('Proxy:', req.method, req.url);
});
}
+3 -5
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": "^31.2.0", "electron": "36.3.1",
"electron-builder": "^24.13.3", "electron-builder": "26.0.12",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",
@@ -38,9 +38,7 @@
"icon": "icon.icns" "icon": "icon.icns"
}, },
"mac": { "mac": {
"notarize": { "notarize": true,
"teamId": "MDAU6QK6R4"
},
"hardenedRuntime": true, "hardenedRuntime": true,
"gatekeeperAssess": false, "gatekeeperAssess": false,
"entitlements": "./entitlements.plist", "entitlements": "./entitlements.plist",
+11 -12
View File
@@ -6,19 +6,18 @@
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
"classic-level": "^3.0.0", "cookie": "1.0.2",
"cookie": "^1.0.2", "cookie-parser": "1.4.7",
"cookie-parser": "^1.4.7", "cors": "2.8.5",
"cors": "^2.8.5",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"express": "^4.21.1", "express": "5.1.0",
"express-static-gzip": "^2.2.0", "express-static-gzip": "3.0.0",
"express-validator": "^7.2.0", "express-validator": "7.2.1",
"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",
@@ -27,10 +26,10 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "1.4.9",
"@types/cors": "^2.8.17", "@types/cors": "2.8.19",
"@types/express": "^4.17.17", "@types/express": "5.0.3",
"@types/multer": "^1.4.11", "@types/multer": "1.4.13",
"@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 '../externals.js'; import { isOntimeCloud } from '../setup/environment.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';
@@ -1,41 +0,0 @@
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,10 +1,40 @@
import express from 'express'; import express from 'express';
import type { Request, Response } from 'express';
import { getCssOverride, postCssOverride, restoreCss } from './assets.controller.js'; import type { ErrorResponse } from 'ontime-types';
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', getCssOverride); router.get('/css', async (_req: Request, res: Response<string | ErrorResponse>) => {
router.post('/css', validatePostCss, postCssOverride); try {
router.post('/css/restore', restoreCss); const data = await readCssFile();
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,12 +1,4 @@
import { Request, Response, NextFunction } from 'express'; import { body } from 'express-validator';
import { body, validationResult } from 'express-validator'; import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const validatePostCss = [ export const validatePostCss = [body('css').isString().trim(), requestValidationFunction];
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,4 +1,6 @@
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation } from 'ontime-types'; import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation, EntryId } from 'ontime-types';
import { makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import { import {
addTrigger, addTrigger,
@@ -12,6 +14,7 @@ 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(() => {
@@ -186,11 +189,9 @@ 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();
firstAutomation = await addAutomation({ await addAutomation({
title: 'test-osc', title: 'test-osc',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
@@ -198,35 +199,15 @@ describe('deleteAutomation()', () => {
}); });
}); });
it('should remove m automation from the list', async () => { it('should remove an 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);
await deleteAutomation(Object.keys(automations)[0]); const rundown = makeRundown({});
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 '../../../services/rundown-service/__mocks__/rundown.mocks.js'; import { makeOntimeEvent } from '../../rundown/__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,4 +1,6 @@
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js'; import { TimerLifeCycle } from 'ontime-types';
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', () => {
@@ -245,3 +247,53 @@ 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,12 +5,14 @@ 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.json(automationDao.getAutomationSettings()); res.status(200).json(automationDao.getAutomationSettings());
} }
export async function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) { export async function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) {
@@ -106,7 +108,10 @@ 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 {
await automationDao.deleteAutomation(req.params.id); const rundown = getCurrentRundown();
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,14 +2,17 @@ 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
@@ -133,7 +136,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(id: string): Promise<void> { export async function deleteAutomation(rundown: Rundown, timedEventOrder: EntryId[], 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)) {
@@ -149,13 +152,9 @@ export async function deleteAutomation(id: string): Promise<void> {
} }
// prevent deleting a automation that is in use in events // prevent deleting a automation that is in use in events
const events = getTimedEvents().filter( const isInUse = isAutomationUsed(rundown, timedEventOrder, id);
(event) => event.triggers && event.triggers.some((trigger) => trigger.automationId === id), if (isInUse) {
); 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/parser.js'; import type { ErrorEmitter } from '../../utils/parserUtils.js';
interface LegacyData extends Partial<DatabaseModel> { interface LegacyData extends Partial<DatabaseModel> {
http?: unknown; http?: unknown;
@@ -12,7 +12,6 @@ import {
testOutput, testOutput,
} from './automation.controller.js'; } from './automation.controller.js';
import { import {
paramContainsId,
validateAutomationSettings, validateAutomationSettings,
validateAutomation, validateAutomation,
validateAutomationPatch, validateAutomationPatch,
@@ -20,6 +19,7 @@ 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', paramContainsId, deleteTrigger); router.delete('/trigger/:id', paramsWithId, 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', paramContainsId, deleteAutomation); router.delete('/automation/:id', paramsWithId, 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 '../../externals.js'; import { isOntimeCloud } from '../../setup/environment.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 { FilterRule, MaybeNumber, OntimeAction } from 'ontime-types'; import { EntryId, FilterRule, isOntimeEvent, MaybeNumber, OntimeAction, Rundown } 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,3 +195,25 @@ 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,84 +10,50 @@ import {
} from 'ontime-types'; } from 'ontime-types';
import { parseUserTime } from 'ontime-utils'; import { parseUserTime } from 'ontime-utils';
import type { Request, Response, NextFunction } from 'express'; import { body, oneOf, param } from 'express-validator';
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').exists().isBoolean(), body('enabledAutomations').isBoolean(),
body('enabledOscIn').exists().isBoolean(), body('enabledOscIn').isBoolean(),
body('oscPortIn').exists().isPort(), body('oscPortIn').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),
(req: Request, res: Response, next: NextFunction) => { requestValidationFunction,
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
export const validateTrigger = [ export const validateTrigger = [
body('title').exists().isString().trim(), body('title').isString().trim().notEmpty(),
body('trigger').exists().isIn(timerLifecycleValues), body('trigger').isIn(timerLifecycleValues),
body('automationId').exists().isString().trim(), body('automationId').isString().trim().notEmpty(),
(req: Request, res: Response, next: NextFunction) => { requestValidationFunction,
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
export const validateTriggerPatch = [ export const validateTriggerPatch = [
param('id').exists(), param('id').isString().notEmpty(),
body('title').optional().isString().trim(), body('title').optional().isString().trim().notEmpty(),
body('trigger').optional().isIn(timerLifecycleValues), body('trigger').optional().isIn(timerLifecycleValues),
body('automationId').optional().isString().trim(), body('automationId').optional().isString().trim().notEmpty(),
(req: Request, res: Response, next: NextFunction) => { requestValidationFunction,
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
export const validateAutomation = [ export const validateAutomation = [body().custom(parseAutomation), requestValidationFunction];
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();
},
];
export const validateAutomationPatch = [ export const validateAutomationPatch = [
param('id').exists(), param('id').isString().notEmpty(),
body().custom(parseAutomation), body().custom(parseAutomation),
(req: Request, res: Response, next: NextFunction) => { requestValidationFunction,
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
/** /**
@@ -140,7 +106,7 @@ function validateOutput(output: Array<unknown>): output is AutomationOutput[] {
} }
export const validateTestPayload = [ export const validateTestPayload = [
body('type').exists().isIn(['osc', 'http', 'ontime']), body('type').isIn(['osc', 'http', 'ontime']),
// validation for OSC message // validation for OSC message
oneOf([ oneOf([
@@ -162,11 +128,7 @@ 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(),
(req: Request, res: Response, next: NextFunction) => { requestValidationFunction,
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
/** /**
@@ -1,81 +1,6 @@
import { CustomFields, Settings, URLPreset } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { import { parseCustomFields, sanitiseCustomFields } from '../customFields.parser.js';
parseCustomFields,
parseProject,
parseSettings,
parseUrlPresets,
parseViewSettings,
sanitiseCustomFields,
} from '../parserFunctions.js';
describe('parseProject()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseProject({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
expect(result).toMatchObject({
title: '',
description: '',
publicUrl: '',
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
});
});
});
describe('parseSettings()', () => {
it('throws if settings object does not exist', () => {
expect(() => parseSettings({})).toThrow();
});
it('returns an a base model as long as we have the app version', () => {
const result = parseSettings({ settings: { version: '1' } as Settings });
expect(result).toBeTypeOf('object');
expect(result).toMatchObject({
version: expect.any(String),
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
});
});
});
describe('parseViewSettings()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseViewSettings({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
});
describe('parseUrlPresets()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseUrlPresets({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn();
const urlPresets = [{ enabled: true, alias: 'alias', pathAndParams: 'ss' }] as URLPreset[];
const result = parseUrlPresets({ urlPresets }, errorEmitter);
expect(result.length).toEqual(1);
expect(result.at(0)).toMatchObject({
enabled: true,
alias: 'alias',
pathAndParams: 'ss',
});
expect(errorEmitter).not.toHaveBeenCalled();
});
});
describe('parseCustomFields()', () => { describe('parseCustomFields()', () => {
it('returns an a base model if nothing is given', () => { it('returns an a base model if nothing is given', () => {
@@ -1,51 +0,0 @@
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 });
}
}
@@ -0,0 +1,64 @@
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,14 +1,49 @@
import express from 'express'; import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
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('/', getCustomFields); router.get('/', async (_req: Request, res: Response<CustomFields>) => {
const customFields = getProjectCustomFields();
res.status(200).json(customFields);
});
router.post('/', validateCustomField, postCustomField); router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
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('/:label', validateEditCustomField, putCustomField); router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
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('/:label', validateDeleteCustomField, deleteCustomField); router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
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,51 +1,35 @@
import { isAlphanumericWithSpace } from 'ontime-utils'; import { isAlphanumericWithSpace } from 'ontime-utils';
import type { Request, Response, NextFunction } from 'express'; import { body, param } from 'express-validator';
import { body, param, validationResult } from 'express-validator'; import { requestValidationFunction } from '../validation-utils/validationFunction.js';
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').exists().isIn(['string', 'image']), body('type').isIn(['string', 'image']),
body('colour').exists().isString().trim(), body('colour').isString().trim(),
(req: Request, res: Response, next: NextFunction) => { requestValidationFunction,
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
export const validateEditCustomField = [ export const validateEditCustomField = [
param('label').exists().isString().trim(), param('key').isString().trim().notEmpty(),
body('label') body('label')
.exists()
.isString() .isString()
.trim() .trim()
.notEmpty()
.custom((value) => { .custom((value) => {
return isAlphanumericWithSpace(value); return isAlphanumericWithSpace(value);
}), }),
body('type').exists().isIn(['string', 'image']), body('type').isIn(['string', 'image']),
body('colour').exists().isString().trim(), body('colour').isString().trim(),
(req: Request, res: Response, next: NextFunction) => { requestValidationFunction,
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
]; ];
export const validateDeleteCustomField = [ export const validateDeleteCustomField = [param('key').isString().notEmpty(), requestValidationFunction];
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();
},
];
@@ -0,0 +1,56 @@
/* 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();
});
});
+18 -9
View File
@@ -58,6 +58,7 @@ 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 ?? [],
}, },
}); });
@@ -105,7 +106,8 @@ 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) {
return res.status(404).send({ message: `Project ${filename} not found.` }); res.status(404).send({ message: `Project ${filename} not found.` });
return;
} }
res.download(pathToFile, filename, (error) => { res.download(pathToFile, filename, (error) => {
@@ -137,7 +139,8 @@ 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')) {
return res.status(403).send({ message }); res.status(403).send({ message });
return;
} }
res.status(400).send({ message }); res.status(400).send({ message });
} }
@@ -194,7 +197,8 @@ 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')) {
return res.status(403).send({ message }); res.status(403).send({ message });
return;
} }
res.status(500).send({ message }); res.status(500).send({ message });
} }
@@ -203,7 +207,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();
@@ -213,7 +217,8 @@ export async function loadDemo(req: Request, res: Response<MessageResponse | Err
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
if (message.startsWith('Project file')) { if (message.startsWith('Project file')) {
return res.status(403).send({ message }); res.status(403).send({ message });
return;
} }
res.status(500).send({ message }); res.status(500).send({ message });
} }
@@ -244,7 +249,8 @@ 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')) {
return res.status(403).send({ message }); res.status(403).send({ message });
return;
} }
res.status(500).send({ message }); res.status(500).send({ message });
@@ -274,7 +280,8 @@ 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')) {
return res.status(403).send({ message }); res.status(403).send({ message });
return;
} }
res.status(500).send({ message }); res.status(500).send({ message });
@@ -302,10 +309,12 @@ 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') {
return res.status(403).send({ message }); res.status(403).send({ message });
return;
} }
if (message === 'Project file not found') { if (message === 'Project file not found') {
return res.status(404).send({ message }); res.status(404).send({ message });
return;
} }
res.status(500).send({ message }); res.status(500).send({ message });
+1 -2
View File
@@ -1,11 +1,10 @@
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(JSON_MIME)) { if (file.mimetype.includes('application/json')) {
cb(null, true); cb(null, true);
} else { } else {
cb(null, false); cb(null, false);

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