mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 06:29:07 +00:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a95bc43a3 | |||
| 1604762f2b | |||
| 2f8f4ad129 | |||
| 0c7c90cae4 | |||
| 1148ac1924 | |||
| 100fbabfc6 | |||
| 42810a97ca | |||
| 05a9b2af1a | |||
| 1bac3a7fd0 | |||
| 8281314ede | |||
| 3e0b3f100e | |||
| fb93063b47 | |||
| 24d3bf05ca | |||
| a4b15970de | |||
| 2a5b053d97 | |||
| 02c30c9681 | |||
| 2de7221653 | |||
| 2498e59156 | |||
| f3b4ea0155 | |||
| aebf949883 | |||
| 33ac05ebee | |||
| 421183fe55 | |||
| b66c19769d | |||
| 7b8445597f | |||
| 0b719c485f | |||
| 17deeddb87 | |||
| 2546159a94 | |||
| 2f26f20db5 | |||
| 11e0215530 | |||
| ea54953298 | |||
| d21f132674 | |||
| c7b2f0e89c | |||
| a1505606cf | |||
| 9edff55bd5 | |||
| e287215179 | |||
| 776649c997 | |||
| 9de5e0ac48 | |||
| c7d99073f6 | |||
| 5c1694d802 | |||
| 7cd319a6b3 | |||
| 2c9b6918e6 | |||
| a50cfad7ef | |||
| 66cd1decde | |||
| c9bf6d9812 | |||
| 8557f64382 | |||
| 8e994fb0df | |||
| 5c47a834b5 | |||
| f356afef43 | |||
| eba5252e40 | |||
| 72c4ad789a | |||
| 3118c20719 | |||
| 3754c374c3 | |||
| dd6d74121c | |||
| 6fb42b989e | |||
| 82dc1bc56b | |||
| d2c7b34142 | |||
| 918f8d1b08 | |||
| fcbcf70e01 | |||
| 5c6b06a0d4 | |||
| fc4c1bf22e | |||
| 84fac739f0 | |||
| ef73c87e2f | |||
| 26ee5768a5 | |||
| 27dcf06c73 | |||
| d48b2ae506 | |||
| 14a7c04d3b | |||
| abd9b127db | |||
| 0a80d6db31 | |||
| 977c99e587 | |||
| fe8bbd5003 | |||
| c6e38a2b4a | |||
| 56f18fe71c | |||
| 0b9306de82 | |||
| 3b1e6cf33d | |||
| dda219ebbe | |||
| 8bb771e5c6 | |||
| 8723bcbd33 | |||
| 0dcea4f2d7 | |||
| 696c016c90 | |||
| eed6373dbf |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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) {
|
||||||
|
|||||||
+5
-5
@@ -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'));
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+2
-77
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,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
Reference in New Issue
Block a user