Compare commits

...

70 Commits

Author SHA1 Message Date
arc-alex a8bc4eb098 bump to version 3.4.1 2024-07-29 12:52:58 +02:00
arc-alex 807698aee1 restore rundownGetAll 2024-07-29 12:51:44 +02:00
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
Alex Christoffer Rasmussen b7c48f38a9 hotfix-build (#1091)
* skip  "alwaysStrict": true

* bump to 3.3.2

* remove all new ts check to be sure
2024-06-17 11:44:44 +02:00
Carlos Valente dcb6bfa62d bump version to 3.3.1 2024-06-17 08:21:01 +02:00
Carlos Valente 0abc4de3cd fix: column reordering in cuesheet 2024-06-17 08:14:12 +02:00
Carlos Valente f750ed3cdc style: match table header size 2024-06-17 08:14:12 +02:00
Carlos Valente f026c1c1e3 chore: run linter on imports 2024-06-17 08:10:14 +02:00
Piotr Górski ce176b01a2 Added Polish Translation (#1077) 2024-06-16 14:46:11 +02:00
Carlos Valente 337dd7892a chore: fix typo in donation text 2024-06-16 14:45:16 +02:00
Carlos Valente b7daf2926a refactor: improve typing 2024-06-16 09:07:01 +02:00
jwetzell 81a07099ae Merge pull request #1084 from cpvalente/fix/npm-publish-with-provenance
add permission for provenance publishing
2024-06-15 14:59:28 -05:00
jwetzell 8f88ca5053 add permission for provenance publishing
https://docs.npmjs.com/generating-provenance-statements
2024-06-15 14:59:16 -05:00
jwetzell 82a885dc44 Merge pull request #1082 from cpvalente/fix/publish-action
add registry url for publishing via CI
2024-06-15 13:33:58 -05:00
jwetzell c4be814de5 add registry url for publishing via CI 2024-06-15 13:33:08 -05:00
Carlos Valente 9f3186e249 bump version to 3.3.0 2024-06-15 17:30:58 +02:00
jwetzell 5159554697 Merge pull request #1080 from cpvalente/fix/npm-publish-action
disable git checks for pnpm publish workflow
2024-06-15 08:30:30 -05:00
jwetzell cf690aff08 disable git checks for pnpm publish workflow 2024-06-15 08:25:43 -05:00
141 changed files with 10129 additions and 7298 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
+9 -6
View File
@@ -1,12 +1,14 @@
name: Ontime CLI build name: Ontime CLI build
on: on:
push: release:
tags: [ "*" ] types: [published]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build_cli: build_cli:
permissions:
id-token: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -18,12 +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'
- 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
@@ -43,8 +46,8 @@ jobs:
run: cp -R apps/server/src/external apps/cli/external run: cp -R apps/server/src/external apps/cli/external
- name: Publish to NPM - name: Publish to NPM
run: pnpm publish --access public run: pnpm publish --access public --no-git-checks
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/cli working-directory: ./apps/cli
+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
@@ -38,7 +38,7 @@ npx install @getontime/cli
## Sponsoring ## Sponsoring
You can help the development of this project or say thank you with a one time donation. \ You can help the development of this project or say thank you with a one time donation. \
See the [terms of fonations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md) See the [terms of donations](https://github.com/cpvalente/ontime/blob/master/SPONSOR.md)
[![](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.1.0", "version": "3.4.1",
"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.0", "version": "3.4.1",
"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) => {
@@ -153,6 +153,7 @@ export default function GeneralPanelForm() {
<option value='pt'>Portuguese</option> <option value='pt'>Portuguese</option>
<option value='es'>Spanish</option> <option value='es'>Spanish</option>
<option value='sv'>Swedish</option> <option value='sv'>Swedish</option>
<option value='pl'>Polish</option>
</Select> </Select>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
@@ -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',
@@ -47,11 +47,13 @@ $table-header-font-size: calc(1rem - 3px);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
background-color: $gray-1300;
font-size: $table-header-font-size; font-size: $table-header-font-size;
color: $gray-700; color: $gray-700;
font-weight: 400;
th {
background-color: $gray-1300;
}
} }
.eventRow { .eventRow {
+11 -19
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import Color from 'color'; import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
@@ -13,7 +12,7 @@ import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow'; import EventRow from './cuesheet-table-elements/EventRow';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings'; import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import { useCuesheetSettings } from './store/CuesheetSettings'; import { useCuesheetSettings } from './store/CuesheetSettings';
import { initialColumnOrder } from './cuesheetCols'; import useColumnManager from './useColumnManager';
import style from './Cuesheet.module.scss'; import style from './Cuesheet.module.scss';
@@ -27,23 +26,20 @@ interface CuesheetProps {
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) { export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings(); const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} }); const {
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({ columnVisibility,
key: 'table-order', columnOrder,
defaultValue: initialColumnOrder, columnSizing,
}); resetColumnOrder,
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} }); setColumnVisibility,
saveColumnOrder,
setColumnSizing,
} = useColumnManager(columns);
const selectedRef = useRef<HTMLTableRowElement | null>(null); const selectedRef = useRef<HTMLTableRowElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null); const tableContainerRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected }); useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
// keep column order in sync with columns
useEffect(() => {
const order = columns.map((col) => col.id as string);
saveColumnOrder(order);
}, [columns, saveColumnOrder]);
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
@@ -61,10 +57,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const resetColumnOrder = () => {
saveColumnOrder(initialColumnOrder);
};
const setAllVisible = () => { const setAllVisible = () => {
table.toggleAllColumnsVisible(true); table.toggleAllColumnsVisible(true);
}; };
@@ -117,5 +117,3 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
...dynamicCustomFields, ...dynamicCustomFields,
]; ];
} }
export const initialColumnOrder: string[] = makeCuesheetColumns({}).map((column) => column.id as string);
@@ -0,0 +1,46 @@
import { useCallback, useEffect } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
export default function useColumnManager(columns: ColumnDef<OntimeRundownEntry>[]) {
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: 'table-order',
defaultValue: columns.map((col) => col.id as string),
});
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
// if the columns change, we update the dataset
useEffect(() => {
let shouldReplace = false;
const newColumns: string[] = [];
// iterate through columns to see if there are new ids
columns.forEach((column) => {
const columnnId = column.id as string;
if (!shouldReplace && !columnOrder.includes(columnnId)) {
shouldReplace = true;
}
newColumns.push(columnnId);
});
if (shouldReplace) {
saveColumnOrder(newColumns);
}
}, [columnOrder, columns, saveColumnOrder]);
const resetColumnOrder = useCallback(() => {
saveColumnOrder(columns.map((col) => col.id as string));
}, [columns, saveColumnOrder]);
return {
columnVisibility,
columnOrder,
columnSizing,
resetColumnOrder,
setColumnVisibility,
saveColumnOrder,
setColumnSizing,
};
}
@@ -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);
@@ -8,6 +8,7 @@ import { langEs } from './languages/es';
import { langFr } from './languages/fr'; import { langFr } from './languages/fr';
import { langIt } from './languages/it'; import { langIt } from './languages/it';
import { langNo } from './languages/no'; import { langNo } from './languages/no';
import { langPl } from './languages/pl';
import { langPt } from './languages/pt'; import { langPt } from './languages/pt';
import { langSv } from './languages/sv'; import { langSv } from './languages/sv';
@@ -20,6 +21,7 @@ const translationsList = {
no: langNo, no: langNo,
pt: langPt, pt: langPt,
sv: langSv, sv: langSv,
pl: langPl,
}; };
interface TranslationContextValue { interface TranslationContextValue {
+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å',
@@ -0,0 +1,22 @@
import { TranslationObject } from './en';
export const langPl: TranslationObject = {
'common.expected_finish': 'Zakładany czas zakończenia',
'common.minutes': 'min',
'common.now': 'Teraz',
'common.next': 'Następnie',
'common.public_message': 'Wiadomość publiczna',
'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.started_at': 'Rozpoczęte o',
'common.time_now': 'Aktualny czas',
'countdown.ended': 'Zakończone o',
'countdown.running': 'Trwa',
'countdown.select_event': 'Wybierz event który chcesz śledzić',
'countdown.to_start': 'Do rozpoczęcia',
'countdown.waiting': 'Oczekiwanie na start',
'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.0", "version": "3.4.1",
"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.0", "version": "3.4.1",
"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,12 @@
import { ErrorResponse, MessageResponse, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types'; import {
ErrorResponse,
MessageResponse,
OntimeRundown,
OntimeRundownEntry,
RundownCached,
RundownPaginated,
} from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
@@ -13,8 +21,12 @@ 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,
getPaginated,
getRundown,
} from '../../services/rundown-service/rundownUtils.js';
export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) { export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) {
const rundown = getRundown(); const rundown = getRundown();
@@ -26,6 +38,51 @@ export async function rundownGetNormalised(_req: Request, res: Response<RundownC
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;
@@ -6,7 +6,9 @@ import {
rundownBatchPut, rundownBatchPut,
rundownDelete, rundownDelete,
rundownGetAll, rundownGetAll,
rundownGetById,
rundownGetNormalised, rundownGetNormalised,
rundownGetPaginated,
rundownPost, rundownPost,
rundownPut, rundownPut,
rundownReorder, rundownReorder,
@@ -16,6 +18,7 @@ import {
paramsMustHaveEventId, paramsMustHaveEventId,
rundownArrayOfIds, rundownArrayOfIds,
rundownBatchPutValidator, rundownBatchPutValidator,
rundownGetPaginatedQueryParams,
rundownPostValidator, rundownPostValidator,
rundownPutValidator, rundownPutValidator,
rundownReorderValidator, rundownReorderValidator,
@@ -25,7 +28,9 @@ import {
export const router = express.Router(); export const router = express.Router();
router.get('/', rundownGetAll); // not used in Ontime frontend router.get('/', rundownGetAll); // not used in Ontime frontend
router.get('/paginated', 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;
+19 -16
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
@@ -208,8 +210,9 @@ export const startServer = async (
expressServer.listen(serverPort, '0.0.0.0', () => { expressServer.listen(serverPort, '0.0.0.0', () => {
const nif = getNetworkInterfaces(); const nif = getNetworkInterfaces();
consoleSuccess(`Local: http://localhost:${serverPort}/editor`); consoleSuccess(`Local: http://localhost:${serverPort}/editor`);
for (const key of Object.keys(nif)) { for (const key in nif) {
consoleSuccess(`Network: http://${nif[key].address}:${serverPort}/editor`); const address = nif[key].address;
consoleSuccess(`Network: http://${address}:${serverPort}/editor`);
} }
}); });
@@ -222,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...');
+4 -4
View File
@@ -4,11 +4,11 @@ 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[];
private escalateErrorFn: (error: string) => void | null; private escalateErrorFn: ((error: string) => void) | null;
private canLog = false; private canLog = false;
constructor() { constructor() {
@@ -20,7 +20,7 @@ class Logger {
/** /**
* Enabling setup logger after init * Enabling setup logger after init
*/ */
init(escalateErrorFn: (error: string) => void) { init(escalateErrorFn?: (error: string) => void) {
// flush logs from queue // flush logs from queue
this.queue.forEach((log) => { this.queue.forEach((log) => {
this._push(log); this._push(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 {
@@ -1,4 +1,4 @@
import { DatabaseModel, OntimeRundown, Settings, URLPreset } from 'ontime-types'; import { DatabaseModel, OntimeRundown, Settings, URLPreset, ViewSettings } from 'ontime-types';
import { safeMerge } from '../DataProvider.utils.js'; import { safeMerge } from '../DataProvider.utils.js';
describe('safeMerge', () => { describe('safeMerge', () => {
@@ -49,7 +49,7 @@ describe('safeMerge', () => {
} as DatabaseModel; } as DatabaseModel;
it('returns existing data if new data is not provided', () => { it('returns existing data if new data is not provided', () => {
const mergedData = safeMerge(existing, undefined); const mergedData = safeMerge(existing, {});
expect(mergedData).toEqual(existing); expect(mergedData).toEqual(existing);
}); });
@@ -136,6 +136,7 @@ describe('safeMerge', () => {
rundown: [], rundown: [],
project: { project: {
title: '', title: '',
description: '',
publicUrl: '', publicUrl: '',
publicInfo: '', publicInfo: '',
backstageUrl: '', backstageUrl: '',
@@ -153,8 +154,9 @@ describe('safeMerge', () => {
viewSettings: { viewSettings: {
overrideStyles: false, overrideStyles: false,
endMessage: '', endMessage: '',
}, } as ViewSettings,
urlPresets: [], urlPresets: [],
customFields: {},
osc: { osc: {
portIn: 8888, portIn: 8888,
portOut: 9999, portOut: 9999,
@@ -163,6 +165,10 @@ describe('safeMerge', () => {
enabledOut: false, enabledOut: false,
subscriptions: [], subscriptions: [],
}, },
http: {
enabledOut: false,
subscriptions: [],
},
} as DatabaseModel; } as DatabaseModel;
const newData = { const newData = {
+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;

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