Compare commits

...

79 Commits

Author SHA1 Message Date
Carlos Valente a26aa9e01c bump version to 3.5.0-beta.2 2024-08-04 21:37:01 +02:00
Carlos Valente da11ef64c2 refactor: roll mode
* chore: escalate stack trace to console

* refactor: event finding in roll

* docs: initial roll specification

* refactor: call to roll

* refactor: normalise index

* refactor: roll into next event

* refactor: hot reload on waiting to roll

* refactor: wait to roll next
2024-08-04 21:34:01 +02:00
Carlos Valente 3012d73285 feat: timeline view 2024-08-04 21:32:25 +02:00
Alex Christoffer Rasmussen bad491063d fix: getShouldClockUpdate test (#1165)
* useFakeTimers

* now can never be negative
2024-07-31 16:58:02 +02:00
Alex Christoffer Rasmussen a103f8b347 show time in block (#1158) 2024-07-31 14:00:40 +02:00
Alex Christoffer Rasmussen dffccd258f prevent rare case of old customFields (#1163)
as part of subscriptions in operator
2024-07-30 15:42:37 +02:00
Alex Christoffer Rasmussen ce8b0d9850 FIX: remove '/' from host in client-control(#1156) 2024-07-30 12:36:31 +02:00
Alex Christoffer Rasmussen e500fa6b5e about in electron help menu (#1155) 2024-07-29 18:06:10 +02:00
Alex Christoffer Rasmussen 67be4a5631 restore rundownGetAll (#1162) 2024-07-29 13:29:56 +02:00
Alex Christoffer Rasmussen c7893938eb remove old l3 options (#1160) 2024-07-27 09:44:41 +02:00
Alex Christoffer Rasmussen 158ef05ff0 add relevantBlock function (#1100)
* feat: current block

* pass full rundown to functions and use filter utils
2024-07-23 12:22:47 +02:00
Carlos Valente 20838c038a chore: evaluate react 19 migration 2024-07-23 09:02:44 +02:00
Carlos Valente 1ddcec993a chore: upgrade sentry to react version 2024-07-22 22:27:10 +02:00
Alex Christoffer Rasmussen 91d6adf8e0 useFakeTimers for initRundown in tests (#1150) 2024-07-22 14:15:09 +02:00
Carlos Valente a7fe5eceef chore: upgrade sentry 2024-07-20 16:25:46 +02:00
jwetzell e6e00c0d4a add freeze feature to rundown (#1106)
* add frozen flag to runtime store

* add middleware for when frozen

* prevent rundown editing when frozen

* add endpoint to router to set frozen state

* add frozen property to rundown placeholder in client
2024-07-19 14:14:59 +02:00
Carlos Valente edc7f20d7a refactor: timer update 2024-07-19 14:11:12 +02:00
Carlos Valente cb773ded9f refactor: improve pending detection 2024-07-19 14:11:12 +02:00
Carlos Valente 2f65711078 refactor: dev label 2024-07-19 14:11:12 +02:00
jwetzell 3056b75960 clean out unused demo and test db.json files (#1148)
* remove demo and test db JSON

* remove demo-db reference in Dockerfile

* remove preloaded-db folder in server

* cleanup package.json scripts

* remove db.json references from server
2024-07-18 22:27:24 +02:00
jwetzell dd9423abf4 remove unused dependency 2024-07-18 22:26:58 +02:00
jwetzell dee6a67018 Merge pull request #1146 from cpvalente/chore/pnpm-catalogs
convert shared dependencies to pnpm catalog
2024-07-17 22:23:47 -05:00
jwetzell a79fb4ecbb convert shared dependencies to pnpm catalog 2024-07-17 07:53:43 -05:00
jwetzell a53a1f87be Merge pull request #1145 from cpvalente/chore/remove-husky
remove husky and pre-commit scripts
2024-07-17 07:41:17 -05:00
jwetzell ef9afcbf3e remove husky and pre-commit scripts 2024-07-16 17:57:20 -05:00
Carlos Valente 33b04e01c9 chore: cleanup unused 2024-07-16 21:34:21 +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
186 changed files with 12722 additions and 9531 deletions
+6 -6
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -1,8 +1,8 @@
name: Ontime CLI build name: Ontime CLI build
on: on:
push: release:
tags: [ "*" ] types: [published]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -20,13 +20,13 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+6 -8
View File
@@ -1,8 +1,8 @@
name: Docker Image CI Ontime name: Docker Image CI Ontime
on: on:
push: release:
tags: [ "*" ] types: [published]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -14,8 +14,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Docker Login - name: Docker Login
uses: docker/login-action@v2.1.0 uses: docker/login-action@v2.1.0
@@ -32,10 +30,10 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry # Push is a shorthand for --output=type=registry
push: true push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release - name: Build and push pre-release
if: github.event.release.prerelease == true if: github.event.release.prerelease == true
@@ -43,8 +41,8 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry # Push is a shorthand for --output=type=registry
push: true push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.18.2 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 8 version: 9
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
+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 -5
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/
@@ -22,7 +22,6 @@ COPY --from=builder /app/apps/client/build ./client/
# Prepare Backend # Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/ COPY --from=builder /app/apps/server/dist/ ./server/
COPY ./demo-db/ ./preloaded-db/
COPY --from=builder /app/apps/server/src/external/ ./external/ COPY --from=builder /app/apps/server/src/external/ ./external/
# Export default ports # Export default ports
+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)
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.3.2", "version": "3.5.0-beta.2",
"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",
@@ -20,8 +20,8 @@
"server" "server"
], ],
"devDependencies": { "devDependencies": {
"eslint": "^8.53.0", "eslint": "catalog:",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "catalog:",
"prettier": "^3.0.3" "prettier": "catalog:"
} }
} }
+11 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.3.2", "version": "3.5.0-beta.2",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -13,7 +13,7 @@
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.6.2", "@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.92.0", "@sentry/react": "^8.19.0",
"@tanstack/react-query": "^5.17.9", "@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9", "@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3", "@tanstack/react-table": "^8.11.3",
@@ -22,9 +22,9 @@
"color": "^4.2.3", "color": "^4.2.3",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
"react": "^18.2.0", "react": "^18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.2.0", "react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.49.2", "react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
@@ -42,7 +42,6 @@
"build:docker": "vite build", "build:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build", "build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest", "test": "vitest",
"test:pipeline": "vitest run", "test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build", "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
@@ -70,13 +69,13 @@
"@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": "catalog:",
"@typescript-eslint/parser": "^7.12.0", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0", "eslint": "catalog:",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0", "eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
@@ -84,9 +83,9 @@
"jsdom": "^21.1.0", "jsdom": "^21.1.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"prettier": "^3.3.1", "prettier": "catalog:",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "^5.4.3", "typescript": "catalog:",
"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",
+57 -30
View File
@@ -1,24 +1,36 @@
import { lazy, Suspense } from 'react'; import React from 'react';
import { Navigate, Route, Routes } from 'react-router-dom'; import {
createRoutesFromChildren,
matchRoutes,
Navigate,
Route,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { useClientPath } from './common/hooks/useClientPath'; import { useClientPath } from './common/hooks/useClientPath';
import Log from './features/log/Log'; import Log from './features/log/Log';
import withPreset from './features/PresetWrapper'; import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper'; import withData from './features/viewers/ViewWrapper';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = lazy(() => import('./features/editors/ProtectedEditor')); const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet')); const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/OperatorExport')); const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer')); const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = lazy(() => import('./features/viewers/clock/Clock')); const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = lazy(() => import('./features/viewers/countdown/Countdown')); const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage')); const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public')); const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const Public = React.lazy(() => import('./features/viewers/public/Public'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock')); const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withPreset(withData(TimerView)); const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView)); const SMinimalTimer = withPreset(withData(MinimalTimerView));
@@ -28,41 +40,56 @@ const SBackstage = withPreset(withData(Backstage));
const SPublic = withPreset(withData(Public)); const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower)); const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock)); const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper')); const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport')); const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport')); const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport')); const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
Sentry.init({
dsn: sentryDsn,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
export default function AppRouter() { export default function AppRouter() {
// handle client path changes // handle client path changes
useClientPath(); useClientPath();
return ( return (
<Suspense fallback={null}> <React.Suspense fallback={null}>
<Routes> <SentryRoutes>
<Route path='/' element={<Navigate to='/timer' />} /> <Route path='/' element={<Navigate to='/timer' />} />
<Route path='/timer' element={<STimer />} /> <Route path='/timer' element={<STimer />} />
<Route path='/public' element={<SPublic />} />
<Route path='/minimal' element={<SMinimalTimer />} /> <Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} /> <Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} /> <Route path='/countdown' element={<SCountdown />} />
<Route path='/backstage' element={<SBackstage />} /> <Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
<Route path='/studio' element={<SStudio />} /> <Route path='/studio' element={<SStudio />} />
<Route path='/lower' element={<SLowerThird />} /> <Route path='/lower' element={<SLowerThird />} />
<Route path='/timeline' element={<STimeline />} />
<Route path='/op' element={<Operator />} />
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} /> <Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
@@ -99,7 +126,7 @@ export default function AppRouter() {
/> />
{/*/!* Send to default if nothing found *!/*/} {/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} /> <Route path='*' element={<STimer />} />
</Routes> </SentryRoutes>
</Suspense> </React.Suspense>
); );
} }
+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;
} }
@@ -38,7 +38,7 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
onClose(); onClose();
}; };
const host = `${window.location.origin}/`; const host = window.location.origin;
const canSubmit = path !== currentPath && path !== ''; const canSubmit = path !== currentPath && path !== '';
return ( return (
@@ -54,7 +54,6 @@ function Overlay() {
}; };
}, [showOverlay, id, setIdentify, handleClose]); }, [showOverlay, id, setIdentify, handleClose]);
console.log('here2');
return ( return (
<div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}> <div className={style.overlay} data-testid='identify-overlay' onClick={handleClose}>
<div className={style.name}>{name}</div> <div className={style.name}>{name}</div>
@@ -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,37 @@ 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) { // TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
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 +80,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 +97,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;
}
@@ -12,9 +12,7 @@ export const useClientPath = () => {
// notify of client path changes // notify of client path changes
useEffect(() => { useEffect(() => {
//remove leading '/' from path socketSendJson('set-client-path', pathname + search);
const fullPath = (pathname.startsWith('/') ? pathname.slice(1) : pathname) + search;
socketSendJson('set-client-path', fullPath);
}, [pathname, search]); }, [pathname, search]);
// navigate to new path when received from server // navigate to new path when received from server
@@ -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) => {
+12
View File
@@ -115,6 +115,7 @@ export const setAuxTimer = {
export const useCuesheet = () => { export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
currentBlockId: state.currentBlock.block?.id ?? null,
selectedEventId: state.eventNow?.id ?? null, selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
@@ -179,6 +180,17 @@ export const useRuntimePlaybackOverview = () => {
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset, offset: state.runtime.offset,
currentBlock: state.currentBlock,
});
return useRuntimeStore(featureSelector);
};
export const useTimelineStatus = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
+5
View File
@@ -38,6 +38,10 @@ export const runtimeStorePlaceholder: RuntimeStore = {
actualStart: null, actualStart: null,
expectedEnd: null, expectedEnd: null,
}, },
currentBlock: {
block: null,
startedAt: null,
},
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
publicEventNow: null, publicEventNow: null,
@@ -48,6 +52,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
duration: 0, duration: 0,
playback: SimplePlayback.Stop, playback: SimplePlayback.Stop,
}, },
frozen: false,
}; };
const deepCompare = <T>(a: T, b: T) => isEqual(a, b); const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
+7 -1
View File
@@ -36,7 +36,8 @@ export const connectSocket = () => {
} }
socketSendJson('set-client-type', 'ontime'); socketSendJson('set-client-type', 'ontime');
socketSendJson('set-client-path', location.pathname);
socketSendJson('set-client-path', location.pathname + location.search);
}; };
websocket.onclose = () => { websocket.onclose = () => {
@@ -150,6 +151,11 @@ export const connectSocket = () => {
updateDevTools({ eventNow: payload }); updateDevTools({ eventNow: payload });
break; break;
} }
case 'ontime-currentBlock': {
patchRuntime('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': { case 'ontime-publicEventNow': {
patchRuntime('publicEventNow', payload); patchRuntime('publicEventNow', payload);
updateDevTools({ publicEventNow: payload }); updateDevTools({ publicEventNow: payload });
@@ -34,3 +34,16 @@ export const enDash = '';
export const timerPlaceholder = '––:––:––'; export const timerPlaceholder = '––:––:––';
export const timerPlaceholderMin = '––:––'; export const timerPlaceholderMin = '––:––';
/**
* Adds opacity to a given colour if possible
*/
export function alpha(colour: string, amount: number): string {
try {
const withAlpha = Color(colour).alpha(amount).hexa();
return withAlpha;
} catch (_error) {
/* we do not handle errors here */
}
return colour;
}
+29 -6
View File
@@ -1,5 +1,5 @@
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types'; import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis } from 'ontime-utils'; import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig'; import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants'; import { APP_SETTINGS } from '../api/constants';
@@ -9,17 +9,17 @@ import { ontimeQueryClient } from '../queryClient';
* Returns current time in milliseconds * Returns current time in milliseconds
* @returns {number} * @returns {number}
*/ */
export const nowInMillis = () => { export function nowInMillis(): number {
const now = new Date(); const now = new Date();
// extract milliseconds since midnight // extract milliseconds since midnight
let elapsed = now.getHours() * 3600000; let elapsed = now.getHours() * MILLIS_PER_HOUR;
elapsed += now.getMinutes() * 60000; elapsed += now.getMinutes() * MILLIS_PER_MINUTE;
elapsed += now.getSeconds() * 1000; elapsed += now.getSeconds() * MILLIS_PER_SECOND;
elapsed += now.getMilliseconds(); elapsed += now.getMilliseconds();
return elapsed; return elapsed;
}; }
/** /**
* @description Resolves format from url and store * @description Resolves format from url and store
@@ -95,3 +95,26 @@ export const formatTime = (
const isNegative = milliseconds < 0; const isNegative = milliseconds < 0;
return `${isNegative ? '-' : ''}${display}`; return `${isNegative ? '-' : ''}${display}`;
}; };
/**
* Handles case for formatting a duration time
* @param duration
* @returns
*/
export function formatDuration(duration: number): string {
// durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) {
return '0h 0m';
}
const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = '';
if (hours > 0) {
result += `${hours}h `;
}
if (minutes > 0) {
result += `${minutes}m`;
}
return result;
}
@@ -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',
@@ -21,11 +21,11 @@ interface CuesheetProps {
columns: ColumnDef<OntimeRundownEntry>[]; columns: ColumnDef<OntimeRundownEntry>[];
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void; handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
selectedId: string | null; selectedId: string | null;
currentBlockId: string | null;
} }
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) { export default function Cuesheet({ data, columns, handleUpdate, selectedId, currentBlockId }: CuesheetProps) {
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings(); const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
const { const {
columnVisibility, columnVisibility,
columnOrder, columnOrder,
@@ -114,11 +114,16 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
} }
if (isOntimeBlock(row.original)) { if (isOntimeBlock(row.original)) {
if (isPast && !showPrevious && key !== currentBlockId) {
return null;
}
return <BlockRow key={key} title={row.original.title} />; return <BlockRow key={key} title={row.original.title} />;
} }
if (isOntimeDelay(row.original)) { if (isOntimeDelay(row.original)) {
if (isPast && !showPrevious) {
return null;
}
const delayVal = row.original.duration; const delayVal = row.original.duration;
if (!showDelayBlock || delayVal === 0) { if (!showDelayBlock || delayVal === 0) {
return null; return null;
} }
@@ -128,9 +133,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
if (isOntimeEvent(row.original)) { if (isOntimeEvent(row.original)) {
eventIndex++; eventIndex++;
const isSelected = key === selectedId; const isSelected = key === selectedId;
if (isSelected) {
isPast = false;
}
if (isPast && !showPrevious) { if (isPast && !showPrevious) {
return null; return null;
@@ -107,6 +107,7 @@ export default function CuesheetWrapper() {
columns={columns} columns={columns}
handleUpdate={handleUpdate} handleUpdate={handleUpdate}
selectedId={featureData.selectedEventId} selectedId={featureData.selectedEventId}
currentBlockId={featureData.currentBlockId}
/> />
</div> </div>
); );
@@ -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
@@ -170,9 +170,12 @@ export default function Operator() {
const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title; const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title;
const secondaryField = getPropertyValue(entry, secondary) ?? ''; const secondaryField = getPropertyValue(entry, secondary) ?? '';
const subscribedData = subscriptions const subscribedData = subscriptions
? subscriptions.map((id) => { ? subscriptions.flatMap((id) => {
if (!customFields[id]) {
return [];
}
const { label, colour } = customFields[id]; const { label, colour } = customFields[id];
return { id, label, colour, value: entry.custom[id] }; return [{ id, label, colour, value: entry.custom[id] }];
}) })
: null; : null;
@@ -30,7 +30,7 @@
&.running { &.running {
border-top: 1px solid $gray-1300; border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $red-700); background-color: var(--operator-running-bg-override, $active-red);
} }
&.past { &.past {
@@ -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 {
@@ -98,8 +101,7 @@
.field { .field {
font-weight: 600; font-weight: 600;
padding: 0 0.25rem; padding-inline: 0.25rem;
margin-right: 0.25rem;
} }
.noColour { .noColour {
@@ -110,6 +112,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 hide 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,
},
];
};
@@ -33,6 +33,7 @@ function _EditorOverview({ children }: { children: React.ReactNode }) {
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} /> <TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
</div> </div>
<ProgressOverview /> <ProgressOverview />
<CurrentBlockOverview />
<RuntimeOverview /> <RuntimeOverview />
<div> <div>
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} /> <TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
@@ -94,6 +95,14 @@ function TitlesOverview() {
); );
} }
function CurrentBlockOverview() {
const { currentBlock, clock } = useRuntimePlaybackOverview();
const timeInBlock = formatedTime(currentBlock.startedAt === null ? null : clock - currentBlock.startedAt);
return <TimeColumn label='Time in block' value={timeInBlock} className={style.clock} />;
}
function TimerOverview() { function TimerOverview() {
const { current } = useTimer(); const { current } = useTimer();
@@ -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);
.aux-timers { align-self: flex-end;
text-align: center; height: fit-content;
font-size: clamp(24px, 1.75vw, 32px);
&__label { &__projected-start {
color: var(--label-color-override, $viewer-label-color); grid-area: projected-start;
text-transform: uppercase; }
}
&__value { &__projected-end {
font-size: clamp(32px, 3.5vw, 50px); grid-area: projected-end;
color: var(--secondary-color-override, $viewer-secondary-color); }
letter-spacing: 0.05em;
line-height: 0.95em;
&--delayed { &__scheduled-start {
color: $delay-color; grid-area: scheduled-start;
} }
&__scheduled-end {
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,120 @@
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: '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,
];
@@ -0,0 +1,111 @@
@use '../../../theme/viewerDefs' as *;
$timeline-entry-height: 20px;
$lane-height: 120px;
$timeline-height: 1rem;
.timeline {
flex: 1;
font-weight: 600;
color: $ui-white;
}
.timelineEvents {
position: relative;
height: 100%;
}
.column {
display: flex;
flex-direction: column;
position: absolute;
border-left: 1px solid $ui-black;
// avoiding content being larger than the view
height: calc(100% - 3rem);
// decorate timeline element
&::before {
content: '';
position: absolute;
box-sizing: content-box;
top: -$timeline-height;
left: 0;
right: 0;
height: $timeline-height;
background-color: $white-40;
}
}
.smallArea {
.content {
gap: 0rem;
writing-mode: vertical-rl;
}
.timeOverview {
opacity: 0;
}
}
.hide {
// hide text elements
& > div {
display: none;
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2rem;
padding-top: 0.25rem;
padding-inline-start: 0.25rem;
overflow: hidden;
line-height: 1rem;
background-color: var(--lighter, $viewer-card-bg-color);
border-bottom: 2px solid $ui-black;
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
&[data-status='done'] {
opacity: $opacity-disabled;
}
&[data-status='live'] {
box-shadow: 0 0.25rem 0 0 $active-red;
}
}
.delay {
margin-top: -2rem;
margin-bottom: -1rem;
}
.timeOverview {
padding-top: 0.25rem;
padding-inline-start: 0.25em;
text-transform: capitalize;
white-space: normal;
height: 6rem;
&[data-status='done'] {
opacity: $opacity-disabled;
}
&[data-status='live'] {
.status {
color: $active-red;
}
}
&[data-status='future'] {
.status {
color: $green-500;
}
}
}
.cross {
text-decoration: line-through;
}
@@ -0,0 +1,107 @@
import { memo } from 'react';
import { useViewportSize } from '@mantine/hooks';
import { isOntimeEvent, MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs, getFirstEvent, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
import ProgressBar from './timeline-progress-bar/TimelineProgressBar';
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
import { ProgressStatus, TimelineEntry } from './TimelineEntry';
import style from './Timeline.module.scss';
function useTimeline(rundown: OntimeEvent[]) {
const { firstEvent } = getFirstEvent(rundown);
const { lastEvent } = getLastEvent(rundown);
const firstStart = firstEvent?.timeStart ?? 0;
const lastEnd = lastEvent?.timeEnd ?? 0;
const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd;
// we make sure the end accounts for delays
const accumulatedDelay = lastEvent?.delay ?? 0;
// timeline is padded to nearest hours (floor and ceil)
const startHour = getStartHour(firstStart);
const endHour = getEndHour(normalisedLastEnd + accumulatedDelay);
return {
rundown: rundown,
startHour,
endHour,
};
}
interface TimelineProps {
selectedEventId: string | null;
rundown: OntimeEvent[];
}
export default memo(Timeline);
function Timeline(props: TimelineProps) {
const { selectedEventId, rundown: baseRundown } = props;
const { width: screenWidth } = useViewportSize();
const timelineData = useTimeline(baseRundown);
if (timelineData === null) {
return null;
}
const { rundown, startHour, endHour } = timelineData;
let hasTimelinePassedMidnight = false;
let previousEventStartTime: MaybeNumber = null;
// we use selectedEventId as a signifier on whether the timeline is live
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
return (
<div className={style.timeline}>
<TimelineMarkers startHour={startHour} endHour={endHour} />
<ProgressBar startHour={startHour} endHour={endHour} />
<div className={style.timelineEvents}>
{rundown.map((event) => {
// for now we dont render delays and blocks
if (!isOntimeEvent(event)) {
return null;
}
// keep track of progress of rundown
if (eventStatus === 'live') {
eventStatus = 'future';
}
if (event.id === selectedEventId) {
eventStatus = 'live';
}
if (!hasTimelinePassedMidnight) {
// we need to offset the start to account for midnight
hasTimelinePassedMidnight = previousEventStartTime !== null && event.timeStart < previousEventStartTime;
}
const normalisedStart = hasTimelinePassedMidnight ? event.timeStart + dayInMs : event.timeStart;
previousEventStartTime = normalisedStart;
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
startHour * MILLIS_PER_HOUR,
endHour * MILLIS_PER_HOUR,
normalisedStart + (event.delay ?? 0),
event.duration,
screenWidth,
);
return (
<TimelineEntry
key={event.id}
colour={event.colour}
delay={event.delay ?? 0}
duration={event.duration}
left={elementLeftPosition}
status={eventStatus}
start={normalisedStart} // solve issues related to crossing midnight
title={event.title}
width={elementWidth}
/>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,94 @@
import { useTimelineStatus } from '../../../common/hooks/useSocket';
import { alpha, cx } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { getStatusLabel } from './timeline.utils';
import style from './Timeline.module.scss';
export type ProgressStatus = 'done' | 'live' | 'future';
interface TimelineEntryProps {
colour: string;
delay: number;
duration: number;
left: number;
status: ProgressStatus;
start: number;
title: string;
width: number;
}
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
export function TimelineEntry(props: TimelineEntryProps) {
const { colour, delay, duration, left, status, start, title, width } = props;
const formattedStartTime = formatTime(start, formatOptions);
const formattedDuration = formatDuration(duration);
const delayedStart = start + delay;
const hasDelay = delay > 0;
const lighterColour = alpha(colour, 0.7);
const columnClasses = cx([style.column, width < 40 && style.smallArea]);
const contentClasses = cx([style.content, width < 20 && style.hide]);
const showTitle = width > 25;
return (
<div
className={columnClasses}
style={{
'--color': colour,
'--lighter': lighterColour ?? '',
left: `${left}px`,
width: `${width}px`,
}}
>
<div
className={contentClasses}
data-status={status}
style={{
'--color': colour,
}}
>
<div className={hasDelay ? style.cross : undefined}>{formattedStartTime}</div>
{hasDelay && <div className={style.delay}>{formatTime(delayedStart, formatOptions)}</div>}
{showTitle && <div>{title}</div>}
</div>
<div className={style.timeOverview} data-status={status}>
{status !== 'done' && (
<>
<div className={style.duration}>{formattedDuration}</div>
<TimelineEntryStatus status={status} start={delayedStart} />
</>
)}
</div>
</div>
);
}
interface TimelineEntryStatusProps {
status: ProgressStatus;
start: number;
}
// we isolate this component to avoid isolate re-renders provoked by the clock changes
function TimelineEntryStatus(props: TimelineEntryStatusProps) {
const { status, start } = props;
const { clock, offset } = useTimelineStatus();
const { getLocalizedString } = useTranslation();
// start times need to be normalised in a rundown that crosses midnight
let statusText = getStatusLabel(start - clock + offset, status);
if (statusText === 'live') {
statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') {
statusText = getLocalizedString('timeline.due');
}
return <div className={style.status}>{statusText}</div>;
}
@@ -0,0 +1,24 @@
.timeline {
width: 100vw;
height: 100vh;
background-color: $ui-black;
color: $ui-white;
display: flex;
flex-direction: column;
gap: 2rem;
}
.title {
padding-inline: 2rem;
font-size: 3.5rem;
}
.sections {
padding-inline: 2rem;
display: grid;
grid-template-columns: 1fr 1fr;
row-gap: 1rem;
column-gap: 3rem;
}
@@ -0,0 +1,67 @@
import { useMemo } from 'react';
import { MaybeString, OntimeEvent, ProjectData, Settings } from 'ontime-types';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import Section from './timeline-section/TimelineSection';
import Timeline from './Timeline';
import { getTimelineOptions } from './timeline.options';
import { getFormattedTimeToStart, getScopedRundown, getUpcomingEvents } from './timeline.utils';
import style from './TimelinePage.module.scss';
interface TimelinePageProps {
backstageEvents: OntimeEvent[];
events: OntimeEvent[];
general: ProjectData;
selectedId: MaybeString;
settings: Settings | undefined;
time: ViewExtendedTimer;
}
/**
* since we inherit from viewPage
* which refreshes at least once a second
* There is little point splitting or memoising top level elements
*/
export default function TimelinePage(props: TimelinePageProps) {
const { events, general, selectedId, settings, time } = props;
const { getLocalizedString } = useTranslation();
const clock = formatTime(time.clock);
const scopedRundown = useMemo(() => {
return getScopedRundown(events, selectedId);
}, [events, selectedId]);
const { now, next, followedBy } = useMemo(() => {
return getUpcomingEvents(scopedRundown, selectedId);
}, [scopedRundown, selectedId]);
// populate options
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = getTimelineOptions(defaultFormat);
const titleNow = now?.title ?? '-';
const dueText = getLocalizedString('timeline.due');
const nextText = next !== null ? `${next.title} · ${getFormattedTimeToStart(next, time.clock, dueText)}` : '-';
const followedByText =
followedBy !== null ? `${followedBy.title} · ${getFormattedTimeToStart(followedBy, time.clock, dueText)}` : '-';
return (
<div className={style.timeline}>
<ViewParamsEditor viewOptions={progressOptions} />
<div className={style.title}>{general.title}</div>
<div className={style.sections}>
<Section title={getLocalizedString('common.time_now')} content={clock} category='now' />
<Section title={getLocalizedString('common.next')} content={nextText} category='next' />
<Section title={getLocalizedString('timeline.live')} content={titleNow} category='now' />
<Section title={getLocalizedString('timeline.followedby')} content={followedByText} category='next' />
</div>
<Timeline selectedEventId={selectedId} rundown={scopedRundown} />
</div>
);
}
@@ -0,0 +1,66 @@
import { dayInMs } from 'ontime-utils';
import { getElementPosition, makeTimelineSections } from '../timeline.utils';
describe('getCSSPosition()', () => {
it('accounts for rundown with one event', () => {
const scheduleStart = 0;
const scheduleEnd = dayInMs;
const eventStart = 0;
const eventDuration = dayInMs;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(0);
expect(result.width).toBe(containerWidth);
});
it('accounts for an event that starts halfway and ends at end', () => {
const scheduleStart = 0;
const scheduleEnd = 100;
const eventStart = 50;
const eventDuration = 50;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(50);
expect(result.width).toBe(50);
});
it('accounts for an event that starts first and ends halfway', () => {
const scheduleStart = 0;
const scheduleEnd = 100;
const eventStart = 0;
const eventDuration = 50;
const containerWidth = 100;
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(0);
expect(result.width).toBe(50);
});
it('accounts for an event that is in the middle of the rundown', () => {
const scheduleStart = 7;
const scheduleEnd = 23;
const eventStart = 10;
const eventDuration = 1;
const containerWidth = 1000;
// 16 hour event, this gives 62.5px per hour
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(187.5); // 3 * 62.5
expect(result.width).toBe(62.5);
});
});
describe('makeTmelineSections', () => {
it('creates an array between the hours given, end excluded', () => {
const result = makeTimelineSections(11, 17);
expect(result).toEqual(['11:00', '12:00', '13:00', '14:00', '15:00', '16:00']);
});
it('wraps around midnight', () => {
const result = makeTimelineSections(22, 26);
expect(result).toEqual(['22:00', '23:00', '00:00', '01:00']);
});
});
@@ -0,0 +1,16 @@
.markers {
width: 100%;
color: $ui-white;
display: flex;
height: 1rem;
line-height: 1rem;
margin-bottom: 0.25rem;
font-size: calc(1rem - 2px);
justify-content: space-evenly;
& > span {
flex-grow: 1;
border-left: 1px solid $white-7;
height: 100vh;
}
}
@@ -0,0 +1,22 @@
import { makeTimelineSections } from '../timeline.utils';
import style from './TimelineMarkers.module.scss';
interface TimelineMarkersProps {
startHour: number;
endHour: number;
}
export default function TimelineMarkers(props: TimelineMarkersProps) {
const { startHour, endHour } = props;
const elements = makeTimelineSections(startHour, endHour);
return (
<div className={style.markers}>
{elements.map((tag) => {
return <span key={tag}>{tag}</span>;
})}
</div>
);
}
@@ -0,0 +1,13 @@
.progressBar {
width: 100%;
height: 1rem;
transition-duration: 0.3s;
transition-property: left;
background-color: $gray-1000;
.progress {
height: 100%;
background-color: $active-red;
}
}
@@ -0,0 +1,24 @@
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { useClock } from '../../../../common/hooks/useSocket';
import { getRelativePositionX } from '../timeline.utils';
import style from './TimelineProgressBar.module.scss';
interface ProgressBarProps {
startHour: number;
endHour: number;
}
export default function ProgressBar(props: ProgressBarProps) {
const { startHour, endHour } = props;
const { clock } = useClock();
const width = getRelativePositionX(startHour * MILLIS_PER_HOUR, endHour * MILLIS_PER_HOUR, clock);
return (
<div className={style.progressBar}>
<div className={style.progress} style={{ width: `${width}%` }} />
</div>
);
}
@@ -0,0 +1,25 @@
.sectionTitle {
line-height: 1.2em;
font-size: 1.5rem;
text-transform: uppercase;
}
.sectionContent {
min-height: 2em;
line-height: 1em;
font-size: 3rem;
text-transform: uppercase;
font-weight: 600;
&.now {
color: $red-500;
}
&.next {
color: $green-500;
}
&.subdue {
opacity: $opacity-disabled;
}
}
@@ -0,0 +1,23 @@
import { MaybeString } from 'ontime-types';
import { cx } from '../../../../common/utils/styleUtils';
import style from './TimelineSection.module.scss';
interface SectionProps {
category: 'now' | 'next';
content: MaybeString;
title: string;
}
export default function Section(props: SectionProps) {
const { category, content, title } = props;
const contentClasses = cx([style.sectionContent, content != null ? style[category] : style.subdue]);
return (
<div>
<div className={style.sectionTitle}>{title}</div>
<div className={contentClasses}>{content ?? '-'}</div>
</div>
);
}
@@ -0,0 +1,22 @@
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideBackstage',
title: 'Hide Private Events',
description: 'Whether to hide non-public events',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -0,0 +1,154 @@
import { isOntimeEvent, MaybeString, OntimeEvent } from 'ontime-types';
import {
dayInMs,
getEventWithId,
getFirstEvent,
getNextEvent,
MILLIS_PER_HOUR,
millisToString,
removeSeconds,
} from 'ontime-utils';
import { clamp } from '../../../common/utils/math';
import { formatDuration } from '../../../common/utils/time';
import { isStringBoolean } from '../common/viewUtils';
import type { ProgressStatus } from './TimelineEntry';
type CSSPosition = {
left: number;
width: number;
};
/**
* Calculates the position (in %) of an element relative to a schedule
*/
export function getRelativePositionX(scheduleStart: number, scheduleEnd: number, now: number): number {
return clamp(((now - scheduleStart) / (scheduleEnd - scheduleStart)) * 100, 0, 100);
}
/**
* Calculates an absolute position of an element based on a schedule
*/
export function getElementPosition(
scheduleStart: number,
scheduleEnd: number,
eventStart: number,
eventDuration: number,
containerWidth: number,
): CSSPosition {
const normalEnd = scheduleEnd < scheduleStart ? scheduleEnd + dayInMs : scheduleEnd;
const totalDuration = normalEnd - scheduleStart;
const width = (eventDuration * containerWidth) / totalDuration;
const left = ((eventStart - scheduleStart) * containerWidth) / totalDuration;
return { left, width };
}
/**
* Gets rounded down hour for a given time
*/
export function getStartHour(startTime: number): number {
const hours = Math.floor(startTime / MILLIS_PER_HOUR);
return hours;
}
/**
* Gets rounded up hour for a given time
*/
export function getEndHour(endTime: number): number {
const hours = Math.ceil(endTime / MILLIS_PER_HOUR);
return hours;
}
/**
* converts a time span into an array of hours
*/
export function makeTimelineSections(firstHour: number, lastHour: number) {
const timelineSections = [];
for (let i = firstHour; i < lastHour; i++) {
timelineSections.push(removeSeconds(millisToString((i % 24) * MILLIS_PER_HOUR)));
}
return timelineSections;
}
/**
* Returns a formatted label for a progress status
*/
export function getStatusLabel(timeToStart: number, status: ProgressStatus): string {
if (status === 'done' || status === 'live') {
return status;
}
if (timeToStart < 0) {
return 'pending';
}
return formatDuration(timeToStart);
}
export function getScopedRundown(rundown: OntimeEvent[], selectedEventId: MaybeString): OntimeEvent[] {
if (rundown.length === 0) {
return [];
}
const params = new URL(document.location.href).searchParams;
const hideBackstage = isStringBoolean(params.get('hideBackstage'));
const hidePast = isStringBoolean(params.get('hidePast'));
let scopedRundown = [...rundown];
if (hidePast && selectedEventId) {
const currentIndex = rundown.findIndex((event) => event.id === selectedEventId);
if (currentIndex >= 0) {
scopedRundown = scopedRundown.slice(currentIndex);
}
}
if (hideBackstage) {
scopedRundown = scopedRundown.filter((event) => event.isPublic);
}
return scopedRundown;
}
type UpcomingEvents = {
now: OntimeEvent | null;
next: OntimeEvent | null;
followedBy: OntimeEvent | null;
};
/**
* Returns upcoming events from current: now, next and followedBy
*/
export function getUpcomingEvents(events: OntimeEvent[], selectedId: MaybeString): UpcomingEvents {
if (events.length === 0) {
return { now: null, next: null, followedBy: null };
}
const now = selectedId ? getEventWithId(events, selectedId) : getFirstEvent(events)?.firstEvent;
if (!isOntimeEvent(now)) {
return { now: null, next: null, followedBy: null };
}
const next = getNextEvent(events, now.id)?.nextEvent;
const followedBy = next ? getNextEvent(events, next.id)?.nextEvent : null;
// Return the titles, handling nulls appropriately
return {
now,
next,
followedBy,
};
}
export function getFormattedTimeToStart(event: OntimeEvent, now: number, dueText: string): string {
const timeToStart = event.timeStart - now;
if (timeToStart < 0) {
return dueText;
}
return `T - ${formatDuration(timeToStart)}`;
}
@@ -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,
},
];
};
-38
View File
@@ -1,51 +1,13 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react';
import App from './App'; import App from './App';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import './index.scss'; import './index.scss';
const container = document.getElementById('root'); const container = document.getElementById('root');
const root = createRoot(container as Element); const root = createRoot(container as Element);
// https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
const sentryRecommendedIgnore = [
// Random plugins/extensions
'top.GLOBALS',
// See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html
'originalCreateNotification',
'canvas.contentDocument',
'MyApp_RemoveAllHighlights',
'http://tt.epicplay.com',
"Can't find variable: ZiteReader",
'jigsaw is not defined',
'ComboSearch is not defined',
'http://loading.retry.widdit.com/',
'atomicFindClose',
// Facebook borked
'fb_xd_fragment',
// ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
// reduce this. (thanks @acdha)
// See http://stackoverflow.com/questions/4113268
'bmi_SafeAddOnload',
'EBCallBackMessageReceived',
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
'conduitPage',
];
Sentry.init({
dsn: 'https://5e4d2c4b57ab409cb98d4c08b2014755@o4504288369836032.ingest.sentry.io/4504288371343360',
integrations: [new Sentry.BrowserTracing()],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
root.render( root.render(
<StrictMode> <StrictMode>
<App /> <App />
+25
View File
@@ -0,0 +1,25 @@
// https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
export const sentryRecommendedIgnore = [
// Random plugins/extensions
'top.GLOBALS',
// See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html
'originalCreateNotification',
'canvas.contentDocument',
'MyApp_RemoveAllHighlights',
'http://tt.epicplay.com',
"Can't find variable: ZiteReader",
'jigsaw is not defined',
'ComboSearch is not defined',
'http://loading.retry.widdit.com/',
'atomicFindClose',
// Facebook borked
'fb_xd_fragment',
// ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
// reduce this. (thanks @acdha)
// See http://stackoverflow.com/questions/4113268
'bmi_SafeAddOnload',
'EBCallBackMessageReceived',
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
'conduitPage',
];
export const sentryDsn = 'https://5e4d2c4b57ab409cb98d4c08b2014755@o4504288369836032.ingest.sentry.io/4504288371343360';
+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;
}
@@ -7,6 +7,7 @@ $white-9: rgba(255, 255, 255, 0.09);
$white-10: rgba(255, 255, 255, 0.10); $white-10: rgba(255, 255, 255, 0.10);
$white-13: rgba(255, 255, 255, 0.13); $white-13: rgba(255, 255, 255, 0.13);
$white-20: rgba(255, 255, 255, 0.20); $white-20: rgba(255, 255, 255, 0.20);
$white-40: rgba(255, 255, 255, 0.40);
$white-60: rgba(255, 255, 255, 0.60); $white-60: rgba(255, 255, 255, 0.60);
$white-90: rgba(255, 255, 255, 0.90); $white-90: rgba(255, 255, 255, 0.90);
+1
View File
@@ -15,6 +15,7 @@ $ontime-color: #ff7597;
$error-red: $red-500; $error-red: $red-500;
$warning-orange: $orange-500; $warning-orange: $orange-500;
$opacity-disabled: 0.4; $opacity-disabled: 0.4;
$active-red: $red-700;
// playback colours // playback colours
$playback-start: $green-600; $playback-start: $green-600;
+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);
+9 -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',
@@ -17,4 +19,8 @@ export const langDe: TranslationObject = {
'countdown.to_start': 'Zeit bis zum Start', 'countdown.to_start': 'Zeit bis zum Start',
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn', 'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
'countdown.overtime': 'überfällig', 'countdown.overtime': 'überfällig',
'timeline.live': 'live',
'timeline.done': 'Beendet',
'timeline.due': 'fällig',
'timeline.followedby': 'Gefolgt von',
}; };
+8 -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',
@@ -15,6 +17,10 @@ export const langEn = {
'countdown.to_start': 'Time to start', 'countdown.to_start': 'Time to start',
'countdown.waiting': 'Waiting for event start', 'countdown.waiting': 'Waiting for event start',
'countdown.overtime': 'in overtime', 'countdown.overtime': 'in overtime',
'timeline.live': 'live',
'timeline.done': 'done',
'timeline.due': 'due',
'timeline.followedby': 'Followed by',
}; };
export type TranslationObject = Record<keyof typeof langEn, string>; export type TranslationObject = Record<keyof typeof langEn, string>;
+8 -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',
@@ -17,4 +19,8 @@ export const langEs: TranslationObject = {
'countdown.to_start': 'Tiempo para comenzar', 'countdown.to_start': 'Tiempo para comenzar',
'countdown.waiting': 'Esperando el inicio del evento', 'countdown.waiting': 'Esperando el inicio del evento',
'countdown.overtime': 'en tiempo extra', 'countdown.overtime': 'en tiempo extra',
'timeline.live': 'live',
'timeline.done': 'Terminado',
'timeline.due': 'pendiente',
'timeline.followedby': 'Seguido por',
}; };
+8 -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',
@@ -17,4 +19,8 @@ export const langFr: TranslationObject = {
'countdown.to_start': 'Évènement commence dans', 'countdown.to_start': 'Évènement commence dans',
'countdown.waiting': 'En attente du début de l’évènement', 'countdown.waiting': 'En attente du début de l’évènement',
'countdown.overtime': 'en dépassement', 'countdown.overtime': 'en dépassement',
'timeline.live': 'live',
'timeline.done': 'Terminé',
'timeline.due': 'dû',
'timeline.followedby': 'Suivi de',
}; };
+8 -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',
@@ -17,4 +19,8 @@ export const langIt: TranslationObject = {
'countdown.to_start': 'Tempo alla partenza', 'countdown.to_start': 'Tempo alla partenza',
'countdown.waiting': "In attesa dell'inizio dell'evento", 'countdown.waiting': "In attesa dell'inizio dell'evento",
'countdown.overtime': 'in ritardo', 'countdown.overtime': 'in ritardo',
'timeline.live': 'live',
'timeline.done': 'Terminato',
'timeline.due': 'previsto',
'timeline.followedby': 'Seguito da',
}; };
+8 -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å',
@@ -17,4 +19,8 @@ export const langNo: TranslationObject = {
'countdown.to_start': 'Tid til start', 'countdown.to_start': 'Tid til start',
'countdown.waiting': 'Venter på start', 'countdown.waiting': 'Venter på start',
'countdown.overtime': 'i overtiden', 'countdown.overtime': 'i overtiden',
'timeline.live': 'live',
'timeline.done': 'Ferdig',
'timeline.due': 'Venter',
'timeline.followedby': 'Etterfulgt av',
}; };
+8 -3
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en'; import { TranslationObject } from './en';
export const langPl: TranslationObject = { export const langPl: TranslationObject = {
'common.end_time': 'Czas zakończenia',
'common.expected_finish': 'Zakładany czas zakończenia', 'common.expected_finish': 'Zakładany czas zakończenia',
'common.minutes': 'min', 'common.minutes': 'min',
'common.now': 'Teraz', 'common.now': 'Teraz',
'common.next': 'Następnie', 'common.next': 'Następnie',
'common.public_message': 'Wiadomość publiczna', 'common.public_message': 'Wiadomość publiczna',
'common.start_time': 'Czas rozpoczęcia', 'common.scheduled_start': 'Planowany początek',
'common.scheduled_end': 'Planowany koniec',
'common.projected_start': 'Przewidywany początek',
'common.projected_end': 'Przewidywany koniec',
'common.stage_timer': 'Timer Scena', 'common.stage_timer': 'Timer Scena',
'common.started_at': 'Rozpoczęte o', 'common.started_at': 'Rozpoczęte o',
'common.time_now': 'Aktualny czas', 'common.time_now': 'Aktualny czas',
@@ -17,5 +19,8 @@ export const langPl: TranslationObject = {
'countdown.to_start': 'Do rozpoczęcia', 'countdown.to_start': 'Do rozpoczęcia',
'countdown.waiting': 'Oczekiwanie na start', 'countdown.waiting': 'Oczekiwanie na start',
'countdown.overtime': 'ponad czasem', 'countdown.overtime': 'ponad czasem',
'timeline.live': 'live',
'timeline.done': 'Zakończony',
'timeline.due': 'termin',
'timeline.followedby': 'Następnie',
}; };
+8 -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',
@@ -17,4 +19,8 @@ export const langPt: TranslationObject = {
'countdown.to_start': 'Tempo para iniciar', 'countdown.to_start': 'Tempo para iniciar',
'countdown.waiting': 'Aguardando o início do evento', 'countdown.waiting': 'Aguardando o início do evento',
'countdown.overtime': 'em tempo extra', 'countdown.overtime': 'em tempo extra',
'timeline.live': 'live',
'timeline.done': 'Concluído',
'timeline.due': 'Pendente',
'timeline.followedby': 'Seguido por',
}; };
+8 -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',
@@ -17,4 +19,8 @@ export const langSv: TranslationObject = {
'countdown.to_start': 'Tid till start', 'countdown.to_start': 'Tid till start',
'countdown.waiting': 'Väntar på att evenemanget ska starta', 'countdown.waiting': 'Väntar på att evenemanget ska starta',
'countdown.overtime': 'i övertid', 'countdown.overtime': 'i övertid',
'timeline.live': 'live',
'timeline.done': 'Avslutad',
'timeline.due': 'Väntande',
'timeline.followedby': 'Följt av',
}; };
+1
View File
@@ -3,6 +3,7 @@ export const navigatorConstants = [
{ url: '/clock', label: 'Clock' }, { url: '/clock', label: 'Clock' },
{ url: '/minimal', label: 'Minimal Timer' }, { url: '/minimal', label: 'Minimal Timer' },
{ url: '/backstage', label: 'Backstage' }, { url: '/backstage', label: 'Backstage' },
{ url: '/timeline', label: 'Timeline' },
{ url: '/public', label: 'Public' }, { url: '/public', label: 'Public' },
{ url: '/lower', label: 'Lower Thirds' }, { url: '/lower', label: 'Lower Thirds' },
{ url: '/studio', label: 'Studio Clock' }, { url: '/studio', label: 'Studio Clock' },
+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 *;
`, `,
}, },
}, },
+4 -1
View File
@@ -1,6 +1,7 @@
const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron'); const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron');
const path = require('path'); const path = require('path');
const electronConfig = require('./electron.config'); const electronConfig = require('./electron.config');
const { version } = require('./package.json');
const { getApplicationMenu } = require('./src/menu/applicationMenu.js'); const { getApplicationMenu } = require('./src/menu/applicationMenu.js');
const env = process.env.NODE_ENV || 'production'; const env = process.env.NODE_ENV || 'production';
@@ -187,7 +188,9 @@ app.whenReady().then(() => {
? electronConfig.reactAppUrl.production(port) ? electronConfig.reactAppUrl.production(port)
: electronConfig.reactAppUrl.development(port); : electronConfig.reactAppUrl.development(port);
const template = getApplicationMenu(isMac, askToQuit, clientUrl); const template = getApplicationMenu(isMac, askToQuit, clientUrl, `v${version}`, (path) => {
win.loadURL(`${clientUrl}/${path}`);
});
const menu = Menu.buildFromTemplate(template); const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu); Menu.setApplicationMenu(menu);
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.3.2", "version": "3.5.0-beta.2",
"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,17 @@
"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": "catalog:",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "catalog:",
"prettier": "^3.0.3" "prettier": "catalog:",
"wait-on": "^7.2.0"
}, },
"scripts": { "scripts": {
"postinstall": "", "postinstall": "",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"lint-staged": "eslint", "dev": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
"dev": "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",
+24 -9
View File
@@ -4,7 +4,7 @@ const { shell } = require('electron');
* @param {boolean} isMac - Whether the target platform is mac * @param {boolean} isMac - Whether the target platform is mac
* @param {function} askToQuit - function for quitting process * @param {function} askToQuit - function for quitting process
*/ */
function getApplicationMenu(isMac, askToQuit, urlBase) { function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow) {
return [ return [
...(isMac ...(isMac
? [ ? [
@@ -59,6 +59,19 @@ function getApplicationMenu(isMac, askToQuit, urlBase) {
{ {
label: 'Ontime Views (opens in browser)', label: 'Ontime Views (opens in browser)',
submenu: [ submenu: [
{
label: 'Public',
click: async () => {
await shell.openExternal(`${urlBase}/public`);
},
},
{
label: 'Lower Thirds',
click: async () => {
await shell.openExternal(`${urlBase}/lower`);
},
},
{ type: 'separator' },
{ {
label: 'Timer', label: 'Timer',
accelerator: 'CmdOrCtrl+V', accelerator: 'CmdOrCtrl+V',
@@ -85,15 +98,9 @@ function getApplicationMenu(isMac, askToQuit, urlBase) {
}, },
}, },
{ {
label: 'Public', label: 'Timeline',
click: async () => { click: async () => {
await shell.openExternal(`${urlBase}/public`); await shell.openExternal(`${urlBase}/timeline`);
},
},
{
label: 'Lower Thirds',
click: async () => {
await shell.openExternal(`${urlBase}/lower`);
}, },
}, },
{ {
@@ -150,6 +157,14 @@ function getApplicationMenu(isMac, askToQuit, urlBase) {
{ {
role: 'help', role: 'help',
submenu: [ submenu: [
{
label: 'About',
click: () => redirectWindow('editor?settings=about'),
},
{
label: version,
click: () => redirectWindow('editor?settings=about'),
},
{ {
label: 'See on github', label: 'See on github',
click: async () => { click: async () => {
+21 -26
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "3.3.2", "version": "3.5.0-beta.2",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -20,49 +20,44 @@
"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": "catalog:",
"@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": "catalog:",
"@typescript-eslint/parser": "^7.12.0", "@typescript-eslint/parser": "catalog:",
"esbuild": "^0.19.10", "esbuild": "^0.19.10",
"eslint": "^8.56.0", "eslint": "catalog:",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "catalog:",
"nodemon": "^2.0.20",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"prettier": "^3.3.1", "prettier": "catalog:",
"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": "catalog:",
"vitest": "^1.6.0" "vitest": "^1.6.0"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
"set:demoproject": "shx cp ../../demo-db/db.json src/preloaded-db/db.json", "postinstall": "pnpm addversion",
"set:testproject": "shx cp ../../demo-db/db.json test-db/db.json", "dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
"postinstall": "pnpm addversion && pnpm set:demoproject && pnpm set:testproject", "dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
"dev": "cross-env NODE_ENV=development nodemon --exec \"ts-node-esm\" ./src/index.ts", "dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
"dev:inspect": "cross-env NODE_ENV=development nodemon --exec \"node --inspect --loader ts-node/esm\" ./src/index.ts", "build": "esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --drop-labels=DEV --outfile=dist/index.cjs",
"dev:test": "cross-env IS_TEST=true nodemon --exec \"ts-node-esm\" ./src/index.ts", "build:electron": "esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --drop-labels=DEV --outfile=dist/index.cjs",
"prebuild": "pnpm set:demoproject", "build:local": "esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --drop-labels=DEV --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:docker": "esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --drop-labels=DEV --outfile=dist/docker.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:localdocker": "cross-env NODE_ENV=local esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --drop-labels=DEV --outfile=dist/docker.cjs",
"build:local": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs", "build:debug": "esbuild src/app.ts --platform=node --format=cjs --bundle --legal-comments=external --outfile=dist/index.cjs",
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --outfile=dist/docker.cjs",
"build:localdocker": "cross-env NODE_ENV=local pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --outfile=dist/docker.cjs",
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --legal-comments=external --outfile=dist/index.cjs",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "cross-env IS_TEST=true vitest", "test": "cross-env IS_TEST=true vitest",
"test:pipeline": "cross-env IS_TEST=true vitest run", "test:pipeline": "cross-env IS_TEST=true vitest run",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
+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);
}

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