Compare commits

...

53 Commits

Author SHA1 Message Date
Carlos Valente c5e821df35 bump version to 3.4.0 2024-07-16 19:21:56 +02:00
jwetzell 0fd71c9a00 align docker-compose example with docker image layout 2024-07-16 19:20:05 +02:00
jwetzell 53b453abc8 change docker data path from /external to /data 2024-07-16 19:20:05 +02:00
Carlos Valente fe0665506e chore: update typescript 2024-07-16 13:18:20 +02:00
Carlos Valente 9bd600c4b1 chore: add watch mode in dev 2024-07-16 13:18:20 +02:00
jwetzell bbd4254637 Merge pull request #1138 from cpvalente/chore/bump-version
bump version to 3.4.0-beta.3
2024-07-15 08:23:16 -05:00
jwetzell 2603c777ef bump version to 3.4.0-beta.3 2024-07-15 07:23:00 -05:00
jwetzell 8942a4ba4f Merge pull request #1137 from cpvalente/fix/buildx-platforms
drop arm/v6 image
2024-07-15 07:20:32 -05:00
jwetzell f97be749dc drop arm/v6 image 2024-07-15 07:18:06 -05:00
Carlos Valente 5836d5ffc7 bump version to 3.4.0-beta.2 2024-07-15 10:28:58 +02:00
jwetzell 91f21fdeb1 Merge pull request #1134 from cpvalente/fix/docker-building
switch Dockerfile build stage to non-alpine image
2024-07-14 13:18:24 -05:00
jwetzell 844eec132b use npm to install specific pnpm version 2024-07-13 06:36:22 -05:00
jwetzell adc4b3f8bf switch Dockerfile build stage to non-alpine image 2024-07-12 21:12:57 -05:00
Carlos Valente fb09631db9 bump version to 3.4.0-beta 2024-07-11 14:44:02 +02:00
Carlos Valente 19febb98a5 bump version to 3.4.0.beta 2024-07-11 14:33:18 +02:00
Carlos Valente 6bfa11dd8d fix: format clock as timer 2024-07-11 13:31:25 +02:00
Carlos Valente 4758bc84b5 chore: upgrade node dependencies 2024-07-11 13:31:05 +02:00
Carlos Valente 917b182e39 feat: granular endpoints for rundown 2024-07-11 11:03:07 +02:00
Carlos Valente e6c3322321 feat: add list of changes to refetch 2024-07-11 11:03:07 +02:00
Carlos Valente 00d34eec9b refactor: emit load event on reload 2024-07-11 11:02:49 +02:00
Carlos Valente 84792bc00d refactor: initialise data 2024-07-11 11:02:22 +02:00
Carlos Valente dae32e89a3 refactor: patch project 2024-07-11 11:02:22 +02:00
Carlos Valente 367997fd16 refactor: simplify file checks 2024-07-11 11:02:22 +02:00
Carlos Valente 751c3329f0 refactor: load project 2024-07-11 11:02:22 +02:00
Carlos Valente 9c5e403b18 refactor: add directory for crash logs 2024-07-11 11:02:22 +02:00
Carlos Valente c944e77bc1 refactor: last project may not exist 2024-07-11 11:02:22 +02:00
Carlos Valente d94c1d4252 refactor: remove unnecessary check 2024-07-11 11:02:22 +02:00
Carlos Valente 9f395cb16d refactor: safe get info 2024-07-11 11:02:22 +02:00
Carlos Valente 46195fc043 refactor: rename project 2024-07-11 11:02:22 +02:00
Carlos Valente c9ef8d55c1 refactor: duplicate project 2024-07-11 11:02:22 +02:00
Carlos Valente ad0e821cc0 refactor: delete project 2024-07-11 11:02:22 +02:00
Carlos Valente 21454947e0 refactor: create project 2024-07-11 11:02:22 +02:00
Carlos Valente ad69c0ff80 refactor: project service boundaries 2024-07-11 11:02:22 +02:00
Carlos Valente 20d9df2501 refactor: escalate errors from parser 2024-07-11 11:02:22 +02:00
Carlos Valente de9af5aaa2 refactor: minor code cleanups 2024-07-11 11:02:22 +02:00
Carlos Valente 61280b06b7 refactor: simplify logic dynamic menus 2024-07-10 23:01:47 +02:00
Carlos Valente ce4a48cd8d refactor: extract options per view 2024-07-10 23:01:47 +02:00
Carlos Valente 2877475a35 refactor: allow reset selection by refreshing 2024-07-10 23:01:47 +02:00
Carlos Valente 73e23ee323 feat: projected times in countdown 2024-07-10 23:01:47 +02:00
Carlos Valente 938c5fbb4c refactor: offset cannot be null 2024-07-10 23:01:47 +02:00
Carlos Valente e90e94828d style: add npm badge 2024-07-09 09:30:01 +02:00
jwetzell 1f0401632e have electron dev script wait for local server instance 2024-07-03 22:31:20 +02:00
jwetzell 5c3989c9b6 switch workflows to release trigger (#1115)
* switch docker workflow to release trigger

* switch cli publish workflow to release trigger
2024-07-03 22:30:56 +02:00
Carlos Valente 6ad26f06c7 style: rename log feature 2024-07-02 16:56:23 +02:00
Carlos Valente a63fa68d39 bump version to 3.3.3 2024-06-23 23:02:14 +02:00
Carlos Valente 20503de6fd refactor: minor code cleanups 2024-06-23 22:07:34 +02:00
Carlos Valente 0335da9786 refactor: improve errors to user 2024-06-23 22:07:34 +02:00
Carlos Valente 9f1a64f53c chore: upgrade dependencies 2024-06-23 22:07:34 +02:00
Alex Christoffer Rasmussen 975356f4f4 events with 0 duration dose not contribute to next day calculation (#1099) 2024-06-23 22:06:44 +02:00
Carlos Valente 1b448835cd fix: duplicate validation 2024-06-23 16:17:03 +02:00
Carlos Valente cbf3d566b9 fix: prevent calling service before init 2024-06-23 11:50:49 +02:00
Carlos Valente 6c20cb5e99 refactor: remove size limits in title 2024-06-22 13:48:19 +02:00
Carlos Valente cfc3aab893 style: add background to highlight 2024-06-21 14:45:45 +02:00
130 changed files with 9981 additions and 7254 deletions
+6 -6
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -1,8 +1,8 @@
name: Ontime CLI build name: Ontime CLI build
on: on:
push: release:
tags: [ "*" ] types: [published]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -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: 18.18.2 node-version: 20
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+6 -8
View File
@@ -1,8 +1,8 @@
name: Docker Image CI Ontime name: Docker Image CI Ontime
on: on:
push: release:
tags: [ "*" ] types: [published]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -14,8 +14,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Docker Login - name: Docker Login
uses: docker/login-action@v2.1.0 uses: docker/login-action@v2.1.0
@@ -32,10 +30,10 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry # Push is a shorthand for --output=type=registry
push: true push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release - name: Build and push pre-release
if: github.event.release.prerelease == true if: github.event.release.prerelease == true
@@ -43,8 +41,8 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry # Push is a shorthand for --output=type=registry
push: true push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1 +1 @@
v18.18.2 v20.15.1
+2 -2
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- __node__ (~18.18.2) - __node__ (~20)
- __pnpm__ (>=8) - __pnpm__ (~9)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
+4 -4
View File
@@ -1,19 +1,19 @@
FROM node:18.18-alpine AS builder FROM node:20-bullseye AS builder
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN npm install -g pnpm@9.5.0
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:18.18-alpine FROM node:20-alpine
# Set environment variables # Set environment variables
# Environment Variable to signal that we are running production # Environment Variable to signal that we are running production
ENV NODE_ENV=docker ENV NODE_ENV=docker
# Ontime Data path # Ontime Data path
ENV ONTIME_DATA=/external/ ENV ONTIME_DATA=/data/
WORKDIR /app/ WORKDIR /app/
+1
View File
@@ -1,6 +1,7 @@
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime) ![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente) [![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.3.2", "version": "3.4.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.3.2", "version": "3.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -70,8 +70,8 @@
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5", "@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^v7.12.0", "@typescript-eslint/eslint-plugin": "^v7.16.1",
"@typescript-eslint/parser": "^7.12.0", "@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
@@ -86,7 +86,7 @@
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"prettier": "^3.3.1", "prettier": "^3.3.1",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "^5.4.3", "typescript": "^5.5.3",
"vite": "^5.2.11", "vite": "^5.2.11",
"vite-plugin-compression2": "^0.12.0", "vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
+1 -1
View File
@@ -123,7 +123,7 @@ export async function renameProject(filename: string, newFilename: string): Prom
const url = `${dbPath}/${filename}/rename`; const url = `${dbPath}/${filename}/rename`;
const decodedUrl = decodeURIComponent(url); const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, { const res = await axios.put(decodedUrl, {
filename: newFilename, newFilename,
}); });
return res.data; return res.data;
} }
@@ -1,25 +1,37 @@
@use '../../../theme/viewerDefs' as *; @use '../../../theme/viewerDefs' as *;
.title-card { .title-card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.25rem;
} }
.inline { .title-card__title,
display: flex; .title-card__secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.title-card__title { .title-card__title {
font-weight: 600; font-weight: 600;
font-size: clamp(32px, 3.5vw, 50px); font-size: clamp(1.5rem, 3vw, 3rem);
color: var(--color-override, $viewer-color); color: var(--color-override, $viewer-color);
line-height: 1.1em; line-height: 1.2em;
}
.title-card__secondary {
font-size: clamp(1rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
} }
.title-card__label { .title-card__label {
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: clamp(1rem, 1.5vw, 1.5rem); font-size: clamp(1rem, 1.5vw, 1.5rem);
font-weight: 400;
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto; margin-left: auto;
text-transform: uppercase; text-transform: uppercase;
@@ -28,13 +40,3 @@
color: var(--accent-color-override, $accent-color); color: var(--accent-color-override, $accent-color);
} }
} }
.title-card__secondary {
font-size: clamp(1.5rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.1em;
&::after {
content: '\200b';
}
}
@@ -1,28 +1,32 @@
import { ForwardedRef, forwardRef } from 'react';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import './TitleCard.scss'; import './TitleCard.scss';
interface TitleCardProps { interface TitleCardProps {
label: 'now' | 'next';
title: string; title: string;
label?: 'now' | 'next';
secondary?: string; secondary?: string;
className?: string;
} }
export default function TitleCard(props: TitleCardProps) { const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
const { label, title, secondary } = props; const { label, title, secondary, className = '' } = props;
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const accent = label === 'now'; const accent = label === 'now';
return ( return (
<div className='title-card'> <div className={`title-card ${className}`} ref={ref}>
<div className='inline'> <span className='title-card__title'>{title}</span>
<span className='title-card__title'>{title}</span> <span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}> {label && getLocalizedString(`common.${label}`)}
{getLocalizedString(`common.${label}`)} </span>
</span>
</div>
<div className='title-card__secondary'>{secondary}</div> <div className='title-card__secondary'>{secondary}</div>
</div> </div>
); );
} });
TitleCard.displayName = 'TitleCard';
export default TitleCard;
@@ -27,6 +27,10 @@ export default function ParamInput(props: EditFormInputProps) {
const { paramField } = props; const { paramField } = props;
const { id, type, defaultValue } = paramField; const { id, type, defaultValue } = paramField;
if (type === 'persist') {
return null;
}
if (type === 'option') { if (type === 'option') {
const optionFromParams = searchParams.get(id); const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || defaultValue; const defaultOptionValue = optionFromParams || defaultValue;
@@ -13,14 +13,24 @@
color: $label-gray; color: $label-gray;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem gap: 0.25rem;
} }
.columnSection { .section {
color: $ui-white;
font-size: 1rem;
&:not(:first-child) {
margin-top: 2rem;
}
}
.fieldSet {
display: flex; display: flex;
padding: $section-spacing 0; padding: $section-spacing 0;
flex-direction: column; flex-direction: column;
gap: $element-spacing; gap: $element-spacing;
margin-left: 0.5rem;
} }
.title { .title {
@@ -13,7 +13,7 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { ParamField } from './types'; import { isSection, ViewOption } from './types';
import style from './ViewParamsEditor.module.scss'; import style from './ViewParamsEditor.module.scss';
@@ -22,25 +22,36 @@ type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/** /**
* Makes a new URLSearchParams object from the given params object * Makes a new URLSearchParams object from the given params object
*/ */
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => { const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOption[]) => {
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => { const newSearchParams = new URLSearchParams();
acc[id] = String(defaultValue);
return acc;
}, {});
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => { // Convert paramFields to an object that contains default values
const defaultValues: Record<string, string> = {};
paramFields.forEach((option) => {
if (!isSection(option)) {
defaultValues[option.id] = String(option.defaultValue);
}
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
});
// compare which values are different from the default values
Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length && defaultValues[id] !== value) { if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
newSearchParams.set(id, value); newSearchParams.set(id, value);
} }
return newSearchParams; });
}, new URLSearchParams()); return newSearchParams;
}; };
interface EditFormDrawerProps { interface EditFormDrawerProps {
paramFields: ParamField[]; viewOptions: ViewOption[];
} }
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) { export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen, onClose, onOpen } = useDisclosure();
@@ -68,7 +79,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
formEvent.preventDefault(); formEvent.preventDefault();
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
onClose(); onClose();
@@ -85,15 +96,29 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
<DrawerBody> <DrawerBody>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}> <form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{paramFields.map((field) => ( {viewOptions.map((option) => {
<div key={field.title} className={style.columnSection}> if (isSection(option)) {
<label className={style.label}> return (
<span className={style.title}>{field.title}</span> <div key={option.section} className={style.section}>
<span className={style.description}>{field.description}</span> {option.section}
<ParamInput key={field.title} paramField={field} /> </div>
</label> );
</div> }
))}
if (option.type === 'persist') {
return null;
}
return (
<div key={option.title} className={style.fieldSet}>
<label className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput key={option.title} paramField={option} />
</label>
</div>
);
})}
</form> </form>
</DrawerBody> </DrawerBody>
@@ -1,15 +1,18 @@
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { type ParamField } from './types'; import type { ParamField } from './types';
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => { export const makeOptionsFromCustomFields = (
const customFieldOptions = Object.entries(customFields).reduce((acc, [key, value]) => { customFields: CustomFields,
return { ...acc, [`custom-${key}`]: `Custom: ${value.label}` }; additionalOptions: Record<string, string> = {},
}, additionalOptions ?? {}); ) => {
return customFieldOptions; return Object.entries(customFields).reduce((options, [key, value]) => {
options[`custom-${key}`] = `Custom: ${value.label}`;
return options;
}, additionalOptions);
}; };
const getTimeOption = (timeFormat: string): ParamField => { export const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`; const placeholder = `${timeFormat} (default)`;
return { return {
id: 'timeformat', id: 'timeformat',
@@ -20,7 +23,7 @@ const getTimeOption = (timeFormat: string): ParamField => {
}; };
}; };
const hideTimerSeconds: ParamField = { export const hideTimerSeconds: ParamField = {
id: 'hideTimerSeconds', id: 'hideTimerSeconds',
title: 'Hide seconds in timer', title: 'Hide seconds in timer',
description: 'Whether to hide seconds in the running timer', description: 'Whether to hide seconds in the running timer',
@@ -28,484 +31,10 @@ const hideTimerSeconds: ParamField = {
defaultValue: false, defaultValue: false,
}; };
const showLeadingZeros: ParamField = { export const showLeadingZeros: ParamField = {
id: 'showLeadingZeros', id: 'showLeadingZeros',
title: 'Show leading zeros in timer', title: 'Show leading zeros in timer',
description: 'Whether to show leading zeros in the running timer', description: 'Whether to show leading zeros in the running timer',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}; };
export const getClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
getTimeOption(timeFormat),
hideTimerSeconds,
showLeadingZeros,
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
};
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
hideTimerSeconds,
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
];
export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
});
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
none: 'None',
});
return [
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'manual',
},
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: topSourceOptions,
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'none',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
};
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
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: '',
},
];
};
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
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: '',
},
];
};
export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
hideTimerSeconds,
];
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.entries(customFields).reduce((acc, [key, field]) => {
return { ...acc, [key]: { value: key, label: field.label, colour: field.colour } };
}, {});
return [
getTimeOption(timeFormat),
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: fieldOptions,
defaultValue: 'title',
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
type: 'multi-option',
values: customFieldSelect,
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
];
};
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
@@ -1,3 +1,7 @@
type ParamSection = {
section: string;
};
type BaseField = { type BaseField = {
id: string; id: string;
title: string; title: string;
@@ -19,5 +23,15 @@ type MultiOptionsField = {
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string }; type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string }; type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean }; type BooleanField = { type: 'boolean'; defaultValue: boolean };
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField); export type ParamField = BaseField &
(StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
export type ViewOption = ParamSection | ParamField;
/**
* Type assertion utility checks whether an entry is a section separator
*/
export function isSection(entry: ViewOption): entry is ParamSection {
return 'section' in entry;
}
@@ -19,7 +19,7 @@ export default function useLongPress(callback: () => void, options: LongPressOpt
const { threshold = 400, onStart, onFinish, onCancel } = options; const { threshold = 400, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false); const isLongPressActive = useRef(false);
const isPressed = useRef(false); const isPressed = useRef(false);
const timerId = useRef<NodeJS.Timer>(); const timerId = useRef<NodeJS.Timeout>();
return useMemo(() => { return useMemo(() => {
const start = (event: SyntheticEvent) => { const start = (event: SyntheticEvent) => {
@@ -17,7 +17,7 @@ export default function LogExport() {
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.SubHeader> <Panel.SubHeader>
Network log Event log
<Button <Button
variant='ontime-subtle' variant='ontime-subtle'
size='sm' size='sm'
@@ -2,8 +2,6 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import * as Panel from '../PanelUtils';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
export type ProjectFormValues = { export type ProjectFormValues = {
@@ -15,10 +13,9 @@ interface ProjectFormProps {
filename: string; filename: string;
onCancel: () => void; onCancel: () => void;
onSubmit: (values: ProjectFormValues) => Promise<void>; onSubmit: (values: ProjectFormValues) => Promise<void>;
submitError: string | null;
} }
export default function ProjectForm({ action, filename, onSubmit, onCancel, submitError }: ProjectFormProps) { export default function ProjectForm({ action, filename, onSubmit, onCancel }: ProjectFormProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
@@ -37,34 +34,31 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel, subm
}, [setFocus]); }, [setFocus]);
return ( return (
<> <form onSubmit={handleSubmit(onSubmit)} className={style.form}>
<form onSubmit={handleSubmit(onSubmit)} className={style.form}> <Input
<Input className={style.formInput}
className={style.formInput} id='filename'
id='filename' size='sm'
type='text'
variant='ontime-filled'
placeholder='Enter new name'
autoComplete='off'
{...register('filename', { required: true })}
/>
<div className={style.actionButtons}>
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button
size='sm' size='sm'
type='text'
variant='ontime-filled' variant='ontime-filled'
placeholder='Enter new name' isDisabled={!isDirty || !isValid || isSubmitting}
autoComplete='off' type='submit'
{...register('filename')} className={style.saveButton}
/> >
<div className={style.actionButtons}> {action}
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}> </Button>
Cancel </div>
</Button> </form>
<Button
size='sm'
variant='ontime-filled'
isDisabled={!isDirty || !isValid || isSubmitting}
type='submit'
className={style.saveButton}
>
{action}
</Button>
</div>
</form>
{submitError && <Panel.Error>{submitError}</Panel.Error>}
</>
); );
} }
@@ -11,6 +11,7 @@ import {
renameProject, renameProject,
} from '../../../../common/api/db'; } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import * as Panel from '../PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm'; import ProjectForm, { ProjectFormValues } from './ProjectForm';
@@ -40,36 +41,53 @@ export default function ProjectListItem({
onToggleEditMode, onToggleEditMode,
}: ProjectListItemProps) { }: ProjectListItemProps) {
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmitRename = async (values: ProjectFormValues) => { const handleSubmitAction = (actionType: 'rename' | 'duplicate') => {
try { return async (values: ProjectFormValues) => {
setLoading(true);
setSubmitError(null); setSubmitError(null);
try {
if (!values.filename) { if (!values.filename) {
setSubmitError('Filename cannot be blank'); setSubmitError('Filename cannot be blank');
return; return;
}
const action = actionType === 'rename' ? renameProject : duplicateProject;
await action(filename, values.filename);
await onRefetch();
onSubmit();
} catch (error) {
setSubmitError(maybeAxiosError(error));
} finally {
setLoading(false);
} }
await renameProject(filename, values.filename); };
};
const handleLoad = async (filename: string) => {
setLoading(true);
setSubmitError(null);
try {
await loadProject(filename);
await onRefetch(); await onRefetch();
onSubmit(); await invalidateAllCaches();
} catch (error) { } catch (error) {
setSubmitError(maybeAxiosError(error)); setSubmitError(maybeAxiosError(error));
} finally {
setLoading(false);
} }
}; };
const handleSubmitDuplicate = async (values: ProjectFormValues) => { const handleDelete = async (filename: string) => {
setLoading(true);
setSubmitError(null);
try { try {
setSubmitError(null); await deleteProject(filename);
if (!values.filename) {
setSubmitError('Filename cannot be blank');
return;
}
await duplicateProject(filename, values.filename);
await onRefetch(); await onRefetch();
onSubmit();
} catch (error) { } catch (error) {
setSubmitError(maybeAxiosError(error)); setSubmitError(maybeAxiosError(error));
} finally {
setLoading(false);
} }
}; };
@@ -86,50 +104,55 @@ export default function ProjectListItem({
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined; const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
return ( return (
<tr key={filename} className={classes}> <>
{isCurrentlyBeingEdited ? ( {submitError && (
<td colSpan={99}> <tr key='filename-error'>
<ProjectForm <td colSpan={99}>
action={editingMode} <Panel.Error>{submitError}</Panel.Error>
filename={filename} </td>
onSubmit={editingMode === 'duplicate' ? handleSubmitDuplicate : handleSubmitRename} </tr>
onCancel={handleCancel} )}
submitError={submitError} <tr key={filename} className={classes}>
/> {isCurrentlyBeingEdited ? (
</td> <td colSpan={99}>
) : ( <ProjectForm
<> action={editingMode}
<td className={style.containCell}>{filename}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
current={current}
filename={filename} filename={filename}
onChangeEditMode={handleToggleEditMode} onSubmit={editingMode === 'duplicate' ? handleSubmitAction('duplicate') : handleSubmitAction('rename')}
onRefetch={onRefetch} onCancel={handleCancel}
/> />
</td> </td>
</> ) : (
)} <>
</tr> <td className={style.containCell}>{filename}</td>
<td>{new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<ActionMenu
current={current}
filename={filename}
onChangeEditMode={handleToggleEditMode}
onDelete={handleDelete}
onLoad={handleLoad}
isDisabled={loading}
/>
</td>
</>
)}
</tr>
</>
); );
} }
function ActionMenu({ interface ActionMenuProps {
current,
filename,
onChangeEditMode,
onRefetch,
}: {
current?: boolean; current?: boolean;
filename: string; filename: string;
isDisabled: boolean;
onChangeEditMode: (editMode: EditMode, filename: string) => void; onChangeEditMode: (editMode: EditMode, filename: string) => void;
onRefetch: () => Promise<void>; onDelete: (filename: string) => void;
}) { onLoad: (filename: string) => void;
const handleLoad = async () => { }
await loadProject(filename); function ActionMenu(props: ActionMenuProps) {
await invalidateAllCaches(); const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad } = props;
};
const handleRename = () => { const handleRename = () => {
onChangeEditMode('rename', filename); onChangeEditMode('rename', filename);
@@ -139,11 +162,6 @@ function ActionMenu({
onChangeEditMode('duplicate', filename); onChangeEditMode('duplicate', filename);
}; };
const handleDelete = async () => {
await deleteProject(filename);
await onRefetch();
};
const handleDownload = async () => { const handleDownload = async () => {
await downloadProject(filename); await downloadProject(filename);
}; };
@@ -161,16 +179,17 @@ function ActionMenu({
color='#e2e2e2' // $gray-200 color='#e2e2e2' // $gray-200
variant='ontime-ghosted' variant='ontime-ghosted'
size='sm' size='sm'
isDisabled={isDisabled}
/> />
<MenuList> <MenuList>
<MenuItem onClick={handleLoad} isDisabled={current}> <MenuItem onClick={() => onLoad(filename)} isDisabled={current}>
Load Load
</MenuItem> </MenuItem>
<MenuItem onClick={handleRename}>Rename</MenuItem> <MenuItem onClick={handleRename}>Rename</MenuItem>
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem> <MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
<MenuItem onClick={handleDownload}>Download</MenuItem> <MenuItem onClick={handleDownload}>Download</MenuItem>
<MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem> <MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>
<MenuItem isDisabled={current} onClick={handleDelete}> <MenuItem isDisabled={current} onClick={() => onDelete(filename)}>
Delete Delete
</MenuItem> </MenuItem>
</MenuList> </MenuList>
@@ -57,7 +57,7 @@ export const settingPanels: Readonly<SettingsOption[]> = [
secondary: [ secondary: [
{ {
id: 'network__log', id: 'network__log',
label: 'Application log', label: 'Event log',
}, },
{ {
id: 'network__clients', id: 'network__clients',
@@ -4,7 +4,6 @@ import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket'; import { useOperator } from '../../common/hooks/useSocket';
@@ -22,6 +21,7 @@ import FollowButton from './follow-button/FollowButton';
import OperatorBlock from './operator-block/OperatorBlock'; import OperatorBlock from './operator-block/OperatorBlock';
import OperatorEvent from './operator-event/OperatorEvent'; import OperatorEvent from './operator-event/OperatorEvent';
import StatusBar from './status-bar/StatusBar'; import StatusBar from './status-bar/StatusBar';
import { getOperatorOptions } from './operator.options';
import style from './Operator.module.scss'; import style from './Operator.module.scss';
@@ -134,7 +134,7 @@ export default function Operator() {
return ( return (
<div className={style.operatorContainer}> <div className={style.operatorContainer}>
<ViewParamsEditor paramFields={operatorOptions} /> <ViewParamsEditor viewOptions={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />} {editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
<StatusBar <StatusBar
@@ -65,12 +65,15 @@
font-size: 1.5rem; font-size: 1.5rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: $ui-white; color: $ui-white;
@include ellipsis-text;
} }
.secondaryField { .secondaryField {
grid-area: secondary; grid-area: secondary;
font-size: 1.25rem; font-size: 1.25rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@include ellipsis-text;
} }
.schedule { .schedule {
@@ -96,10 +99,10 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
.field { .field {
font-weight: 600; font-weight: 600;
padding: 0 0.25rem; padding-inline: 0.25rem;
margin-right: 0.25rem;
} }
.noColour { .noColour {
@@ -110,6 +113,8 @@
} }
.value { .value {
padding-inline: 0.25rem;
background-color: $gray-1250;
color: var(--operator-highlight-override, $ui-white); color: var(--operator-highlight-override, $ui-white);
margin-right: 1rem; margin-right: 1rem;
} }
@@ -0,0 +1,60 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.entries(customFields).reduce<
Record<string, { value: string; label: string; colour: string }>
>((acc, [key, field]) => {
acc[key] = { value: key, label: field.label, colour: field.colour };
return acc;
}, {});
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: fieldOptions,
defaultValue: 'title',
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
type: 'multi-option',
values: customFieldSelect,
},
{ section: 'Element visibility' },
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -6,14 +6,14 @@ import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss'; import style from '../EventEditor.module.scss';
interface CountedTextInputProps extends InputProps { interface EventTextInputProps extends InputProps {
field: EditorUpdateFields; field: EditorUpdateFields;
label: string; label: string;
initialValue: string; initialValue: string;
submitHandler: (field: EditorUpdateFields, value: string) => void; submitHandler: (field: EditorUpdateFields, value: string) => void;
} }
export default function EventTextInput(props: CountedTextInputProps) { export default function EventTextInput(props: EventTextInputProps) {
const { field, label, initialValue, submitHandler, maxLength } = props; const { field, label, initialValue, submitHandler, maxLength } = props;
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]); const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
@@ -34,7 +34,7 @@ export default function EventTextInput(props: CountedTextInputProps) {
variant='ontime-filled' variant='ontime-filled'
data-testid='input-textfield' data-testid='input-textfield'
value={value} value={value}
maxLength={maxLength || 50} maxLength={maxLength || 100}
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@@ -5,6 +5,7 @@ import {
Message, Message,
OntimeEvent, OntimeEvent,
ProjectData, ProjectData,
Runtime,
Settings, Settings,
SupportedEvent, SupportedEvent,
TimerMessage, TimerMessage,
@@ -36,6 +37,7 @@ type WithDataProps = {
publicEventNext: OntimeEvent | null; publicEventNext: OntimeEvent | null;
publicEventNow: OntimeEvent | null; publicEventNow: OntimeEvent | null;
publicSelectedId: string | null; publicSelectedId: string | null;
runtime: Runtime;
selectedId: string | null; selectedId: string | null;
settings: Settings | undefined; settings: Settings | undefined;
time: ViewExtendedTimer; time: ViewExtendedTimer;
@@ -66,7 +68,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
}, [rundownData]); }, [rundownData]);
// websocket data // websocket data
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow } = const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } =
useStore(runtimeStore); useStore(runtimeStore);
const publicSelectedId = publicEventNow?.id ?? null; const publicSelectedId = publicEventNow?.id ?? null;
const selectedId = eventNow?.id ?? null; const selectedId = eventNow?.id ?? null;
@@ -108,6 +110,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
publicEventNext={publicEventNext} publicEventNext={publicEventNext}
publicEventNow={publicEventNow} publicEventNow={publicEventNow}
publicSelectedId={publicSelectedId} publicSelectedId={publicSelectedId}
runtime={runtime}
selectedId={selectedId} selectedId={selectedId}
settings={settings} settings={settings}
time={TimeManagerType} time={TimeManagerType}
@@ -11,7 +11,6 @@ import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext'; import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav'; import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard'; import TitleCard from '../../../common/components/title-card/TitleCard';
import { getBackstageOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -23,8 +22,12 @@ import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { getBackstageOptions } from './backstage.options';
import './Backstage.scss'; import './Backstage.scss';
export const MotionTitleCard = motion(TitleCard);
interface BackstageProps { interface BackstageProps {
customFields: CustomFields; customFields: CustomFields;
isMirrored: boolean; isMirrored: boolean;
@@ -97,7 +100,7 @@ export default function Backstage(props: BackstageProps) {
return ( return (
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'> <div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<ViewParamsEditor paramFields={backstageOptions} /> <ViewParamsEditor viewOptions={backstageOptions} />
<div className='project-header'> <div className='project-header'>
{general.title} {general.title}
<div className='clock-container'> <div className='clock-container'>
@@ -124,7 +127,7 @@ export default function Backstage(props: BackstageProps) {
animate='visible' animate='visible'
exit='exit' exit='exit'
> >
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} /> <TitleCard title={eventNow.title} secondary={secondaryTextNow} />
<div className='timer-group'> <div className='timer-group'>
<div className='aux-timers'> <div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div> <div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
@@ -151,16 +154,17 @@ export default function Backstage(props: BackstageProps) {
<AnimatePresence> <AnimatePresence>
{eventNext && ( {eventNext && (
<motion.div <MotionTitleCard
className='event next' className='event next'
key='next' key='next'
variants={titleVariants} variants={titleVariants}
initial='hidden' initial='hidden'
animate='visible' animate='visible'
exit='exit' exit='exit'
> label='next'
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} /> title={eventNext.title}
</motion.div> secondary={secondaryTextNext}
/>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
@@ -0,0 +1,44 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{
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: '',
},
{ section: 'Schedule options' },
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -2,7 +2,6 @@ import { useSearchParams } from 'react-router-dom';
import { Settings, ViewSettings } from 'ontime-types'; import { Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import { getClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -11,6 +10,8 @@ import { OverridableOptions } from '../../../common/models/View.types';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getClockOptions } from './clock.options';
import './Clock.scss'; import './Clock.scss';
interface ClockProps { interface ClockProps {
@@ -130,7 +131,7 @@ export default function Clock(props: ClockProps) {
}} }}
data-testid='clock-view' data-testid='clock-view'
> >
<ViewParamsEditor paramFields={clockOptions} /> <ViewParamsEditor viewOptions={clockOptions} />
<SuperscriptTime <SuperscriptTime
time={clock} time={clock}
className='clock' className='clock'
@@ -0,0 +1,76 @@
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getClockOptions = (timeFormat: string): ViewOption[] => [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'View style override' },
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
@@ -3,6 +3,7 @@ import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString, removeLeadingZero
import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils'; import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>; type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>;
@@ -68,11 +69,13 @@ export function getFormattedTimer(
return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder; return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder;
} }
let timeToParse = timer; if (timerType === TimerType.Clock) {
return formatTime(timer);
}
let timeToParse = timer;
if (options.removeSeconds) { if (options.removeSeconds) {
const timerIsTimeOfDay = timerType === TimerType.Clock; const isNegative = timeToParse < -MILLIS_PER_SECOND && timerType !== TimerType.CountUp;
const isNegative = timeToParse < -MILLIS_PER_SECOND && !timerIsTimeOfDay && timerType !== TimerType.CountUp;
if (isNegative) { if (isNegative) {
// in negative numbers, we need to round down // in negative numbers, we need to round down
timeToParse -= MILLIS_PER_MINUTE; timeToParse -= MILLIS_PER_MINUTE;
@@ -21,11 +21,11 @@
flex-direction: column; flex-direction: column;
&__title { &__title {
font-size: clamp(24px, 2vw, 32px); font-size: clamp(1.5rem, 2vw, 2rem);
} }
&__events { &__events {
font-size: clamp(16px, 1.5vw, 24px); font-size: clamp(1rem, 1.5vw, 1.5rem);
margin-top: 1em; margin-top: 1em;
overflow-y: auto; overflow-y: auto;
height: 70vh; height: 70vh;
@@ -38,18 +38,18 @@
.countdown-container { .countdown-container {
height: 100%; height: 100%;
width: 100%; width: 100%;
gap: min(2vh, 16px); gap: min(2vh, 1rem);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px); padding: min(2vh, 1rem) clamp(1rem, 10vw, 4rem);
display: grid; display: grid;
grid-template-rows: auto auto auto auto 1fr; grid-template-rows: auto auto auto auto 1fr;
grid-template-columns: 100%; grid-template-columns: 100%;
grid-template-areas: grid-template-areas:
'header' 'header'
'status' 'status'
'clock' 'clock'
'title' 'title'
'timers'; 'timers';
/* =================== HEADER + EXTRAS ===================*/ /* =================== HEADER + EXTRAS ===================*/
@@ -59,13 +59,13 @@
font-weight: 600; font-weight: 600;
.label { .label {
font-size: clamp(16px, 1.5vw, 24px); font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase; text-transform: uppercase;
} }
.time { .time {
font-size: clamp(32px, 3.5vw, 50px); font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color); color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em; letter-spacing: 0.05em;
line-height: 0.95em; line-height: 0.95em;
@@ -75,7 +75,7 @@
.status { .status {
grid-area: status; grid-area: status;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
font-size: clamp(32px, 3.5vw, 50px); font-size: clamp(2rem, 3.5vw, 3.5rem);
font-weight: 600; font-weight: 600;
} }
@@ -105,7 +105,7 @@
.title { .title {
grid-area: title; grid-area: title;
background-color: var(--card-background-color-override, $viewer-card-bg-color); background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 16px 24px; padding: 1rem 1.5rem;
border-radius: 8px; border-radius: 8px;
font-weight: 600; font-weight: 600;
@@ -119,28 +119,49 @@
.timer-group { .timer-group {
grid-area: timers; grid-area: timers;
display: flex; display: grid;
justify-content: space-evenly; grid-template-areas:
align-items: flex-end; 'projected-start projected-end'
'scheduled-start scheduled-end';
grid-template-columns: 1fr 1fr;
justify-items: center;
text-align: center;
row-gap: clamp(1rem, 5vh, 4rem);
align-self: flex-end;
height: fit-content;
.aux-timers { &__projected-start {
text-align: center; grid-area: projected-start;
font-size: clamp(24px, 1.75vw, 32px); }
&__label { &__projected-end {
color: var(--label-color-override, $viewer-label-color); grid-area: projected-end;
text-transform: uppercase; }
}
&__value { &__scheduled-start {
font-size: clamp(32px, 3.5vw, 50px); grid-area: scheduled-start;
color: var(--secondary-color-override, $viewer-secondary-color); }
letter-spacing: 0.05em;
line-height: 0.95em;
&--delayed { &__scheduled-end {
color: $delay-color; grid-area: scheduled-end;
} }
&__label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
&__value {
margin-top: 0.5rem;
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
&--delayed {
color: $delay-color;
} }
} }
} }
@@ -1,10 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, ViewSettings } from 'ontime-types'; import {
import { millisToString, removeLeadingZero } from 'ontime-utils'; OntimeEvent,
OntimeRundownEntry,
Playback,
Runtime,
Settings,
SupportedEvent,
TimerPhase,
ViewSettings,
} from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import { getCountdownOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -12,8 +19,10 @@ import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider'; 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 { fetchTimerData, TimerMessage } from './countdown.helpers'; import { fetchTimerData, getTimerItems, TimerMessage } from './countdown.helpers';
import { getCountdownOptions } from './countdown.options';
import CountdownSelect from './CountdownSelect'; import CountdownSelect from './CountdownSelect';
import './Countdown.scss'; import './Countdown.scss';
@@ -21,27 +30,26 @@ import './Countdown.scss';
interface CountdownProps { interface CountdownProps {
isMirrored: boolean; isMirrored: boolean;
backstageEvents: OntimeEvent[]; backstageEvents: OntimeEvent[];
time: ViewExtendedTimer; runtime: Runtime;
selectedId: string | null; selectedId: string | null;
viewSettings: ViewSettings;
settings: Settings | undefined; settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
} }
export default function Countdown(props: CountdownProps) { export default function Countdown(props: CountdownProps) {
const { isMirrored, backstageEvents, time, selectedId, viewSettings, settings } = props; const { isMirrored, backstageEvents, runtime, selectedId, settings, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const [follow, setFollow] = useState<OntimeEvent | null>(null); const [follow, setFollow] = useState<OntimeEvent | null>(null);
const [runningTimer, setRunningTimer] = useState(0);
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
const [delay, setDelay] = useState(0); const [delay, setDelay] = useState(0);
useWindowTitle('Countdown'); useWindowTitle('Countdown');
// eg. http://localhost:4001/countdown?eventId=ei0us // eg. http://localhost:4001/countdown?eventId=ei0us
// Check for user options // update data to the event we are following
useEffect(() => { useEffect(() => {
if (!backstageEvents) { if (!backstageEvents) {
return; return;
@@ -50,6 +58,12 @@ export default function Countdown(props: CountdownProps) {
const eventId = searchParams.get('eventid'); const eventId = searchParams.get('eventid');
const eventIndex = searchParams.get('event'); const eventIndex = searchParams.get('event');
// if there is no event selected, we reset the data
if (!eventId && !eventIndex) {
setFollow(null);
return;
}
let followThis: OntimeEvent | null = null; let followThis: OntimeEvent | null = null;
const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event); const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event);
@@ -66,49 +80,50 @@ export default function Countdown(props: CountdownProps) {
} }
}, [backstageEvents, searchParams]); }, [backstageEvents, searchParams]);
useEffect(() => {
if (!follow) {
return;
}
const { message, timer } = fetchTimerData(time, follow, selectedId);
setRunningMessage(message);
setRunningTimer(timer);
}, [follow, selectedId, time]);
// defer rendering until we load stylesheets // defer rendering until we load stylesheets
if (!shouldRender) { if (!shouldRender) {
return null; return null;
} }
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id; const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt; const finished = time.phase === TimerPhase.Overtime;
const isRunningFinished = finished && runningMessage === TimerMessage.running; const isRunningFinished = finished && runningMessage === TimerMessage.running;
const isSelected = runningMessage === TimerMessage.running;
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : ''; const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
const clock = formatTime(time.clock); const clock = formatTime(time.clock);
const startTime = follow === null ? '...' : formatTime(follow.timeStart + delay); const { scheduledStart, scheduledEnd, projectedStart, projectedEnd } = getTimerItems(
const endTime = follow === null ? '...' : formatTime(follow.timeEnd + delay); follow?.timeStart,
follow?.timeEnd,
delay,
runtime.offset,
);
const formatTimer = (): string => { const hideSeconds = searchParams.get('hideTimerSeconds');
if (runningMessage === TimerMessage.ended) { const formattedTimer = getFormattedTimer(runningTimer, time.timerType, getLocalizedString('common.minutes'), {
return formatTime(runningTimer, { format12: 'hh:mm a', format24: 'HH:mm' }); removeSeconds: isStringBoolean(hideSeconds),
removeLeadingZero: false,
});
const persistParam = () => {
const eventId = searchParams.get('eventid');
if (eventId !== null) {
return { id: 'eventid', value: eventId };
} }
let formattedTime = millisToString(isSelected ? runningTimer : runningTimer + delay); const eventIndex = searchParams.get('event');
if (isSelected || runningMessage === TimerMessage.waiting) { if (eventIndex !== null) {
formattedTime = removeLeadingZero(formattedTime); return { id: 'eventindex', value: eventIndex };
} }
return formattedTime; return undefined;
}; };
const formattedTimer = formatTimer();
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const timeOption = getCountdownOptions(defaultFormat); const viewOptions = getCountdownOptions(defaultFormat, persistParam());
return ( return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'> <div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<ViewParamsEditor paramFields={timeOption} /> <ViewParamsEditor viewOptions={viewOptions} />
{follow === null ? ( {follow === null ? (
<CountdownSelect events={backstageEvents} /> <CountdownSelect events={backstageEvents} />
) : ( ) : (
@@ -129,13 +144,25 @@ export default function Countdown(props: CountdownProps) {
{follow?.title && <div className='title'>{follow.title}</div>} {follow?.title && <div className='title'>{follow.title}</div>}
<div className='timer-group'> <div className='timer-group'>
<div className='aux-timers'> {projectedStart && projectedEnd && (
<div className='aux-timers__label'>{getLocalizedString('common.start_time')}</div> <div className='timer-group__projected-start'>
<SuperscriptTime time={startTime} className={`aux-timers__value ${delayedTimerStyles}`} /> <div className='timer-group__label'>{getLocalizedString('common.projected_start')}</div>
<SuperscriptTime time={projectedStart} className={`timer-group__value ${delayedTimerStyles}`} />
</div>
)}
{projectedStart && projectedEnd && (
<div className='timer-group__projected-end'>
<div className='timer-group__label'>{getLocalizedString('common.projected_end')}</div>
<SuperscriptTime time={projectedEnd} className={`timer-group__value ${delayedTimerStyles}`} />
</div>
)}
<div className='timer-group__scheduled-start'>
<div className='timer-group__label'>{getLocalizedString('common.scheduled_start')}</div>
<SuperscriptTime time={scheduledStart} className={`timer-group__value ${delayedTimerStyles}`} />
</div> </div>
<div className='aux-timers'> <div className='timer-group__scheduled-end'>
<div className='aux-timers__label'>{getLocalizedString('common.end_time')}</div> <div className='timer-group__label'>{getLocalizedString('common.scheduled_end')}</div>
<SuperscriptTime time={endTime} className={`aux-timers__value ${delayedTimerStyles}`} /> <SuperscriptTime time={scheduledEnd} className={`timer-group__value ${delayedTimerStyles}`} />
</div> </div>
</div> </div>
</div> </div>
@@ -1,6 +1,8 @@
import { OntimeEvent, Playback } from 'ontime-types'; import { OntimeEvent, Playback } from 'ontime-types';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { isStringBoolean } from '../common/viewUtils';
export enum TimerMessage { export enum TimerMessage {
toStart = 'to_start', toStart = 'to_start',
@@ -20,48 +22,85 @@ export const sanitiseTitle = (title: string | null) => (title ? title : '{no tit
*/ */
export const fetchTimerData = ( export const fetchTimerData = (
time: ViewExtendedTimer, time: ViewExtendedTimer,
follow: OntimeEvent, follow: OntimeEvent | null,
selectedId: string | null, selectedId: string | null,
offset: number,
): { message: TimerMessage; timer: number } => { ): { message: TimerMessage; timer: number } => {
let message; if (follow === null) {
let timer; return { message: TimerMessage.unhandled, timer: 0 };
}
if (selectedId === follow.id) { if (selectedId === follow.id) {
// check that is not running // if it is selected, it may not be running
message = time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running; return {
timer = time.current ?? 0; message: time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running,
} else if (time.clock < follow.timeStart) { timer: time.current ?? 0,
// if it hasnt started, we count to start };
message = TimerMessage.toStart;
timer = follow.timeStart - time.clock;
} else if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
// if it has started, we show running timer
message = TimerMessage.waiting;
timer = time.current ?? 0;
} else {
// running timer timer is not the one we are following
if (follow.timeStart > follow.timeEnd) {
// ends day after
if (follow.timeStart > time.clock) {
// if it hasnt started, we count to start
message = TimerMessage.toStart;
timer = follow.timeStart - time.clock;
} else if (follow.timeStart <= time.clock) {
// if it has started, we show running timer
message = TimerMessage.waiting;
timer = time.current ?? 0;
} else {
// if it has ended, we show how long ago
message = TimerMessage.ended;
timer = follow.timeEnd;
}
} else {
// if it has ended, we show how long ago
message = TimerMessage.ended;
timer = follow.timeEnd;
}
} }
return { message, timer };
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'));
}
@@ -0,0 +1,29 @@
import { getTimeOption, hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
const makePersistedField = (id: string, value: string): ViewOption => {
return {
id,
title: 'Used to keep the selection on submit',
description: 'Used to keep the selection on submit',
type: 'persist',
value,
};
};
type Persisted = { id: string; value: string };
export const getCountdownOptions = (timeFormat: string, persisted?: Persisted): ViewOption[] => [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
{ section: 'View behaviour' },
{
id: 'showProjected',
title: 'Show projected time',
description: 'Show projected times for the event, as well as apply the runtime offset to the timer.',
type: 'boolean',
defaultValue: false,
},
...(persisted ? [makePersistedField(persisted.id, persisted.value)] : []),
];
@@ -72,6 +72,8 @@
font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif; font-family: var(--lowerThird-font-family-override), Lato, Arial, sans-serif;
text-align: var(--lowerThird-text-align-override, left); text-align: var(--lowerThird-text-align-override, left);
white-space: nowrap; white-space: nowrap;
@include ellipsis-text();
&::after { &::after {
content: '\200b'; content: '\200b';
} }
@@ -3,12 +3,13 @@ import { useSearchParams } from 'react-router-dom';
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types'; import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import { getLowerThirdOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { getLowerThirdOptions } from './lowerThird.options';
import './LowerThird.scss'; import './LowerThird.scss';
type LowerOptions = { type LowerOptions = {
@@ -182,7 +183,7 @@ export default function LowerThird(props: LowerProps) {
return ( return (
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}> <div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
<ViewParamsEditor paramFields={getLowerThirdOptions(customFields)} /> <ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} />
<div <div
className={`container container--${playState}`} className={`container container--${playState}`}
style={{ minWidth: `${options.width}vw`, animationDuration: transition }} style={{ minWidth: `${options.width}vw`, animationDuration: transition }}
@@ -0,0 +1,132 @@
import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
});
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
none: 'None',
});
return [
{ section: 'View behaviour' },
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'manual',
},
{ section: 'Data sources' },
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: topSourceOptions,
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'none',
},
{ section: 'View style override' },
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
};
@@ -2,7 +2,6 @@ import { useSearchParams } from 'react-router-dom';
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types'; import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -11,6 +10,8 @@ import { OverridableOptions } from '../../../common/models/View.types';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import { getFormattedTimer, getTimerByType, isStringBoolean } from '../common/viewUtils'; import { getFormattedTimer, getTimerByType, isStringBoolean } from '../common/viewUtils';
import { MINIMAL_TIMER_OPTIONS } from './minimalTimer.options';
import './MinimalTimer.scss'; import './MinimalTimer.scss';
interface MinimalTimerProps { interface MinimalTimerProps {
@@ -161,7 +162,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
}} }}
data-testid='minimal-timer' data-testid='minimal-timer'
> >
<ViewParamsEditor paramFields={MINIMAL_TIMER_OPTIONS} /> <ViewParamsEditor viewOptions={MINIMAL_TIMER_OPTIONS} />
{showEndMessage ? ( {showEndMessage ? (
<div className='end-message'>{viewSettings.endMessage}</div> <div className='end-message'>{viewSettings.endMessage}</div>
) : ( ) : (
@@ -0,0 +1,91 @@
import { hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
{ section: 'Timer Options' },
hideTimerSeconds,
{ section: 'Element visibility' },
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
{ section: 'View style override' },
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
@@ -43,7 +43,7 @@
.time { .time {
font-size: clamp(32px, 3.5vw, 50px); font-size: clamp(32px, 3.5vw, 50px);
font-weight: 600; color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em; letter-spacing: 0.05em;
line-height: 0.95em; line-height: 0.95em;
} }
@@ -8,7 +8,6 @@ import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext'; import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav'; import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard'; import TitleCard from '../../../common/components/title-card/TitleCard';
import { getPublicOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -19,8 +18,12 @@ import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { getPublicOptions } from './public.options';
import './Public.scss'; import './Public.scss';
export const MotionTitleCard = motion(TitleCard);
interface BackstageProps { interface BackstageProps {
customFields: CustomFields; customFields: CustomFields;
isMirrored: boolean; isMirrored: boolean;
@@ -71,7 +74,7 @@ export default function Public(props: BackstageProps) {
return ( return (
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'> <div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<ViewParamsEditor paramFields={publicOptions} /> <ViewParamsEditor viewOptions={publicOptions} />
<div className='project-header'> <div className='project-header'>
{general.title} {general.title}
<div className='clock-container'> <div className='clock-container'>
@@ -83,31 +86,33 @@ export default function Public(props: BackstageProps) {
<div className='now-container'> <div className='now-container'>
<AnimatePresence> <AnimatePresence>
{publicEventNow && ( {publicEventNow && (
<motion.div <MotionTitleCard
className='event now' className='event now'
key='now' key='now'
variants={titleVariants} variants={titleVariants}
initial='hidden' initial='hidden'
animate='visible' animate='visible'
exit='exit' exit='exit'
> label='now'
<TitleCard label='now' title={publicEventNow.title} secondary={secondaryTextNow} /> title={publicEventNow.title}
</motion.div> secondary={secondaryTextNow}
/>
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence> <AnimatePresence>
{publicEventNext && ( {publicEventNext && (
<motion.div <MotionTitleCard
className='event next' className='event next'
key='next' key='next'
variants={titleVariants} variants={titleVariants}
initial='hidden' initial='hidden'
animate='visible' animate='visible'
exit='exit' exit='exit'
> label='next'
<TitleCard label='next' title={publicEventNext.title} secondary={secondaryTextNext} /> title={publicEventNext.title}
</motion.div> secondary={secondaryTextNext}
/>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
@@ -0,0 +1,44 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{
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: '',
},
{ section: 'Schedule options' },
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -168,7 +168,6 @@ $orange-active: #f60;
display: flex; display: flex;
align-items: start; align-items: start;
gap: 0.5em; gap: 0.5em;
white-space: nowrap;
&--now { &--now {
color: var(--studio-active-label, $red-active); color: var(--studio-active-label, $red-active);
@@ -4,7 +4,6 @@ import { isOntimeEvent, Playback } from 'ontime-types';
import { millisToString, removeSeconds } from 'ontime-utils'; import { millisToString, removeSeconds } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
import { getStudioClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import useFitText from '../../../common/hooks/useFitText'; import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
@@ -14,6 +13,7 @@ import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { isStringBoolean } from '../common/viewUtils'; import { isStringBoolean } from '../common/viewUtils';
import { getStudioClockOptions } from './studioClock.options';
import { secondsInMillis, trimRundown } from './studioClock.utils'; import { secondsInMillis, trimRundown } from './studioClock.utils';
import './StudioClock.scss'; import './StudioClock.scss';
@@ -36,7 +36,7 @@ export default function StudioClock(props: StudioClockProps) {
// TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts? // TODO: can we prevent the Flash of Unstyled Content on the 7segment fonts?
// deferring rendering seems to affect styling (font and useFitText) // deferring rendering seems to affect styling (font and useFitText)
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 }); const { fontSize: titleFontSize, ref: titleRef } = useFitText({ minFontSize: 150, maxFontSize: 500 });
const activeIndicators = [...Array(12).keys()]; const activeIndicators = [...Array(12).keys()];
const secondsIndicators = [...Array(60).keys()]; const secondsIndicators = [...Array(60).keys()];
@@ -75,7 +75,7 @@ export default function StudioClock(props: StudioClockProps) {
return ( return (
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'> <div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
<ViewParamsEditor paramFields={studioClockOptions} /> <ViewParamsEditor viewOptions={studioClockOptions} />
<div className='clock-container'> <div className='clock-container'>
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>} {hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
<div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div> <div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
@@ -0,0 +1,9 @@
import { getTimeOption, hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
import type { ViewOption } from '../../../common/components/view-params-editor/types';
export const getStudioClockOptions = (timeFormat: string): ViewOption[] => [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
];
@@ -15,7 +15,7 @@
padding: min(2vh, 16px) clamp(16px, 10vw, 64px); padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: grid; display: grid;
grid-template-columns: auto; grid-template-columns: 100%;
grid-template-rows: auto 1fr auto auto auto; grid-template-rows: auto 1fr auto auto auto;
grid-template-areas: grid-template-areas:
'clock' 'clock'
@@ -15,7 +15,6 @@ import {
import { overrideStylesURL } from '../../../common/api/constants'; import { overrideStylesURL } from '../../../common/api/constants';
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 TitleCard from '../../../common/components/title-card/TitleCard';
import { getTimerOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -25,6 +24,8 @@ import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getFormattedTimer, getPropertyValue, getTimerByType, isStringBoolean } from '../common/viewUtils'; import { getFormattedTimer, getPropertyValue, getTimerByType, isStringBoolean } from '../common/viewUtils';
import { getTimerOptions } from './timer.options';
import './Timer.scss'; import './Timer.scss';
// motion // motion
@@ -43,6 +44,8 @@ const titleVariants = {
}, },
}; };
export const MotionTitleCard = motion(TitleCard);
interface TimerProps { interface TimerProps {
customFields: CustomFields; customFields: CustomFields;
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
@@ -156,7 +159,7 @@ export default function Timer(props: TimerProps) {
return ( return (
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'> <div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
<ViewParamsEditor paramFields={timerOptions} /> <ViewParamsEditor viewOptions={timerOptions} />
<div className={showBlackout ? 'blackout blackout--active' : 'blackout'} /> <div className={showBlackout ? 'blackout blackout--active' : 'blackout'} />
{!userOptions.hideMessage && ( {!userOptions.hideMessage && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}> <div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
@@ -211,31 +214,33 @@ export default function Timer(props: TimerProps) {
<> <>
<AnimatePresence> <AnimatePresence>
{eventNow?.title && ( {eventNow?.title && (
<motion.div <MotionTitleCard
className='event now' className='event now'
key='now' key='now'
variants={titleVariants} variants={titleVariants}
initial='hidden' initial='hidden'
animate='visible' animate='visible'
exit='exit' exit='exit'
> label='now'
<TitleCard label='now' title={mainFieldNow} secondary={secondaryTextNow} /> title={mainFieldNow}
</motion.div> secondary={secondaryTextNow}
/>
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence> <AnimatePresence>
{eventNext?.title && ( {eventNext?.title && (
<motion.div <MotionTitleCard
className='event next'
key='next' key='next'
variants={titleVariants} variants={titleVariants}
initial='hidden' initial='hidden'
animate='visible' animate='visible'
exit='exit' exit='exit'
> label='next'
<TitleCard label='next' title={mainFieldNext} secondary={secondaryTextNext} /> title={mainFieldNext}
</motion.div> secondary={secondaryTextNext}
className='event next'
/>
)} )}
</AnimatePresence> </AnimatePresence>
</> </>
@@ -0,0 +1,74 @@
import { CustomFields } from 'ontime-types';
import {
getTimeOption,
hideTimerSeconds,
makeOptionsFromCustomFields,
showLeadingZeros,
} from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
showLeadingZeros,
{ section: 'Data sources' },
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{ section: 'Element visibility' },
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
};
+7 -1
View File
@@ -1,4 +1,4 @@
@use "ontimeStyles" as *; @use 'ontimeStyles' as *;
//////////////////////////////////// general app elements //////////////////////////////////// general app elements
@@ -15,3 +15,9 @@
color: $ontime-color; color: $ontime-color;
} }
} }
@mixin ellipsis-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+4
View File
@@ -28,3 +28,7 @@ $timer-finished-color: $playback-negative;
$timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-color-override $timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-color-override
$external-color: rgba(white, 70%); // --external-color-override $external-color: rgba(white, 70%); // --external-color-override
// properties of other timers (clock and countdown)
$timer-label-size: clamp(16px, 1.5vw, 24px);
$timer-value-size: clamp(2rem, 3.5vw, 3.5rem);
+5 -3
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en'; import { TranslationObject } from './en';
export const langDe: TranslationObject = { export const langDe: TranslationObject = {
'common.end_time': 'Endzeit', 'common.expected_finish': 'Erwartetes Ende',
'common.expected_finish': 'Voraussichtliches Ende',
'common.minutes': 'min', 'common.minutes': 'min',
'common.now': 'Jetzt', 'common.now': 'Jetzt',
'common.next': 'Nächste', 'common.next': 'Nächste',
'common.public_message': 'Öffentliche Nachricht', 'common.public_message': 'Öffentliche Nachricht',
'common.start_time': 'Startzeit', 'common.scheduled_start': 'Geplanter beginn',
'common.scheduled_end': 'Geplantes ende',
'common.projected_start': 'Erwartetes beginn',
'common.projected_end': 'Erwartetes ende',
'common.stage_timer': 'Bühnen-Timer', 'common.stage_timer': 'Bühnen-Timer',
'common.started_at': 'Gestartet am', 'common.started_at': 'Gestartet am',
'common.time_now': 'Aktuelle Zeit', 'common.time_now': 'Aktuelle Zeit',
+4 -2
View File
@@ -1,11 +1,13 @@
export const langEn = { export const langEn = {
'common.end_time': 'End Time',
'common.expected_finish': 'Expected Finish', 'common.expected_finish': 'Expected Finish',
'common.minutes': 'min', 'common.minutes': 'min',
'common.now': 'Now', 'common.now': 'Now',
'common.next': 'Next', 'common.next': 'Next',
'common.public_message': 'Public message', 'common.public_message': 'Public message',
'common.start_time': 'Start Time', 'common.scheduled_start': 'Scheduled start',
'common.scheduled_end': 'Scheduled end',
'common.projected_start': 'Projected start',
'common.projected_end': 'Projected end',
'common.stage_timer': 'Stage Timer', 'common.stage_timer': 'Stage Timer',
'common.started_at': 'Started At', 'common.started_at': 'Started At',
'common.time_now': 'Time now', 'common.time_now': 'Time now',
+4 -2
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en'; import { TranslationObject } from './en';
export const langEs: TranslationObject = { export const langEs: TranslationObject = {
'common.end_time': 'Hora de finalización',
'common.expected_finish': 'Finalización esperada', 'common.expected_finish': 'Finalización esperada',
'common.minutes': 'min', 'common.minutes': 'min',
'common.now': 'Ahora', 'common.now': 'Ahora',
'common.next': 'Siguiente', 'common.next': 'Siguiente',
'common.public_message': 'Mensaje público', 'common.public_message': 'Mensaje público',
'common.start_time': 'Hora de inicio', 'common.scheduled_start': 'Inicio programado',
'common.scheduled_end': 'Fin programado',
'common.projected_start': 'Inicio previsto',
'common.projected_end': 'Fin previsto',
'common.stage_timer': 'Temporizador de presentador', 'common.stage_timer': 'Temporizador de presentador',
'common.started_at': 'Iniciado en', 'common.started_at': 'Iniciado en',
'common.time_now': 'Ahora', 'common.time_now': 'Ahora',
+4 -2
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en'; import { TranslationObject } from './en';
export const langFr: TranslationObject = { export const langFr: TranslationObject = {
'common.end_time': 'Termine à',
'common.expected_finish': 'Fin estimée à', 'common.expected_finish': 'Fin estimée à',
'common.minutes': 'min', 'common.minutes': 'min',
'common.now': 'Maintenant', 'common.now': 'Maintenant',
'common.next': 'A suivre', 'common.next': 'A suivre',
'common.public_message': 'Message public', 'common.public_message': 'Message public',
'common.start_time': 'Heure de début', 'common.scheduled_start': 'Début prévu',
'common.scheduled_end': 'Fin prévue',
'common.projected_start': 'Début projeté',
'common.projected_end': 'Fin projetée',
'common.stage_timer': 'Minuteur de scène', 'common.stage_timer': 'Minuteur de scène',
'common.started_at': 'Commencé à', 'common.started_at': 'Commencé à',
'common.time_now': 'Heure', 'common.time_now': 'Heure',
+4 -2
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en'; import { TranslationObject } from './en';
export const langIt: TranslationObject = { export const langIt: TranslationObject = {
'common.end_time': 'Ora di Fine',
'common.expected_finish': 'Fine Prevista', 'common.expected_finish': 'Fine Prevista',
'common.minutes': 'min', 'common.minutes': 'min',
'common.now': 'Adesso', 'common.now': 'Adesso',
'common.next': 'Prossimo', 'common.next': 'Prossimo',
'common.public_message': 'Messaggio pubblico', 'common.public_message': 'Messaggio pubblico',
'common.start_time': 'Ora di Inizio', 'common.scheduled_start': 'Inizio programmato',
'common.scheduled_end': 'Fine programmata',
'common.projected_start': 'Inizio previsto',
'common.projected_end': 'Fine prevista',
'common.stage_timer': 'Orologio Palco', 'common.stage_timer': 'Orologio Palco',
'common.started_at': 'Iniziato Alle', 'common.started_at': 'Iniziato Alle',
'common.time_now': 'Ora attuale', 'common.time_now': 'Ora attuale',
+4 -2
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en'; import { TranslationObject } from './en';
export const langNo: TranslationObject = { export const langNo: TranslationObject = {
'common.end_time': 'Sluttid',
'common.expected_finish': 'Forventet slutt', 'common.expected_finish': 'Forventet slutt',
'common.minutes': 'min', 'common.minutes': 'min',
'common.now': 'Nå', 'common.now': 'Nå',
'common.next': 'Neste', 'common.next': 'Neste',
'common.public_message': 'Offentlig beskjed', 'common.public_message': 'Offentlig beskjed',
'common.start_time': 'Starttid', 'common.scheduled_start': 'Planlagt start',
'common.scheduled_end': 'Planlagt slutt',
'common.projected_start': 'Forventet start',
'common.projected_end': 'Forventet slutt',
'common.stage_timer': 'Scenetimer', 'common.stage_timer': 'Scenetimer',
'common.started_at': 'Startet', 'common.started_at': 'Startet',
'common.time_now': 'Klokken nå', 'common.time_now': 'Klokken nå',
+4 -3
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en'; import { TranslationObject } from './en';
export const langPl: TranslationObject = { export const langPl: TranslationObject = {
'common.end_time': 'Czas zakończenia',
'common.expected_finish': 'Zakładany czas zakończenia', 'common.expected_finish': 'Zakładany czas zakończenia',
'common.minutes': 'min', 'common.minutes': 'min',
'common.now': 'Teraz', 'common.now': 'Teraz',
'common.next': 'Następnie', 'common.next': 'Następnie',
'common.public_message': 'Wiadomość publiczna', 'common.public_message': 'Wiadomość publiczna',
'common.start_time': 'Czas rozpoczęcia', 'common.scheduled_start': 'Planowany początek',
'common.scheduled_end': 'Planowany koniec',
'common.projected_start': 'Przewidywany początek',
'common.projected_end': 'Przewidywany koniec',
'common.stage_timer': 'Timer Scena', 'common.stage_timer': 'Timer Scena',
'common.started_at': 'Rozpoczęte o', 'common.started_at': 'Rozpoczęte o',
'common.time_now': 'Aktualny czas', 'common.time_now': 'Aktualny czas',
@@ -18,4 +20,3 @@ export const langPl: TranslationObject = {
'countdown.waiting': 'Oczekiwanie na start', 'countdown.waiting': 'Oczekiwanie na start',
'countdown.overtime': 'ponad czasem', 'countdown.overtime': 'ponad czasem',
}; };
+4 -2
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en'; import { TranslationObject } from './en';
export const langPt: TranslationObject = { export const langPt: TranslationObject = {
'common.end_time': 'Hora de término',
'common.expected_finish': 'Término esperado', 'common.expected_finish': 'Término esperado',
'common.minutes': 'min', 'common.minutes': 'min',
'common.now': 'Agora', 'common.now': 'Agora',
'common.next': 'Próximo', 'common.next': 'Próximo',
'common.public_message': 'Mensagem pública', 'common.public_message': 'Mensagem pública',
'common.start_time': 'Hora de início', 'common.scheduled_start': 'Início programado',
'common.scheduled_end': 'Fim programado',
'common.projected_start': 'Início previsto',
'common.projected_end': 'Fim previsto',
'common.stage_timer': 'Temporizador do presentador', 'common.stage_timer': 'Temporizador do presentador',
'common.started_at': 'Iniciado em', 'common.started_at': 'Iniciado em',
'common.time_now': 'Hora atual', 'common.time_now': 'Hora atual',
+4 -2
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en'; import { TranslationObject } from './en';
export const langSv: TranslationObject = { export const langSv: TranslationObject = {
'common.end_time': 'Sluttid',
'common.expected_finish': 'Förväntat slut', 'common.expected_finish': 'Förväntat slut',
'common.minutes': 'min', 'common.minutes': 'min',
'common.now': 'Nu', 'common.now': 'Nu',
'common.next': 'Nästa', 'common.next': 'Nästa',
'common.public_message': 'Offentligt meddelande', 'common.public_message': 'Offentligt meddelande',
'common.start_time': 'Starttid', 'common.scheduled_start': 'Planerad start',
'common.scheduled_end': 'Planerad slut',
'common.projected_start': 'Beräknad start',
'common.projected_end': 'Beräknad slut',
'common.stage_timer': 'Timer för scenen', 'common.stage_timer': 'Timer för scenen',
'common.started_at': 'Började vid', 'common.started_at': 'Började vid',
'common.time_now': 'Klockan nu', 'common.time_now': 'Klockan nu',
+1
View File
@@ -61,6 +61,7 @@ export default defineConfig({
additionalData: ` additionalData: `
@use './src/theme/ontimeColours' as *; @use './src/theme/ontimeColours' as *;
@use './src/theme/ontimeStyles' as *; @use './src/theme/ontimeStyles' as *;
@use './src/theme/mixins' as *;
`, `,
}, },
}, },
+5 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.3.2", "version": "3.4.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
@@ -12,17 +12,18 @@
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"main": "main.js", "main": "main.js",
"devDependencies": { "devDependencies": {
"electron": "^28.3.3", "electron": "^31.2.0",
"electron-builder": "^24.13.3", "electron-builder": "^24.13.3",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"prettier": "^3.0.3" "prettier": "^3.0.3",
"wait-on": "^7.2.0"
}, },
"scripts": { "scripts": {
"postinstall": "", "postinstall": "",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"lint-staged": "eslint", "lint-staged": "eslint",
"dev": "cross-env NODE_ENV=development electron .", "dev": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
"dist-win": "electron-builder --publish=never --x64 --win", "dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac", "dist-mac": "electron-builder --publish=never --mac",
"dist-mac:local": "electron-builder --publish=never --mac -c.mac.identity=null", "dist-mac:local": "electron-builder --publish=never --mac -c.mac.identity=null",
+11 -12
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "3.3.2", "version": "3.4.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -20,30 +20,29 @@
"node-xlsx": "^0.23.0", "node-xlsx": "^0.23.0",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"steno": "^3.1.0", "steno": "^4.0.2",
"ts-essentials": "^9.4.1",
"ws": "^8.13.0" "ws": "^8.13.0"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/multer": "^1.4.11", "@types/multer": "^1.4.11",
"@types/node": "^18.11.18", "@types/node": "^20.14.10",
"@types/node-osc": "^6.0.2", "@types/node-osc": "^6.0.2",
"@types/websocket": "^1.0.5", "@types/websocket": "^1.0.5",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^v7.12.0", "@typescript-eslint/eslint-plugin": "^v7.16.1",
"@typescript-eslint/parser": "^7.12.0", "@typescript-eslint/parser": "^7.16.1",
"esbuild": "^0.19.10", "esbuild": "^0.19.10",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"nodemon": "^2.0.20",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"prettier": "^3.3.1", "prettier": "^3.3.1",
"server-timing": "^3.3.3", "server-timing": "^3.3.3",
"shx": "^0.3.4", "shx": "^0.3.4",
"ts-node": "^10.9.1", "ts-essentials": "^9.4.1",
"typescript": "^5.4.3", "tsx": "^4.16.2",
"typescript": "^5.5.3",
"vitest": "^1.6.0" "vitest": "^1.6.0"
}, },
"scripts": { "scripts": {
@@ -51,9 +50,9 @@
"set:demoproject": "shx cp ../../demo-db/db.json src/preloaded-db/db.json", "set:demoproject": "shx cp ../../demo-db/db.json src/preloaded-db/db.json",
"set:testproject": "shx cp ../../demo-db/db.json test-db/db.json", "set:testproject": "shx cp ../../demo-db/db.json test-db/db.json",
"postinstall": "pnpm addversion && pnpm set:demoproject && pnpm set:testproject", "postinstall": "pnpm addversion && pnpm set:demoproject && pnpm set:testproject",
"dev": "cross-env NODE_ENV=development nodemon --exec \"ts-node-esm\" ./src/index.ts", "dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
"dev:inspect": "cross-env NODE_ENV=development nodemon --exec \"node --inspect --loader ts-node/esm\" ./src/index.ts", "dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
"dev:test": "cross-env IS_TEST=true nodemon --exec \"ts-node-esm\" ./src/index.ts", "dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
"prebuild": "pnpm set:demoproject", "prebuild": "pnpm set:demoproject",
"build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs", "build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
"build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs", "build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
+58 -97
View File
@@ -8,32 +8,17 @@ import {
} from 'ontime-types'; } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import { join } from 'path';
import { existsSync } from 'fs';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { doesProjectExist, handleUploaded } from '../../services/project-service/projectServiceUtils.js';
import { resolveDbDirectory, resolveProjectsDirectory } from '../../setup/index.js';
import * as projectService from '../../services/project-service/ProjectService.js'; import * as projectService from '../../services/project-service/ProjectService.js';
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
import { appStateService } from '../../services/app-state-service/AppStateService.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) { export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
// all fields are optional in validation
if (failEmptyObjects(req.body, res)) {
res.status(400).send({ message: 'No field found to patch' });
return;
}
try { try {
const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = req.body; const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = req.body;
const patchDb: DatabaseModel = { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http }; const patchDb: DatabaseModel = { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http };
const newData = await projectService.applyDataModel(patchDb); const newData = await projectService.patchCurrentProject(patchDb);
res.status(200).send(newData); res.status(200).send(newData);
} catch (error) { } catch (error) {
@@ -52,27 +37,20 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
* or a 500 status with an error message in case of an exception. * or a 500 status with an error message in case of an exception.
*/ */
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) { export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
const newProjectData: ProjectData = {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
};
try { try {
const filename = generateUniqueFileName(resolveProjectsDirectory, req.body.filename); const newFileName = await projectService.createProject(req.body.filename, newProjectData);
const errors = projectService.validateProjectFiles({ newFilename: filename });
if (errors.length) {
return res.status(409).send({ message: 'Project with title already exists' });
}
const newProjectData: ProjectData = {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
};
await projectService.createProjectFile(filename, newProjectData);
res.status(200).send({ res.status(200).send({
filename, filename: newFileName,
}); });
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
@@ -85,10 +63,8 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
*/ */
export async function projectDownload(req: Request, res: Response) { export async function projectDownload(req: Request, res: Response) {
const { filename } = req.body; const { filename } = req.body;
const pathToFile = join(resolveDbDirectory, filename); const pathToFile = doesProjectExist(filename);
if (!pathToFile) {
// Check if the file exists before attempting to download
if (!existsSync(pathToFile)) {
return res.status(404).send({ message: `Project ${filename} not found.` }); return res.status(404).send({ message: `Project ${filename} not found.` });
} }
@@ -102,6 +78,7 @@ export async function projectDownload(req: Request, res: Response) {
/** /**
* uploads, parses and applies the data from a given file * uploads, parses and applies the data from a given file
* Pretty much loadProject but with the extra upload step
*/ */
export async function postProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) { export async function postProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (!req.file) { if (!req.file) {
@@ -110,23 +87,18 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
} }
try { try {
const options = req.query;
const { filename, path } = req.file; const { filename, path } = req.file;
await handleUploaded(path, filename);
await projectService.handleUploadedFile(path, filename); await projectService.loadProjectFile(filename);
await projectService.applyProjectFile(filename, options);
const oscSettings = await DataProvider.getOsc();
const httpSettings = await DataProvider.getHttp();
oscIntegration.init(oscSettings);
httpIntegration.init(httpSettings);
res.status(201).send({ res.status(201).send({
message: `Loaded project ${filename}`, message: `Loaded project ${filename}`,
}); });
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
return res.status(403).send({ message });
}
res.status(400).send({ message }); res.status(400).send({ message });
} }
} }
@@ -150,23 +122,16 @@ export async function listProjects(_req: Request, res: Response<ProjectFileListR
export async function loadProject(req: Request, res: Response<MessageResponse | ErrorResponse>) { export async function loadProject(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try { try {
const name = req.body.filename; const name = req.body.filename;
if (!projectService.doesProjectExist(name)) { await projectService.loadProjectFile(name);
return res.status(404).send({ message: 'File not found' });
}
await projectService.applyProjectFile(name);
const oscSettings = await DataProvider.getOsc();
const httpSettings = await DataProvider.getHttp();
oscIntegration.init(oscSettings);
httpIntegration.init(httpSettings);
res.status(201).send({ res.status(201).send({
message: `Loaded project ${name}`, message: `Loaded project ${name}`,
}); });
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
return res.status(403).send({ message });
}
res.status(500).send({ message }); res.status(500).send({ message });
} }
} }
@@ -182,16 +147,12 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
* or a 500 status with an error message in case of an exception. * or a 500 status with an error message in case of an exception.
*/ */
export async function duplicateProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) { export async function duplicateProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
// file to copy from
const { filename } = req.params;
// new file name
const { newFilename } = req.body;
try { try {
const { filename } = req.params;
const { newFilename } = req.body;
const errors = projectService.validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
await projectService.duplicateProjectFile(filename, newFilename); await projectService.duplicateProjectFile(filename, newFilename);
res.status(201).send({ res.status(201).send({
@@ -199,6 +160,10 @@ 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')) {
return res.status(403).send({ message });
}
res.status(500).send({ message }); res.status(500).send({ message });
} }
} }
@@ -215,16 +180,9 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
*/ */
export async function renameProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) { export async function renameProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try { try {
const { filename: newFilename } = req.body; const { newFilename } = req.body;
const { filename } = req.params; const { filename } = req.params;
const errors = projectService.validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
// Rename the file
await projectService.renameProjectFile(filename, newFilename); await projectService.renameProjectFile(filename, newFilename);
res.status(201).send({ res.status(201).send({
@@ -232,6 +190,10 @@ 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')) {
return res.status(403).send({ message });
}
res.status(500).send({ message }); res.status(500).send({ message });
} }
} }
@@ -247,33 +209,32 @@ export async function renameProjectFile(req: Request, res: Response<MessageRespo
* or a 500 status with an error message in case of an exception. * or a 500 status with an error message in case of an exception.
*/ */
export async function deleteProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) { export async function deleteProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
const { filename } = req.params;
try { try {
const { filename } = req.params;
const { lastLoadedProject } = await appStateService.get();
if (lastLoadedProject === filename) {
return res.status(403).send({ message: 'Cannot delete currently loaded project' });
}
const errors = projectService.validateProjectFiles({ filename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
await projectService.deleteProjectFile(filename); await projectService.deleteProjectFile(filename);
res.status(204).send({ res.status(200).send({
message: `Deleted project ${filename}`, message: `Deleted project ${filename}`,
}); });
} catch (error) {
const message = getErrorMessage(error);
if (message === 'Cannot delete currently loaded project') {
return res.status(403).send({ message });
}
if (message === 'Project file not found') {
return res.status(404).send({ message });
}
res.status(500).send({ message });
}
}
export async function getInfo(_req: Request, res: Response<GetInfo | ErrorResponse>) {
try {
const info = await projectService.getInfo();
res.status(200).send(info);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
res.status(500).send({ message }); res.status(500).send({ message });
} }
} }
export async function getInfo(_req: Request, res: Response<GetInfo>) {
const info = await projectService.getInfo();
res.status(200).send(info);
}
+3 -2
View File
@@ -18,6 +18,7 @@ import {
validatePatchProject, validatePatchProject,
validateFilenameBody, validateFilenameBody,
validateFilenameParam, validateFilenameParam,
validateNewFilenameBody,
} from './db.validation.js'; } from './db.validation.js';
export const router = express.Router(); export const router = express.Router();
@@ -31,8 +32,8 @@ router.post('/new', validateFilenameBody, validateNewProject, createProjectFile)
router.get('/all', listProjects); router.get('/all', listProjects);
router.post('/load', validateFilenameBody, loadProject); router.post('/load', validateFilenameBody, loadProject);
router.post('/:filename/duplicate', validateFilenameParam, validateFilenameBody, duplicateProjectFile); router.post('/:filename/duplicate', validateFilenameParam, validateNewFilenameBody, duplicateProjectFile);
router.put('/:filename/rename', validateFilenameParam, validateFilenameBody, renameProjectFile); router.put('/:filename/rename', validateFilenameParam, validateNewFilenameBody, renameProjectFile);
router.delete('/:filename', validateFilenameParam, deleteProjectFile); router.delete('/:filename', validateFilenameParam, deleteProjectFile);
router.get('/info', getInfo); router.get('/info', getInfo);
+34 -1
View File
@@ -26,13 +26,22 @@ export const validateNewProject = [
* @description Validates request for pathing data in the project. * @description Validates request for pathing data in the project.
*/ */
export const validatePatchProject = [ export const validatePatchProject = [
// Custom validator to ensure the body is not empty
(req: Request, res: Response, next: NextFunction) => {
if (Object.keys(req.body).length === 0) {
return res.status(422).json({ errors: [{ msg: 'Request body cannot be empty' }] });
}
next();
},
body('rundown').isArray().optional({ nullable: false }), body('rundown').isArray().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }), body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }), body('settings').isObject().optional({ nullable: false }),
body('viewSettings').isObject().optional({ nullable: false }), body('viewSettings').isObject().optional({ nullable: false }),
body('aliases').isArray().optional({ nullable: false }), body('urlPresets').isArray().optional({ nullable: false }),
body('customFields').isObject().optional({ nullable: false }), body('customFields').isObject().optional({ nullable: false }),
body('osc').isObject().optional({ nullable: false }), body('osc').isObject().optional({ nullable: false }),
body('http').isObject().optional({ nullable: false }),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); const errors = validationResult(req);
@@ -41,6 +50,30 @@ export const validatePatchProject = [
}, },
]; ];
/**
* @description Validates request with newFilename in the body.
*/
export const validateNewFilenameBody = [
body('newFilename')
.exists()
.isString()
.trim()
.customSanitizer((input: string) => sanitize(input))
.withMessage('Failed to sanitize the filename')
.notEmpty()
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/** /**
* @description Validates request with filename in the body. * @description Validates request with filename in the body.
*/ */
@@ -11,7 +11,7 @@ import { existsSync } from 'fs';
import xlsx from 'node-xlsx'; import xlsx from 'node-xlsx';
import { parseExcel } from '../../utils/parser.js'; import { parseExcel } from '../../utils/parser.js';
import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js'; import { parseRundown } from '../../utils/parserFunctions.js';
import { deleteFile } from '../../utils/parserUtils.js'; import { deleteFile } from '../../utils/parserUtils.js';
let excelData: { name: string; data: unknown[][] }[] = []; let excelData: { name: string; data: unknown[][] }[] = [];
@@ -42,11 +42,10 @@ export function generateRundownPreview(options: ImportMap): { rundown: OntimeRun
const dataFromExcel = parseExcel(data, options); const dataFromExcel = parseExcel(data, options);
// we run the parsed data through an extra step to ensure the objects shape // we run the parsed data through an extra step to ensure the objects shape
const rundown = parseRundown(dataFromExcel); const { rundown, customFields } = parseRundown(dataFromExcel);
if (rundown.length === 0) { if (rundown.length === 0) {
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`); throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
} }
const customFields = parseCustomFields(dataFromExcel);
// clear the data // clear the data
excelData = []; excelData = [];
@@ -1,14 +1,14 @@
import type { ErrorResponse, HttpSettings } from 'ontime-types'; import type { ErrorResponse, HttpSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { getErrorMessage } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getHTTP(_req: Request, res: Response<HttpSettings>) { export async function getHTTP(_req: Request, res: Response<HttpSettings>) {
const http = DataProvider.getHttp(); const http = getDataProvider().getHttp();
res.status(200).send(http); res.status(200).send(http);
} }
@@ -22,7 +22,7 @@ export async function postHTTP(req: Request, res: Response<HttpSettings | ErrorR
httpIntegration.init(httpSettings); httpIntegration.init(httpSettings);
// we persist the data after init to avoid persisting invalid data // we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setHttp(httpSettings); const result = await getDataProvider().setHttp(httpSettings);
res.send(result).status(200); res.send(result).status(200);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
@@ -1,14 +1,14 @@
import type { ErrorResponse, OSCSettings } from 'ontime-types'; import type { ErrorResponse, OSCSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { getErrorMessage } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getOSC(_req: Request, res: Response<OSCSettings>) { export async function getOSC(_req: Request, res: Response<OSCSettings>) {
const osc = DataProvider.getOsc(); const osc = getDataProvider().getOsc();
res.status(200).send(osc); res.status(200).send(osc);
} }
@@ -22,7 +22,7 @@ export async function postOSC(req: Request, res: Response<OSCSettings | ErrorRes
oscIntegration.init(oscSettings); oscIntegration.init(oscSettings);
// we persist the data after init to avoid persisting invalid data // we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setOsc(oscSettings); const result = await getDataProvider().setOsc(oscSettings);
res.send(result).status(200); res.send(result).status(200);
} catch (error) { } catch (error) {
@@ -1,13 +1,14 @@
import { ErrorResponse, ProjectData } from 'ontime-types'; import { ErrorResponse, ProjectData } from 'ontime-types';
import type { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getProjectData(_req: Request, res: Response<ProjectData>) { export async function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(DataProvider.getProjectData()); res.json(getDataProvider().getProjectData());
} }
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) { export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
@@ -25,7 +26,7 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
backstageInfo: req.body?.backstageInfo, backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage, endMessage: req.body?.endMessage,
}); });
const newData = await DataProvider.setProjectData(newEvent); const newData = await getDataProvider().setProjectData(newEvent);
res.status(200).send(newData); res.status(200).send(newData);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
@@ -1,4 +1,5 @@
import { ErrorResponse, MessageResponse, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types'; import { ErrorResponse, MessageResponse, OntimeRundownEntry, RundownCached, RundownPaginated } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
@@ -13,19 +14,63 @@ import {
reorderEvent, reorderEvent,
swapEvents, swapEvents,
} from '../../services/rundown-service/RundownService.js'; } from '../../services/rundown-service/RundownService.js';
import { getNormalisedRundown, getRundown } from '../../services/rundown-service/rundownUtils.js'; import {
import { getErrorMessage } from 'ontime-utils'; getEventWithId,
getNormalisedRundown,
export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) { getPaginated,
const rundown = getRundown(); getRundown,
res.json(rundown); } from '../../services/rundown-service/rundownUtils.js';
}
export async function rundownGetNormalised(_req: Request, res: Response<RundownCached>) { export async function rundownGetNormalised(_req: Request, res: Response<RundownCached>) {
const cachedRundown = getNormalisedRundown(); const cachedRundown = getNormalisedRundown();
res.json(cachedRundown); res.json(cachedRundown);
} }
export async function rundownGetById(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
const { eventId } = req.params;
try {
const event = getEventWithId(eventId);
if (!event) {
res.status(404).send({ message: 'Event not found' });
return;
}
res.status(200).json(event);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).json({ message });
}
}
export async function rundownGetPaginated(req: Request, res: Response<RundownPaginated | ErrorResponse>) {
const { limit, offset } = req.query;
if (limit == null && offset == null) {
return res.json({
rundown: getRundown(),
total: getRundown().length,
});
}
try {
let parsedOffset = Number(offset);
if (Number.isNaN(parsedOffset)) {
parsedOffset = 0;
}
let parsedLimit = Number(limit);
if (Number.isNaN(parsedLimit)) {
parsedLimit = Infinity;
}
const paginatedRundown = getPaginated(parsedOffset, parsedLimit);
res.status(200).json(paginatedRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).json({ message });
}
}
export async function rundownPost(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) { export async function rundownPost(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) { if (failEmptyObjects(req.body, res)) {
return; return;
@@ -5,8 +5,9 @@ import {
rundownApplyDelay, rundownApplyDelay,
rundownBatchPut, rundownBatchPut,
rundownDelete, rundownDelete,
rundownGetAll, rundownGetById,
rundownGetNormalised, rundownGetNormalised,
rundownGetPaginated,
rundownPost, rundownPost,
rundownPut, rundownPut,
rundownReorder, rundownReorder,
@@ -16,6 +17,7 @@ import {
paramsMustHaveEventId, paramsMustHaveEventId,
rundownArrayOfIds, rundownArrayOfIds,
rundownBatchPutValidator, rundownBatchPutValidator,
rundownGetPaginatedQueryParams,
rundownPostValidator, rundownPostValidator,
rundownPutValidator, rundownPutValidator,
rundownReorderValidator, rundownReorderValidator,
@@ -24,8 +26,9 @@ import {
export const router = express.Router(); export const router = express.Router();
router.get('/', rundownGetAll); // not used in Ontime frontend router.get('/', rundownGetPaginatedQueryParams, rundownGetPaginated); // not used in Ontime frontend
router.get('/normalised', rundownGetNormalised); router.get('/normalised', rundownGetNormalised);
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
router.post('/', rundownPostValidator, rundownPost); router.post('/', rundownPostValidator, rundownPost);
@@ -1,4 +1,4 @@
import { body, param, validationResult } from 'express-validator'; import { body, param, query, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
export const rundownPostValidator = [ export const rundownPostValidator = [
@@ -75,3 +75,14 @@ export const rundownArrayOfIds = [
next(); next();
}, },
]; ];
export const rundownGetPaginatedQueryParams = [
query('offset').isNumeric().optional(),
query('limit').isNumeric().optional(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -1,15 +1,16 @@
import { ErrorResponse, Settings } from 'ontime-types'; import { ErrorResponse, Settings } from 'ontime-types';
import { getErrorMessage, obfuscate } from 'ontime-utils'; import { getErrorMessage, obfuscate } from 'ontime-utils';
import { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js'; import { failEmptyObjects } from '../../utils/routerUtils.js';
import { extractPin } from '../../services/project-service/ProjectService.js';
import { isDocker } from '../../setup/index.js'; import { isDocker } from '../../setup/index.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { extractPin } from './settings.utils.js';
export async function getSettings(_req: Request, res: Response<Settings>) { export async function getSettings(_req: Request, res: Response<Settings>) {
const settings = DataProvider.getSettings(); const settings = getDataProvider().getSettings();
const obfuscatedSettings = { ...settings }; const obfuscatedSettings = { ...settings };
if (settings.editorKey) { if (settings.editorKey) {
obfuscatedSettings.editorKey = obfuscate(settings.editorKey); obfuscatedSettings.editorKey = obfuscate(settings.editorKey);
@@ -27,7 +28,7 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
return; return;
} }
try { try {
const settings = DataProvider.getSettings(); const settings = getDataProvider().getSettings();
const editorKey = extractPin(req.body?.editorKey, settings.editorKey); const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey); const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
const serverPort = Number(req.body?.serverPort); const serverPort = Number(req.body?.serverPort);
@@ -57,7 +58,7 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
language, language,
serverPort, serverPort,
}; };
await DataProvider.setSettings(newData); await getDataProvider().setSettings(newData);
res.status(200).send(newData); res.status(200).send(newData);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
@@ -0,0 +1,15 @@
/**
* Business logic for resolving a string
*/
export function extractPin(value: string | undefined | null, fallback: string | null): string | null {
if (value === null) {
return value;
}
if (typeof value === 'undefined') {
return fallback;
}
if (value.length === 0) {
return null;
}
return value;
}
@@ -1,13 +1,13 @@
import type { ErrorResponse, URLPreset } from 'ontime-types'; import type { ErrorResponse, URLPreset } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failIsNotArray } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failIsNotArray } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getUrlPresets(_req: Request, res: Response<URLPreset[]>) { export async function getUrlPresets(_req: Request, res: Response<URLPreset[]>) {
const presets = DataProvider.getUrlPresets(); const presets = getDataProvider().getUrlPresets();
res.status(200).send(presets as URLPreset[]); res.status(200).send(presets as URLPreset[]);
} }
@@ -21,7 +21,7 @@ export async function postUrlPresets(req: Request, res: Response<URLPreset[] | E
alias: preset.alias, alias: preset.alias,
pathAndParams: preset.pathAndParams, pathAndParams: preset.pathAndParams,
})); }));
await DataProvider.setUrlPresets(newPresets); await getDataProvider().setUrlPresets(newPresets);
res.status(200).send(newPresets); res.status(200).send(newPresets);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
@@ -1,31 +1,26 @@
import type { ErrorResponse, ViewSettings } from 'ontime-types'; import type { ErrorResponse, ViewSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getViewSettings(_req: Request, res: Response<ViewSettings>) { export async function getViewSettings(_req: Request, res: Response<ViewSettings>) {
const views = DataProvider.getViewSettings(); const views = getDataProvider().getViewSettings();
res.status(200).send(views); res.status(200).send(views);
} }
export async function postViewSettings(req: Request, res: Response<ViewSettings | ErrorResponse>) { export async function postViewSettings(req: Request, res: Response<ViewSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try { try {
const newData = { const newData = {
dangerColor: req.body.dangerColor, dangerColor: req.body.dangerColor,
endMessage: req.body?.endMessage ?? '', endMessage: req.body.endMessage,
freezeEnd: req.body.freezeEnd, freezeEnd: req.body.freezeEnd,
normalColor: req.body.normalColor, normalColor: req.body.normalColor,
overrideStyles: req.body.overrideStyles, overrideStyles: req.body.overrideStyles,
warningColor: req.body.warningColor, warningColor: req.body.warningColor,
}; } as ViewSettings;
await DataProvider.setViewSettings(newData); await getDataProvider().setViewSettings(newData);
res.status(200).send(newData); res.status(200).send(newData);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
@@ -5,11 +5,12 @@ import { Request, Response, NextFunction } from 'express';
* @description Validates object for POST /ontime/views * @description Validates object for POST /ontime/views
*/ */
export const validateViewSettings = [ export const validateViewSettings = [
check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'), check('dangerColor').exists().isString().trim().withMessage('dangerColor value must be string'),
check('endMessage').isString().trim().withMessage('endMessage value must be string'), check('endMessage').exists().isString().trim().withMessage('endMessage value must be string'),
check('normalColor').isString().trim().withMessage('normalColor value must be string'), check('freezeEnd').exists().isBoolean().withMessage('freezeEnd value must be boolean'),
check('warningColor').isString().trim().withMessage('warningColor value must be string'), check('normalColor').exists().isString().trim().withMessage('normalColor value must be string'),
check('dangerColor').isString().trim().withMessage('dangerColor value must be string'), check('overrideStyles').exists().isBoolean().withMessage('overrideStyles value must be boolean'),
check('warningColor').exists().isString().trim().withMessage('warningColor value must be string'),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); const errors = validationResult(req);
@@ -55,16 +55,17 @@ const actionHandlers: Record<string, ActionHandler> = {
throw new Error('Invalid property or value'); throw new Error('Invalid property or value');
} }
const newObjectProperty = parseProperty(property, value); // parseProperty is async because of the data lock
parseProperty(property, value).then((newObjectProperty) => {
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent;
shouldThrottle = willCauseRegeneration(key) || shouldThrottle;
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent; if (patchEvent.custom && newObjectProperty.custom) {
shouldThrottle = willCauseRegeneration(key) || shouldThrottle; Object.assign(patchEvent.custom, newObjectProperty.custom);
} else {
if (patchEvent.custom && newObjectProperty.custom) { Object.assign(patchEvent, newObjectProperty);
Object.assign(patchEvent.custom, newObjectProperty.custom); }
} else { });
Object.assign(patchEvent, newObjectProperty);
}
}); });
if (shouldThrottle) { if (shouldThrottle) {
@@ -1,17 +1,17 @@
import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'ontime-types'; import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils'; import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { editEvent } from '../services/rundown-service/RundownService.js'; import { editEvent } from '../services/rundown-service/RundownService.js';
import { getEventWithId } from '../services/rundown-service/rundownUtils.js'; import { getEventWithId } from '../services/rundown-service/rundownUtils.js';
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js'; import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
/** /**
* *
* @param {number} value time amount in seconds * @param {number} value time amount in seconds
* @returns {number} time in milliseconds clamped to 0 and max duration * @returns {number} time in milliseconds clamped to 0 and max duration
*/ */
function clampDuration(value: number) { function clampDuration(value: number): number {
const valueInMillis = value * MILLIS_PER_SECOND; const valueInMillis = value * MILLIS_PER_SECOND;
if (valueInMillis > maxDuration || valueInMillis < 0) { if (valueInMillis > maxDuration || valueInMillis < 0) {
throw new Error('Times should be from 0 to 23:59:59'); throw new Error('Times should be from 0 to 23:59:59');
@@ -42,10 +42,11 @@ const propertyConversion = {
timeEnd: (value: unknown) => clampDuration(coerceNumber(value)), timeEnd: (value: unknown) => clampDuration(coerceNumber(value)),
}; };
export function parseProperty(property: string, value: unknown) { export async function parseProperty(property: string, value: unknown) {
if (property.startsWith('custom:')) { if (property.startsWith('custom:')) {
const customKey = property.split(':')[1].toLocaleLowerCase(); // all custom fields keys are lowercase const customKey = property.split(':')[1].toLocaleLowerCase(); // all custom fields keys are lowercase
if (!(customKey in DataProvider.getCustomFields())) { const customFields = getDataProvider().getCustomFields();
if (!(customKey in customFields)) {
throw new Error(`Custom field ${customKey} not found`); throw new Error(`Custom field ${customKey} not found`);
} }
const parserFn = propertyConversion.custom; const parserFn = propertyConversion.custom;
+16 -14
View File
@@ -1,4 +1,4 @@
import { HttpSettings, LogOrigin, OSCSettings, Playback, SimpleDirection, SimplePlayback } from 'ontime-types'; import { LogOrigin, Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
import 'dotenv/config'; import 'dotenv/config';
import express from 'express'; import express from 'express';
@@ -13,11 +13,10 @@ import {
srcDirectory, srcDirectory,
environment, environment,
isProduction, isProduction,
resolveDbPath,
resolveExternalsDirectory, resolveExternalsDirectory,
resolveStylesDirectory, resolveStylesDirectory,
resolvedPath, resolvedPath,
clearUploadfolder, resolvePublicDirectoy,
} from './setup/index.js'; } from './setup/index.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight } from './utils/console.js'; import { consoleSuccess, consoleHighlight } from './utils/console.js';
@@ -28,8 +27,7 @@ import { integrationRouter } from './api-integration/integration.router.js';
// Import adapters // Import adapters
import { socket } from './adapters/WebsocketAdapter.js'; import { socket } from './adapters/WebsocketAdapter.js';
import { DataProvider } from './classes/data-provider/DataProvider.js'; import { getDataProvider } from './classes/data-provider/DataProvider.js';
import { dbLoadingProcess } from './setup/loadDb.js';
// Services // Services
import { integrationService } from './services/integration-service/IntegrationService.js'; import { integrationService } from './services/integration-service/IntegrationService.js';
@@ -44,6 +42,10 @@ import { messageService } from './services/message-service/MessageService.js';
import { populateDemo } from './setup/loadDemo.js'; import { populateDemo } from './setup/loadDemo.js';
import { getState } from './stores/runtimeState.js'; import { getState } from './stores/runtimeState.js';
import { initRundown } from './services/rundown-service/RundownService.js'; import { initRundown } from './services/rundown-service/RundownService.js';
import { initialiseProject } from './services/project-service/ProjectService.js';
// Utilities
import { clearUploadfolder } from './utils/upload.js';
import { generateCrashReport } from './utils/generateCrashReport.js'; import { generateCrashReport } from './utils/generateCrashReport.js';
import { getNetworkInterfaces } from './utils/networkInterfaces.js'; import { getNetworkInterfaces } from './utils/networkInterfaces.js';
@@ -53,8 +55,8 @@ consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
const canLog = isProduction; const canLog = isProduction;
if (!canLog) { if (!canLog) {
console.log(`Ontime running in ${environment} environment`); console.log(`Ontime running in ${environment} environment`);
console.log(`Ontime directory at ${srcDirectory} `); console.log(`Ontime source directory at ${srcDirectory} `);
console.log(`Ontime database at ${resolveDbPath}`); console.log(`Ontime public directory at ${resolvePublicDirectoy} `);
} }
// Create express APP // Create express APP
@@ -147,10 +149,11 @@ const checkStart = (currentState: OntimeStartOrder) => {
export const initAssets = async () => { export const initAssets = async () => {
checkStart(OntimeStartOrder.InitAssets); checkStart(OntimeStartOrder.InitAssets);
await dbLoadingProcess;
await clearUploadfolder(); await clearUploadfolder();
populateStyles(); populateStyles();
populateDemo(); populateDemo();
const project = await initialiseProject();
logger.info(LogOrigin.Server, `Initialised Ontime with ${project}`);
}; };
/** /**
@@ -160,8 +163,7 @@ export const startServer = async (
escalateErrorFn?: (error: string) => void, escalateErrorFn?: (error: string) => void,
): Promise<{ message: string; serverPort: number }> => { ): Promise<{ message: string; serverPort: number }> => {
checkStart(OntimeStartOrder.InitServer); checkStart(OntimeStartOrder.InitServer);
const { serverPort } = getDataProvider().getSettings();
const { serverPort } = DataProvider.getSettings();
expressServer = http.createServer(app); expressServer = http.createServer(app);
socket.init(expressServer); socket.init(expressServer);
@@ -192,8 +194,8 @@ export const startServer = async (
logger.init(escalateErrorFn); logger.init(escalateErrorFn);
// initialise rundown service // initialise rundown service
const persistedRundown = DataProvider.getRundown(); const persistedRundown = getDataProvider().getRundown();
const persistedCustomFields = DataProvider.getCustomFields(); const persistedCustomFields = getDataProvider().getCustomFields();
initRundown(persistedRundown, persistedCustomFields); initRundown(persistedRundown, persistedCustomFields);
// load restore point if it exists // load restore point if it exists
@@ -223,11 +225,11 @@ export const startServer = async (
/** /**
* starts integrations * starts integrations
*/ */
export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpSettings }) => { export const startIntegrations = async () => {
checkStart(OntimeStartOrder.InitIO); checkStart(OntimeStartOrder.InitIO);
// if a config is not provided, we use the persisted one // if a config is not provided, we use the persisted one
const { osc, http } = config ?? DataProvider.getData(); const { osc, http } = getDataProvider().getData();
if (osc) { if (osc) {
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...'); logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
+2 -2
View File
@@ -4,7 +4,7 @@ import { generateId, millisToString } from 'ontime-utils';
import { clock } from '../services/Clock.js'; import { clock } from '../services/Clock.js';
import { isProduction } from '../setup/index.js'; import { isProduction } from '../setup/index.js';
import { socket } from '../adapters/WebsocketAdapter.js'; import { socket } from '../adapters/WebsocketAdapter.js';
import { consoleSubdued, consoleRed } from '../utils/console.js'; import { consoleSubdued, consoleError } from '../utils/console.js';
class Logger { class Logger {
private queue: Log[]; private queue: Log[];
@@ -47,7 +47,7 @@ class Logger {
private _push(log: Log) { private _push(log: Log) {
if (this.canLog || log.level === LogLevel.Severe) { if (this.canLog || log.level === LogLevel.Severe) {
if (log.level === LogLevel.Severe) { if (log.level === LogLevel.Severe) {
consoleRed(`[${log.level}] \t ${log.origin} \t ${log.text}`); consoleError(`[${log.level}] \t ${log.origin} \t ${log.text}`);
} else { } else {
consoleSubdued(`[${log.level}] \t ${log.origin} \t ${log.text}`); consoleSubdued(`[${log.level}] \t ${log.origin} \t ${log.text}`);
} }
@@ -1,7 +1,3 @@
/**
* Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data
*/
import { import {
ProjectData, ProjectData,
OntimeRundown, OntimeRundown,
@@ -14,117 +10,165 @@ import {
URLPreset, URLPreset,
} from 'ontime-types'; } from 'ontime-types';
import { data, db } from '../../setup/loadDb.js'; import type { Low } from 'lowdb';
import { JSONFilePreset } from 'lowdb/node';
import { isProduction, isTest } from '../../setup/index.js';
import { isPath } from '../../utils/fileManagement.js';
import { consoleError } from '../../utils/console.js';
import { safeMerge } from './DataProvider.utils.js'; import { safeMerge } from './DataProvider.utils.js';
import { isTest } from '../../setup/index.js';
type ReadonlyPromise<T> = Promise<Readonly<T>>; type ReadonlyPromise<T> = Promise<Readonly<T>>;
export class DataProvider { let db = {} as Low<DatabaseModel>;
static getData() {
return data;
}
static async setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> { export async function initPersistence(filePath: string, fallbackData: DatabaseModel) {
data.project = { ...data.project, ...newData }; if (!isProduction) {
this.persist(); if (!isPath(filePath)) {
return data.project; consoleError(filePath);
} consoleError(new Error('initPersistence should be called with a path').stack);
process.exit(0);
static getProjectData(): Readonly<ProjectData> {
return data.project;
}
static async setCustomFields(newData: CustomFields): ReadonlyPromise<CustomFields> {
data.customFields = { ...newData };
this.persist();
return data.customFields;
}
static getCustomFields(): Readonly<CustomFields> {
return data.customFields;
}
static async setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
data.rundown = [...newData];
this.persist();
return data.rundown;
}
static getSettings(): Readonly<Settings> {
return data.settings;
}
static async setSettings(newData: Settings): ReadonlyPromise<Settings> {
data.settings = { ...newData };
this.persist();
return data.settings;
}
static getOsc(): Readonly<OSCSettings> {
return data.osc;
}
static getHttp(): Readonly<HttpSettings> {
return data.http;
}
static getUrlPresets(): Readonly<URLPreset[]> {
return data.urlPresets;
}
static async setUrlPresets(newData: URLPreset[]): ReadonlyPromise<URLPreset[]> {
data.urlPresets = newData;
this.persist();
return data.urlPresets;
}
static getViewSettings(): Readonly<ViewSettings> {
return data.viewSettings;
}
static async setViewSettings(newData: ViewSettings): ReadonlyPromise<ViewSettings> {
data.viewSettings = { ...newData };
this.persist();
return data.viewSettings;
}
static async setOsc(newData: OSCSettings): ReadonlyPromise<OSCSettings> {
data.osc = { ...newData };
this.persist();
return data.osc;
}
static async setHttp(newData: HttpSettings): ReadonlyPromise<HttpSettings> {
data.http = { ...newData };
this.persist();
return data.http;
}
static getRundown(): Readonly<OntimeRundown> {
return data.rundown;
}
private static async persist() {
if (isTest) {
return;
} }
await db.write();
} }
const newDb = await JSONFilePreset<DatabaseModel>(filePath, fallbackData);
static async mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<DatabaseModel> { // Read the database to initialize it
const mergedData = safeMerge(data, newData); newDb.data = fallbackData;
data.project = mergedData.project; await newDb.write();
data.settings = mergedData.settings; await newDb.read();
data.viewSettings = mergedData.viewSettings;
data.osc = mergedData.osc;
data.http = mergedData.http;
data.urlPresets = mergedData.urlPresets;
data.customFields = mergedData.customFields;
data.rundown = mergedData.rundown;
this.persist(); db = newDb;
}
return data;
} export function getDataProvider() {
if (db === null) throw new Error('Database not initialized');
return {
getData,
setProjectData,
getProjectData,
setCustomFields,
getCustomFields,
setRundown,
getSettings,
setSettings,
getOsc,
getHttp,
getUrlPresets,
setUrlPresets,
getViewSettings,
setViewSettings,
setOsc,
setHttp,
getRundown,
mergeIntoData,
};
}
function getData(): Readonly<DatabaseModel> {
return db.data;
}
async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
db.data.project = { ...db.data.project, ...newData };
await persist();
return db.data.project;
}
function getProjectData(): Readonly<ProjectData> {
return db.data.project;
}
async function setCustomFields(newData: CustomFields): ReadonlyPromise<CustomFields> {
db.data.customFields = { ...newData };
await persist();
return db.data.customFields;
}
function getCustomFields(): Readonly<CustomFields> {
return db.data.customFields;
}
async function setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
db.data.rundown = [...newData];
await persist();
return db.data.rundown;
}
function getSettings(): Readonly<Settings> {
return db.data.settings;
}
async function setSettings(newData: Settings): ReadonlyPromise<Settings> {
db.data.settings = { ...newData };
await persist();
return db.data.settings;
}
function getOsc(): Readonly<OSCSettings> {
return db.data.osc;
}
function getHttp(): Readonly<HttpSettings> {
return db.data.http;
}
function getUrlPresets(): Readonly<URLPreset[]> {
return db.data.urlPresets;
}
async function setUrlPresets(newData: URLPreset[]): ReadonlyPromise<URLPreset[]> {
db.data.urlPresets = newData;
await persist();
return db.data.urlPresets;
}
function getViewSettings(): Readonly<ViewSettings> {
return db.data.viewSettings;
}
async function setViewSettings(newData: ViewSettings): ReadonlyPromise<ViewSettings> {
db.data.viewSettings = { ...newData };
await persist();
return db.data.viewSettings;
}
async function setOsc(newData: OSCSettings): ReadonlyPromise<OSCSettings> {
db.data.osc = { ...newData };
await persist();
return db.data.osc;
}
async function setHttp(newData: HttpSettings): ReadonlyPromise<HttpSettings> {
db.data.http = { ...newData };
await persist();
return db.data.http;
}
function getRundown(): Readonly<OntimeRundown> {
return db.data.rundown;
}
async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<DatabaseModel> {
const mergedData = safeMerge(db.data, newData);
db.data.project = mergedData.project;
db.data.settings = mergedData.settings;
db.data.viewSettings = mergedData.viewSettings;
db.data.osc = mergedData.osc;
db.data.http = mergedData.http;
db.data.urlPresets = mergedData.urlPresets;
db.data.customFields = mergedData.customFields;
db.data.rundown = mergedData.rundown;
await persist();
return db.data;
}
/**
* Handles persisting data to file
*/
async function persist() {
if (isTest) return;
await db.write();
} }
@@ -1,11 +1,9 @@
import { DatabaseModel } from 'ontime-types'; import { DatabaseModel } from 'ontime-types';
/** /**
* Merges two data objects * Merges a partial ontime project into a given ontime project
* @param {object} existing
* @param {object} newData
*/ */
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) { export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = newData || {}; const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = newData || {};
return { return {
+2 -2
View File
@@ -1,5 +1,5 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import { consoleHighlight, consoleRed } from './utils/console.js'; import { consoleHighlight, consoleError } from './utils/console.js';
import { initAssets, startIntegrations, startServer } from './app.js'; import { initAssets, startIntegrations, startServer } from './app.js';
async function startOntime() { async function startOntime() {
@@ -16,7 +16,7 @@ async function startOntime() {
consoleHighlight('Request: Start integrations...'); consoleHighlight('Request: Start integrations...');
await startIntegrations(); await startIntegrations();
} catch (error) { } catch (error) {
consoleRed(`Request failed: ${error}`); consoleError(`Request failed: ${error}`);
} }
} }
+409
View File
@@ -0,0 +1,409 @@
import { DatabaseModel, EndAction, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
export const demoDb: DatabaseModel = {
rundown: [
{
type: SupportedEvent.Event,
id: '32d31',
cue: 'SF1.01',
title: 'Albania',
note: 'SF1.01',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 36000000,
timeEnd: 37200000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Sekret',
artist: 'Ronela Hajati',
},
},
{
type: SupportedEvent.Event,
id: '21cd2',
cue: 'SF1.02',
title: 'Latvia',
note: 'SF1.02',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 37500000,
timeEnd: 38700000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Eat Your Salad',
artist: 'Citi Zeni',
},
},
{
type: SupportedEvent.Event,
id: '0b371',
cue: 'SF1.03',
title: 'Lithuania',
note: 'SF1.03',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 39000000,
timeEnd: 40200000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Sentimentai',
artist: 'Monika Liu',
},
},
{
type: SupportedEvent.Event,
id: '3cd28',
cue: 'SF1.04',
title: 'Switzerland',
note: 'SF1.04',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 40500000,
timeEnd: 41700000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Boys Do Cry',
artist: 'Marius Bear',
},
},
{
type: SupportedEvent.Event,
id: 'e457f',
cue: 'SF1.05',
title: 'Slovenia',
note: 'SF1.05',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 42000000,
timeEnd: 43200000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Disko',
artist: 'LPS',
},
},
{
type: SupportedEvent.Block,
id: '01e85',
title: 'Lunch break',
},
{
type: SupportedEvent.Event,
id: '1c420',
cue: 'SF1.06',
title: 'Ukraine',
note: 'SF1.06',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 47100000,
timeEnd: 48300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Stefania',
artist: 'Kalush Orchestra',
},
},
{
type: SupportedEvent.Event,
id: 'b7737',
cue: 'SF1.07',
title: 'Bulgaria',
note: 'SF1.07',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 48600000,
timeEnd: 49800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Intention',
artist: 'Intelligent Music Project',
},
},
{
type: SupportedEvent.Event,
id: 'd3a80',
cue: 'SF1.08',
title: 'Netherlands',
note: 'SF1.08',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 50100000,
timeEnd: 51300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'De Diepte',
artist: 'S10',
},
},
{
type: SupportedEvent.Event,
id: '8276c',
cue: 'SF1.09',
title: 'Moldova',
note: 'SF1.09',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 51600000,
timeEnd: 52800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Trenuletul',
artist: 'Zdob si Zdub',
},
},
{
type: SupportedEvent.Event,
id: '2340b',
cue: 'SF1.10',
title: 'Portugal',
note: 'SF1.10',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 53100000,
timeEnd: 54300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Saudade Saudade',
artist: 'Maro',
},
},
{
type: SupportedEvent.Block,
id: 'cb90b',
title: 'Afternoon break',
},
{
type: SupportedEvent.Event,
id: '503c4',
cue: 'SF1.11',
title: 'Croatia',
note: 'SF1.11',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 56100000,
timeEnd: 57300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Guilty Pleasure',
artist: 'Mia Dimsic',
},
},
{
type: SupportedEvent.Event,
id: '5e965',
cue: 'SF1.12',
title: 'Denmark',
note: 'SF1.12',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 57600000,
timeEnd: 58800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'The Show',
artist: 'Reddi',
},
},
{
type: SupportedEvent.Event,
id: 'bab4a',
cue: 'SF1.13',
title: 'Austria',
note: 'SF1.13',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 59100000,
timeEnd: 60300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Halo',
artist: 'LUM!X & Pia Maria',
},
},
{
type: SupportedEvent.Event,
id: 'd3eb1',
cue: 'SF1.14',
title: 'Greece',
note: 'SF1.14',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 60600000,
timeEnd: 61800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Die Together',
artist: 'Amanda Tenfjord',
},
},
],
project: {
title: 'Eurovision Song Contest',
description: 'Turin 2022',
publicUrl: 'www.getontime.no',
publicInfo: 'Rehearsal Schedule - Turin 2022',
backstageUrl: 'www.github.com/cpvalente/ontime',
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
},
settings: {
app: 'ontime',
version: '3.3.2',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
},
viewSettings: {
dangerColor: '#ED3333',
endMessage: '',
freezeEnd: false,
normalColor: '#ffffffcc',
overrideStyles: false,
warningColor: '#FFAB33',
},
customFields: {
song: {
label: 'Song',
type: 'string',
colour: '#339E4E',
},
artist: {
label: 'Artist',
type: 'string',
colour: '#3E75E8',
},
},
urlPresets: [
{
enabled: true,
alias: 'test',
pathAndParams: 'lower?bg=ff2&text=f00&size=0.6&transition=5',
},
],
osc: {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: true,
enabledOut: false,
subscriptions: [],
},
http: {
enabledOut: false,
subscriptions: [],
},
};
+2 -2
View File
@@ -8,12 +8,12 @@ import { timerConfig } from '../config/config.js';
* Service manages Ontime's main timer * Service manages Ontime's main timer
*/ */
export class TimerService { export class TimerService {
private readonly _interval: NodeJS.Timer; private readonly _interval: NodeJS.Timeout;
/** how often we recalculate */ /** how often we recalculate */
static _refreshInterval: number; static _refreshInterval: number;
/** when timer will be finished */ /** when timer will be finished */
private endCallback: NodeJS.Timer; private endCallback: NodeJS.Timeout;
private onUpdateCallback: (updateResult: UpdateResult) => void; private onUpdateCallback: (updateResult: UpdateResult) => void;
@@ -1558,7 +1558,7 @@ describe('getRuntimeOffset()', () => {
} as RuntimeState; } as RuntimeState;
const offset = getRuntimeOffset(state); const offset = getRuntimeOffset(state);
expect(offset).toBe(null); expect(offset).toBe(0);
}); });
it('handles loaded event', () => { it('handles loaded event', () => {
@@ -1,44 +1,40 @@
import { Low } from 'lowdb'; import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node'; import { JSONFile } from 'lowdb/node';
import { appStatePath, isTest } from '../../setup/index.js'; import { appStatePath, isProduction, isTest } from '../../setup/index.js';
import { isPath } from '../../utils/fileManagement.js';
import { consoleError } from '../../utils/console.js';
interface Config { interface AppState {
lastLoadedProject: string; lastLoadedProject?: string;
} }
/** const adapter = new JSONFile<AppState>(appStatePath);
* Service manages Ontime's runtime memory between boots const config = new Low<AppState>(adapter, {});
*/
class AppStateService { export async function isLastLoadedProject(projectName: string): Promise<boolean> {
private config: Low<Config>; const lastLoaded = await getLastLoadedProject();
private pathToFile: string; return lastLoaded === projectName;
constructor(appStatePath: string) {
this.pathToFile = appStatePath;
const adapter = new JSONFile<Config>(this.pathToFile);
this.config = new Low<Config>(adapter, null);
this.init();
}
private async init() {
await this.config.read();
await this.config.write();
}
async get(): Promise<Config> {
await this.config.read();
return this.config.data;
}
async updateDatabaseConfig(filename: string): Promise<void> {
if (isTest) return;
this.config.data.lastLoadedProject = filename;
await this.config.write();
}
} }
export const appStateService = new AppStateService(appStatePath); export async function getLastLoadedProject(): Promise<string | undefined> {
// in test environment, we want to start the demo project
if (isTest) return;
await config.read();
return config.data.lastLoadedProject;
}
export async function setLastLoadedProject(filename: string): Promise<void> {
if (isTest) return;
if (!isProduction) {
if (isPath(filename)) {
consoleError(filename);
consoleError(new Error('setLastLoadedProject should not be called with a path').stack);
process.exit(0);
}
}
config.data.lastLoadedProject = filename;
await config.write();
}
@@ -8,7 +8,7 @@ export type GetTimeFn = () => number;
export class AuxTimerService { export class AuxTimerService {
private timer: SimpleTimer; private timer: SimpleTimer;
private interval: NodeJS.Timer | null = null; private interval: NodeJS.Timeout | null = null;
private emit: EmitFn; private emit: EmitFn;
private getTime: GetTimeFn; private getTime: GetTimeFn;
@@ -1,20 +1,44 @@
import { DatabaseModel, GetInfo, ProjectData, ProjectFile, ProjectFileListResponse } from 'ontime-types'; import { DatabaseModel, GetInfo, LogOrigin, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { copyFile, rename, stat, writeFile } from 'fs/promises'; import { copyFile, rename } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { initRundown } from '../rundown-service/RundownService.js'; import { logger } from '../../classes/Logger.js';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import { getNetworkInterfaces } from '../../utils/networkInterfaces.js'; import { getNetworkInterfaces } from '../../utils/networkInterfaces.js';
import { resolveProjectsDirectory, resolveStylesPath } from '../../setup/index.js'; import { resolveCorruptDirectory, resolveProjectsDirectory, resolveStylesPath } from '../../setup/index.js';
import { filterProjectFiles, parseProjectFile } from './projectFileUtils.js'; import {
import { appStateService } from '../app-state-service/AppStateService.js'; appendToName,
import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js'; ensureDirectory,
generateUniqueFileName,
getFileNameFromPath,
removeFileExtension,
} from '../../utils/fileManagement.js';
import { dbModel } from '../../models/dataModel.js'; import { dbModel } from '../../models/dataModel.js';
import { deleteFile } from '../../utils/parserUtils.js'; import { deleteFile } from '../../utils/parserUtils.js';
import { switchDb } from '../../setup/loadDb.js'; import { parseDatabaseModel } from '../../utils/parser.js';
import { parseRundown } from '../../utils/parserFunctions.js';
import { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { initRundown } from '../rundown-service/RundownService.js';
import {
getLastLoadedProject,
isLastLoadedProject,
setLastLoadedProject,
} from '../app-state-service/AppStateService.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import { oscIntegration } from '../integration-service/OscIntegration.js';
import { httpIntegration } from '../integration-service/HttpIntegration.js';
import {
copyCorruptFile,
doesProjectExist,
getPathToProject,
getProjectFiles,
moveCorruptFile,
parseJsonFile,
} from './projectServiceUtils.js';
// init dependencies // init dependencies
init(); init();
@@ -24,69 +48,135 @@ init();
*/ */
function init() { function init() {
ensureDirectory(resolveProjectsDirectory); ensureDirectory(resolveProjectsDirectory);
} ensureDirectory(resolveCorruptDirectory);
type Options = {
onlyRundown?: 'true' | 'false';
};
/**
* Handles a file from the upload folder and applies its data
*/
export async function applyProjectFile(name: string, options?: Options) {
const filePath = join(resolveProjectsDirectory, name);
const data = parseProjectFile(filePath);
// change LowDB to point to new file
await switchDb(name);
// apply data model
await applyDataModel(data, options);
// persist the project selection
await appStateService.updateDatabaseConfig(name);
} }
/** /**
* Copies a file from upload folder to the projects folder * Private function loads a demo project
* @param filePath * to be composed in the loading functions
* @param name
* @returns
*/ */
export async function handleUploadedFile(filePath: string, name: string) { async function loadDemoProject(): Promise<string> {
const newFilePath = join(resolveProjectsDirectory, name); const pathToNewFile = generateUniqueFileName(resolveProjectsDirectory, config.demoProject);
await rename(filePath, newFilePath); await initPersistence(getPathToProject(pathToNewFile), demoDb);
await deleteFile(filePath); const newName = getFileNameFromPath(pathToNewFile);
return name; await setLastLoadedProject(newName);
return newName;
} }
/** /**
* Asynchronously retrieves and returns an array of project files from the 'uploads' folder. * Private function loads a new, empty project
* Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed. * to be composed in the loading functions
* For each qualifying file, its metadata is retrieved, including filename, creation time, and last modification time.
*
* @returns {Promise<Array<ProjectFile>>} A promise that resolves to an array of ProjectFile objects,
* each representing a file in the 'uploads' folder with its metadata.
* The metadata includes the filename, creation or overwriting time (updatedAt)
*
* @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics.
*/ */
export async function getProjectFiles(): Promise<ProjectFile[]> { async function loadNewProject(): Promise<string> {
const allFiles = await getFilesFromFolder(resolveProjectsDirectory); const pathToNewFile = generateUniqueFileName(resolveProjectsDirectory, config.newProject);
const filteredFiles = filterProjectFiles(allFiles); await initPersistence(getPathToProject(pathToNewFile), dbModel);
const newName = getFileNameFromPath(pathToNewFile);
await setLastLoadedProject(newName);
return newName;
}
const projectFiles: ProjectFile[] = []; /**
for (const file of filteredFiles) { * Private function handles side effects on currupted files
const filePath = join(resolveProjectsDirectory, file); * Corrupted files in this context contain data that failed domain validation
const stats = await stat(filePath); */
async function handleCorruptedFile(filePath: string, fileName: string): Promise<string> {
// copy file to corrupted folder
await copyCorruptFile(filePath, fileName).catch((_) => {
/* while we have to catch the error, we dont need to handle it */
});
projectFiles.push({ // and make a new file with the recovered data
filename: removeFileExtension(file), const newPath = appendToName(filePath, '(recovered)');
updatedAt: stats.mtime.toISOString(), await rename(filePath, newPath);
}); return getFileNameFromPath(newPath);
}
/**
* Coordinates the initial load of a project on app startup
* This is different from the load project since we need to always load something
* @returns {Promise<string>} - name of the loaded file
*/
export async function initialiseProject(): Promise<string> {
// check what was loaded before
const previousProject = await getLastLoadedProject();
if (!previousProject) {
return loadDemoProject();
} }
return projectFiles; // try and load the previous project
const filePath = doesProjectExist(previousProject);
if (filePath === null) {
logger.warning(LogOrigin.Server, `Previous project file ${previousProject} not found`);
return loadNewProject();
}
try {
const fileData = await parseJsonFile(filePath);
const result = parseDatabaseModel(fileData);
let parsedFileName = previousProject;
let parsedFilePath = filePath;
if (result.errors.length > 0) {
logger.warning(LogOrigin.Server, 'Project loaded with errors');
parsedFileName = await handleCorruptedFile(filePath, previousProject);
parsedFilePath = getPathToProject(parsedFileName);
}
await initPersistence(parsedFilePath, result.data);
await setLastLoadedProject(parsedFileName);
return parsedFileName;
} catch (error) {
logger.warning(LogOrigin.Server, `Unable to load previous project ${previousProject}: ${getErrorMessage(error)}`);
await moveCorruptFile(filePath, previousProject).catch((_) => {
/* while we have to catch the error, we dont need to handle it */
});
return loadNewProject();
}
}
/**
* Loads a data from a file into the runtime
*/
export async function loadProjectFile(name: string) {
const filePath = doesProjectExist(name);
if (filePath === null) {
throw new Error('Project file not found');
}
// when loading a project file, we allow parsing to fail and interrupt the process
const fileData = await parseJsonFile(filePath);
const result = parseDatabaseModel(fileData);
let parsedFileName = name;
let parsedFilePath = filePath;
if (result.errors.length > 0) {
logger.warning(LogOrigin.Server, 'Project loaded with errors');
parsedFileName = await handleCorruptedFile(filePath, name);
parsedFilePath = getPathToProject(parsedFileName);
}
// change LowDB to point to new file
await initPersistence(parsedFilePath, result.data);
logger.info(LogOrigin.Server, `Loaded project ${parsedFileName}`);
// persist the project selection
await setLastLoadedProject(parsedFileName);
// since load happens at runtime, we need to update the services that depend on the data
// apply data model
runtimeService.stop();
const { rundown, customFields, osc, http } = result.data;
// apply the rundown
initRundown(rundown, customFields);
// apply integrations
oscIntegration.init(osc);
httpIntegration.init(http);
} }
/** /**
@@ -94,47 +184,81 @@ export async function getProjectFiles(): Promise<ProjectFile[]> {
*/ */
export async function getProjectList(): Promise<ProjectFileListResponse> { export async function getProjectList(): Promise<ProjectFileListResponse> {
const files = await getProjectFiles(); const files = await getProjectFiles();
const appState = await appStateService.get(); const lastLoadedProject = await getLastLoadedProject();
const lastLoadedProject = removeFileExtension(appState.lastLoadedProject);
return { return {
files, files,
lastLoadedProject, lastLoadedProject: lastLoadedProject ? removeFileExtension(lastLoadedProject) : '',
}; };
} }
/** /**
* Duplicates an existing project file * Duplicates an existing project file
*/ */
export async function duplicateProjectFile(existingProjectFile: string, newProjectFile: string) { export async function duplicateProjectFile(originalFile: string, newFilename: string) {
const projectFilePath = join(resolveProjectsDirectory, existingProjectFile); const projectFilePath = doesProjectExist(originalFile);
const duplicateProjectFilePath = join(resolveProjectsDirectory, newProjectFile); if (projectFilePath === null) {
throw new Error('Project file not found');
}
return copyFile(projectFilePath, duplicateProjectFilePath); const duplicateProjectFilePath = doesProjectExist(newFilename);
if (duplicateProjectFilePath !== null) {
throw new Error(`Project file with name ${newFilename} already exists`);
}
const pathToDuplicate = getPathToProject(newFilename);
return copyFile(projectFilePath, pathToDuplicate);
} }
/** /**
* Renames an existing project file * Renames an existing project file
*/ */
export async function renameProjectFile(existingProjectFile: string, newName: string) { export async function renameProjectFile(originalFile: string, newFilename: string) {
const projectFilePath = join(resolveProjectsDirectory, existingProjectFile); const projectFilePath = doesProjectExist(originalFile);
const newProjectFilePath = join(resolveProjectsDirectory, newName); if (projectFilePath === null) {
throw new Error('Project file not found');
}
await rename(projectFilePath, newProjectFilePath); const newProjectFilePath = doesProjectExist(newFilename);
if (newProjectFilePath !== null) {
throw new Error(`Project file with name ${newFilename} already exists`);
}
const pathToRenamed = getPathToProject(newFilename);
await rename(projectFilePath, pathToRenamed);
// Update the last loaded project config if current loaded project is the one being renamed // Update the last loaded project config if current loaded project is the one being renamed
const { lastLoadedProject } = await appStateService.get(); const isLoaded = await isLastLoadedProject(originalFile);
if (isLoaded) {
const fileData = await parseJsonFile(pathToRenamed);
const result = parseDatabaseModel(fileData);
if (lastLoadedProject === existingProjectFile) { // change LowDB to point to new file
await appStateService.updateDatabaseConfig(newName); await initPersistence(pathToRenamed, result.data);
logger.info(LogOrigin.Server, `Loaded project ${newFilename}`);
// persist the project selection
await setLastLoadedProject(newFilename);
// apply data model
runtimeService.stop();
const { rundown, customFields, osc, http } = result.data;
// apply the rundown
initRundown(rundown, customFields);
// apply integrations
oscIntegration.init(osc);
httpIntegration.init(http);
} }
} }
/** /**
* Creates a new project file and applies its result * Creates a new project file and applies its result
*/ */
export async function createProjectFile(filename: string, projectData: ProjectData) { export async function createProject(filename: string, projectData: ProjectData) {
const data = { const data: DatabaseModel = {
...dbModel, ...dbModel,
project: { project: {
...dbModel.project, ...dbModel.project,
@@ -142,24 +266,36 @@ export async function createProjectFile(filename: string, projectData: ProjectDa
}, },
}; };
// create new file const uniqueFileName = generateUniqueFileName(resolveProjectsDirectory, filename);
const newFile = join(resolveProjectsDirectory, filename); const newFile = getPathToProject(uniqueFileName);
await writeFile(newFile, JSON.stringify(data));
// change LowDB to point to new file // change LowDB to point to new file
await switchDb(filename); await initPersistence(newFile, data);
// apply its data // apply data to running services
await applyDataModel(data); // we dont need to parse since we are creating a new file
await patchCurrentProject(data);
appStateService.updateDatabaseConfig(filename); // update app state to point to new value
setLastLoadedProject(uniqueFileName);
return uniqueFileName;
} }
/** /**
* Deletes a project file * Deletes a project file
*/ */
export async function deleteProjectFile(filename: string) { export async function deleteProjectFile(filename: string) {
const projectFilePath = join(resolveProjectsDirectory, filename); const isPreviousProject = await isLastLoadedProject(filename);
if (isPreviousProject) {
throw new Error('Cannot delete currently loaded project');
}
const projectFilePath = doesProjectExist(filename);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
await deleteFile(projectFilePath); await deleteFile(projectFilePath);
} }
@@ -167,8 +303,8 @@ export async function deleteProjectFile(filename: string) {
* Adds business logic to gathering data for the info endpoint * Adds business logic to gathering data for the info endpoint
*/ */
export async function getInfo(): Promise<GetInfo> { export async function getInfo(): Promise<GetInfo> {
const { version, serverPort } = DataProvider.getSettings(); const { version, serverPort } = getDataProvider().getSettings();
const osc = DataProvider.getOsc(); const osc = getDataProvider().getOsc();
// get nif and inject localhost // get nif and inject localhost
const ni = getNetworkInterfaces(); const ni = getNetworkInterfaces();
@@ -184,83 +320,22 @@ export async function getInfo(): Promise<GetInfo> {
}; };
} }
/**
* Business logic for resolving a string
*/
export function extractPin(value: string | undefined | null, fallback: string | null): string | null {
if (value === null) {
return value;
}
if (typeof value === 'undefined') {
return fallback;
}
if (value.length === 0) {
return null;
}
return value;
}
/** /**
* applies a partial database model * applies a partial database model
*/ */
export async function applyDataModel(data: Partial<DatabaseModel>, _options?: Options) { export async function patchCurrentProject(data: Partial<DatabaseModel>) {
runtimeService.stop(); runtimeService.stop();
// TODO: allow partial project merge from options // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need to remove the fields before merging
const { rundown, customFields, ...rest } = data; const { rundown, customFields, ...rest } = data;
const newData = await DataProvider.mergeIntoData(rest); // we can pass some stuff straight to the data provider
const newData = await getDataProvider().mergeIntoData(rest);
// ... but rundown and custom fields need to be checked
if (rundown != null) { if (rundown != null) {
initRundown(rundown, customFields ?? {}); const result = parseRundown(data);
initRundown(result.rundown, result.customFields);
} }
return newData; return newData;
} }
/**
* Checks whether a project of a given name exists
* @param name
*/
export function doesProjectExist(name: string): boolean {
const projectFilePath = join(resolveProjectsDirectory, name);
return existsSync(projectFilePath);
}
/**
* @description Validates the existence of project files.
* @param {object} projectFiles
* @param {string} projectFiles.projectFilename
* @param {string} projectFiles.newFilename
*
* @returns {Promise<Array<string>>} Array of errors
*
*/
export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array<string> => {
const errors: string[] = [];
if (projectFiles.filename) {
const projectFilePath = join(resolveProjectsDirectory, projectFiles.filename);
if (!existsSync(projectFilePath)) {
errors.push('Project file does not exist');
}
}
if (projectFiles.newFilename) {
const projectFilePath = join(resolveProjectsDirectory, projectFiles.newFilename);
if (existsSync(projectFilePath)) {
errors.push('New project file already exists');
}
}
return errors;
};
/**
* Get current project title or fallback
*/
export function getProjectTitle(): string {
const { title } = DataProvider.getProjectData();
return title || 'ontime data';
}
@@ -1,33 +1,73 @@
import { expect, vi } from 'vitest'; import { Mock } from 'vitest';
import { getProjectFiles } from '../ProjectService.js'; import { isLastLoadedProject } from '../../app-state-service/AppStateService.js';
vi.mock('fs/promises', () => { import { deleteProjectFile, duplicateProjectFile, renameProjectFile } from '../ProjectService.js';
const mockFiles = ['file1.json', 'file2.json', 'file3.json', 'document.txt', 'image.png']; import { doesProjectExist } from '../projectServiceUtils.js';
const mockStats = {
birthtime: new Date('2020-01-01'),
mtime: new Date('2021-01-01'),
};
// stop the database loading from initiating
vi.mock('../../../setup/loadDb.js', () => {
return { return {
readdir: vi.fn().mockResolvedValue(mockFiles), switchDb: vi.fn(),
stat: vi.fn().mockResolvedValue(mockStats),
}; };
}); });
describe('getProjectFiles test', () => { vi.mock('../../app-state-service/AppStateService.js', () => ({
it('should return a list of project .json files', async () => { isLastLoadedProject: vi.fn(),
const { readdir, stat } = await import('fs/promises'); }));
const result = await getProjectFiles(); vi.mock('../projectServiceUtils.js', () => ({
doesProjectExist: vi.fn(),
getPathToProject: vi.fn(),
}));
const expectedFiles = ['file1', 'file2', 'file3'].map((file) => ({ /**
filename: file, * tests only assert errors since the
updatedAt: new Date('2021-01-01').toISOString(), * controller depend on these to send the right responses
})); */
describe('deleteProjectFile', () => {
it('throws an error if trying to delete the currently loaded project', async () => {
(isLastLoadedProject as Mock).mockResolvedValue(true);
await expect(deleteProjectFile('loadedProject')).rejects.toThrow('Cannot delete currently loaded project');
});
expect(result).toEqual(expectedFiles); it('throws an error if the project file does not exist', async () => {
expect(readdir).toHaveBeenCalled(); (isLastLoadedProject as Mock).mockResolvedValue(false);
expect(stat).toHaveBeenCalledTimes(expectedFiles.length); (doesProjectExist as Mock).mockReturnValue(null);
await expect(deleteProjectFile('nonexistentProject')).rejects.toThrow('Project file not found');
});
});
describe('duplicateProjectFile', () => {
it('throws an error if origin project does not exist', async () => {
(doesProjectExist as Mock).mockReturnValue(null);
await expect(duplicateProjectFile('does not exist', 'doesnt matter')).rejects.toThrow('Project file not found');
});
it('throws an error if new file name is already a project', () => {
// current project exists
(doesProjectExist as Mock).mockReturnValueOnce('thisoneexists');
// new project exists
(doesProjectExist as Mock).mockReturnValueOnce('existingproject');
expect(duplicateProjectFile('thisoneexists', 'existingproject')).rejects.toThrow(
'Project file with name existingproject already exists',
);
});
});
describe('renameProjectFile', () => {
it('throws an error if origin project does not exist', async () => {
(doesProjectExist as Mock).mockReturnValue(null);
await expect(renameProjectFile('does not exist', 'doesnt matter')).rejects.toThrow('Project file not found');
});
it('throws an error if new file name is already a project', async () => {
// current project exists
(doesProjectExist as Mock).mockReturnValueOnce('this one exists');
// new project exists
(doesProjectExist as Mock).mockReturnValueOnce('existingproject');
expect(renameProjectFile('this one exists', 'existingproject')).rejects.toThrow(
'Project file with name existingproject already exists',
);
}); });
}); });
@@ -0,0 +1,33 @@
import { expect, vi } from 'vitest';
import { getProjectFiles } from '../projectServiceUtils.js';
vi.mock('fs/promises', () => {
const mockFiles = ['file1.json', 'file2.json', 'file3.json', 'document.txt', 'image.png'];
const mockStats = {
birthtime: new Date('2020-01-01'),
mtime: new Date('2021-01-01'),
};
return {
readdir: vi.fn().mockResolvedValue(mockFiles),
stat: vi.fn().mockResolvedValue(mockStats),
};
});
describe('getProjectFiles test', () => {
it('should return a list of project .json files', async () => {
const { readdir, stat } = await import('fs/promises');
const result = await getProjectFiles();
const expectedFiles = ['file1', 'file2', 'file3'].map((file) => ({
filename: file,
updatedAt: new Date('2021-01-01').toISOString(),
}));
expect(result).toEqual(expectedFiles);
expect(readdir).toHaveBeenCalled();
expect(stat).toHaveBeenCalledTimes(expectedFiles.length);
});
});
@@ -1,31 +0,0 @@
import { readFileSync } from 'fs';
import { extname } from 'path';
/**
* Given an array of file names, filters out any files that do not have a '.json' extension.
* We assume these are project files
* @param files
* @returns
*/
export function filterProjectFiles(files: Array<string>): Array<string> {
return files.filter((file) => {
const ext = extname(file).toLowerCase();
return ext === '.json';
});
}
export function parseProjectFile(filePath: string): object {
if (!filePath.endsWith('.json')) {
throw new Error('Invalid file type');
}
const rawdata = readFileSync(filePath, 'utf-8');
const uploadedJson = JSON.parse(rawdata);
// at this point, we think this is a DatabaseModel
// verify by looking for the required fields
if (uploadedJson?.settings?.app !== 'ontime') {
throw new Error('Not a ontime project file');
}
return uploadedJson;
}
@@ -0,0 +1,106 @@
import { DatabaseModel, MaybeString, ProjectFile } from 'ontime-types';
import { existsSync } from 'fs';
import { copyFile, readFile, rename, stat } from 'fs/promises';
import { extname, join } from 'path';
import { resolveCorruptDirectory, resolveProjectsDirectory } from '../../setup/index.js';
import { getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
/**
* Handles the upload of a new project file
* @param filePath
* @param name
*/
export async function handleUploaded(filePath: string, name: string) {
const newFilePath = join(resolveProjectsDirectory, name);
await rename(filePath, newFilePath);
}
/**
* Asynchronously retrieves and returns an array of project files from the 'uploads' folder.
* Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed.
* For each qualifying file, its metadata is retrieved, including filename, creation time, and last modification time.
*
* @returns {Promise<Array<ProjectFile>>} A promise that resolves to an array of ProjectFile objects,
* each representing a file in the 'uploads' folder with its metadata.
* The metadata includes the filename, creation or overwriting time (updatedAt)
*
* @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics.
*/
export async function getProjectFiles(): Promise<ProjectFile[]> {
const allFiles = await getFilesFromFolder(resolveProjectsDirectory);
const filteredFiles = filterProjectFiles(allFiles);
const projectFiles: ProjectFile[] = [];
for (const file of filteredFiles) {
const filePath = join(resolveProjectsDirectory, file);
const stats = await stat(filePath);
projectFiles.push({
filename: removeFileExtension(file),
updatedAt: stats.mtime.toISOString(),
});
}
return projectFiles;
}
/**
* Checks whether a project of a given name exists
* @param name
*/
export function doesProjectExist(name: string): MaybeString {
const projectFilePath = getPathToProject(name);
if (existsSync(projectFilePath)) {
return projectFilePath;
}
return null;
}
/**
* Returns the absolute path to a project file
*/
export function getPathToProject(name: string): string {
return join(resolveProjectsDirectory, name);
}
/**
* Makes a copy of a given project to the corrupted directory
*/
export async function copyCorruptFile(filePath: string, name: string): Promise<void> {
const newPath = join(resolveCorruptDirectory, name);
return copyFile(filePath, newPath);
}
/**
* Moves a file permanently to the corrupted directory
*/
export async function moveCorruptFile(filePath: string, name: string): Promise<void> {
const newPath = join(resolveCorruptDirectory, name);
return rename(filePath, newPath);
}
/**
* Given an array of file names, filters out any files that do not have a '.json' extension.
* We assume these are project files
*/
export function filterProjectFiles(files: Array<string>): Array<string> {
return files.filter((file) => {
const ext = extname(file).toLowerCase();
return ext === '.json';
});
}
/**
* Parses a project file and returns the JSON object
* @throws It will throw an error if it cannot read or parse the file
*/
export async function parseJsonFile(filePath: string): Promise<Partial<DatabaseModel>> {
if (!filePath.endsWith('.json')) {
throw new Error('Invalid file type');
}
const rawdata = await readFile(filePath, 'utf-8');
return JSON.parse(rawdata);
}
@@ -245,7 +245,7 @@ function notifyChanges(options: { timer?: boolean | string[]; external?: boolean
if (options.external) { if (options.external) {
// advice socket subscribers of change // advice socket subscribers of change
sendRefetch(); sendRefetch(Array.isArray(options.timer) ? options.timer : undefined);
} }
} }
@@ -27,6 +27,19 @@ import {
customFieldChangelog, customFieldChangelog,
} from '../rundownCache.js'; } from '../rundownCache.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setCustomFields: vi.fn().mockImplementation((newData) => newData),
setRundown: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('generate()', () => { describe('generate()', () => {
it('creates normalised versions of a given rundown', () => { it('creates normalised versions of a given rundown', () => {
const testRundown: OntimeRundown = [ const testRundown: OntimeRundown = [
@@ -170,6 +183,19 @@ describe('generate()', () => {
expect(initResult.totalDuration).toBe(500 - 100); expect(initResult.totalDuration).toBe(500 - 100);
}); });
it('calculates total duration with 0 duration events without causing a next day', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 100, duration: 0 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 100, timeEnd: 300 } as OntimeEvent,
{ type: SupportedEvent.Event, id: 'skipped', skip: true, timeStart: 300, timeEnd: 400 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(4);
expect(initResult.totalDuration).toBe(500 - 100);
});
it('calculates total duration across days with gap', () => { it('calculates total duration across days with gap', () => {
const testRundown: OntimeRundown = [ const testRundown: OntimeRundown = [
{ {
@@ -836,23 +862,6 @@ describe('calculateRuntimeDelaysFrom()', () => {
describe('custom fields', () => { describe('custom fields', () => {
describe('createCustomField()', () => { describe('createCustomField()', () => {
beforeEach(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
DataProvider: {
...vi.fn().mockImplementation(() => {
return {};
}),
getCustomFields: vi.fn().mockReturnValue({}),
setCustomFields: vi.fn().mockImplementation((newData) => {
return newData;
}),
persist: vi.fn().mockReturnValue({}),
},
};
});
});
it('creates a field from given parameters', async () => { it('creates a field from given parameters', async () => {
const expected = { const expected = {
lighting: { lighting: {

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