Compare commits

..

28 Commits

Author SHA1 Message Date
cv 1b867b0dbc chore: update tests 2023-09-29 22:23:16 +02:00
cv 34b79e7699 chore: update tests 2023-09-29 22:21:15 +02:00
cv 4498a04a34 refactor: align import map to demo 2023-09-29 22:16:39 +02:00
cv d9df866308 refactor: enforce integers in numbers 2023-09-29 22:10:40 +02:00
cv 8146d88765 refactor: force refetch after mutation 2023-09-29 21:56:15 +02:00
cv d5cb2735ec Merge remote-tracking branch 'origin/master' into excel-import 2023-09-29 21:53:13 +02:00
cv 9b12afe2a6 refactor: align import map to demo 2023-09-29 21:40:22 +02:00
cv e10c0e8c97 refactor: typescript improvements 2023-09-29 21:39:50 +02:00
cv 4e9d9fc075 refactor: parse time fields 2023-09-29 21:12:40 +02:00
cv 45ece13d04 refactor: g-sheet time is object 2023-09-29 21:12:27 +02:00
cv e5e5798272 refactor: update type 2023-09-29 21:12:01 +02:00
cv cf7f6bdbf7 feat: resolve times on excel import, refs #508 2023-09-29 20:19:29 +02:00
cv a0d4f40bec refactor: small cleanups and type improvement 2023-09-28 08:15:54 +02:00
cv ba5ef6668d refactor: parse import fields 2023-09-28 08:15:24 +02:00
cv 9fc04f2fd1 chore: create feature endpoints 2023-09-27 15:09:48 +02:00
cv 13d72dd1a4 style: small presentation tweaks 2023-09-27 15:08:14 +02:00
cv 30905757f3 chore: increase size limits on uploads 2023-09-27 14:31:14 +02:00
cv e09a7d99f0 refactor: handle unexpected data types 2023-09-27 12:16:39 +02:00
cv 52ca04e063 chore: create import map utilities 2023-09-27 12:15:50 +02:00
cv b8188c7485 chore: add external deepmerge utility 2023-09-27 12:14:04 +02:00
cv 85172ae8d7 temp: handle inconsistent errors from server 2023-09-27 09:56:24 +02:00
cv cb871a8c26 refactor: simplify handling of notification 2023-09-27 09:55:18 +02:00
cv 88a853c158 refactor: convert to typescript 2023-09-17 20:55:23 +02:00
cv 99cd9bf0b7 refactor: simplify handling axios errors 2023-09-17 20:49:02 +02:00
cv 29fd145bd9 wip: create upload UI components 2023-09-17 20:48:27 +02:00
cv f6440a503f chore: upgrade deps 2023-09-17 13:16:46 +02:00
cv aa0d4103b1 style: tweaks on components 2023-09-16 14:09:49 +02:00
cv 12b051dadb refactor: rename utilities file 2023-09-16 14:09:17 +02:00
522 changed files with 14454 additions and 23775 deletions
-1
View File
@@ -1 +0,0 @@
"ONTIME_VERSION.js"
+12 -38
View File
@@ -1,5 +1,4 @@
{
"root": true,
"parserOptions": {
"ecmaVersion": 2020
},
@@ -7,46 +6,21 @@
"es6": true,
"jest": true
},
"parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"],
"plugins": ["@typescript-eslint", "prettier"],
"extends": [
"eslint:recommended"
],
"overrides": [
{
"files": ["e2e/**/**.spec.ts", "e2e/**/**.test.ts"],
"extends": ["plugin:playwright/playwright-test"]
"files": [
"e2e/**/**.spec.ts",
"e2e/**/**.test.ts"
],
"extends": [
"plugin:playwright/playwright-test"
]
}
],
"rules": {
"no-useless-concat": "warn",
"prefer-template": "warn",
"no-console": [
"warn",
{
"allow": ["warn", "error"]
}
],
"no-restricted-imports": [
"error",
{
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
}
],
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"prettier/prettier": [
"warn",
{
"endOfLine": "auto"
}
]
"no-console": "warn"
}
}
}
+7 -19
View File
@@ -16,17 +16,17 @@ jobs:
- uses: actions/checkout@v3
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Setup Node.js environment
uses: actions/setup-node@v3.6.0
with:
version: 16.16.0
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v2.2.4
with:
version: 8
version: 7.26.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -34,7 +34,7 @@ jobs:
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm turbo build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
@@ -44,8 +44,7 @@ jobs:
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Build and push stable release
if: github.event.release.prerelease == false
- name: Build and push Docker images
uses: docker/build-push-action@v4.0.0
with:
context: .
@@ -55,14 +54,3 @@ jobs:
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+9 -9
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v2.2.4
with:
version: 8
version: 7.26.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -49,12 +49,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v2.2.4
with:
version: 8
version: 7.26.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -82,12 +82,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v2.2.4
with:
version: 8
version: 7.26.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
+6 -36
View File
@@ -17,50 +17,26 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v2.2.4
with:
version: 8
version: 7.26.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Run code quality per package
- name: React - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Server - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
if: always()
run: pnpm lint
working-directory: ./packages/types
# We choose to run tests separately
- name: React - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/client
- name: Server - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/server
- name: Utils - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./packages/utils
@@ -73,12 +49,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.18.2
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v2.2.4
with:
version: 8
version: 7.26.3
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -98,9 +74,3 @@ jobs:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v3
with:
name: automated-screenshots
path: automated-screenshots/
retention-days: 14
-1
View File
@@ -27,7 +27,6 @@ ontime.code-workspace
test-results
playwright-report
/playwright/.cache/
automated-screenshots
# production
build/
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
BIN
View File
Binary file not shown.
-10
View File
@@ -1,10 +0,0 @@
build
coverage
dist
node_modules
playwright-report
**/*.toml
**/*.yml
**/*.json
-1
View File
@@ -1,5 +1,4 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
+3 -6
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup
- __node__ (~18.18.2)
- __pnpm__ (>=8)
- __node__ (>=16.16)
- __pnpm__ (>=7)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
@@ -56,8 +56,6 @@ E2E tests are in a separate package. On running, [playwright](https://playwright
webserver to test against
These tests also run against a separate version of the DB (test-db)
Before running the E2E, you should first build the project with `pnpm build:local`.
You can run playwright tests from project root with `pnpm e2e`
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually
@@ -76,7 +74,7 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo build:electron`
- __Build the UI and server__ by running `turbo build:local`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
The build distribution assets will be at `.apps/electron/dist`
@@ -89,7 +87,6 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build packages__ by running `pnpm build:localdocker`
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d`
+45 -37
View File
@@ -4,7 +4,7 @@
## Download the latest releases here
<div style="display: flex; justify-content: space-around">
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
@@ -14,11 +14,10 @@
Ontime is an application for creating and managing event running order and timers.
The user inputs a list of events along with scheduling and event information.
The user inputs a list of events along with scheduling and event information.
This will then populate a series of screens which are available to be rendered by any device in the Network.
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video
outputs.
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png)
@@ -29,7 +28,7 @@ outputs.
Once installed and running, Ontime starts a background server that is the heart of all processes.
From the app, you can add / edit your running order and control the timer playback.
Any device with a browser in the same network can choose one of the supported views to render the available data.
Any device with a browser in the same network can choose one of the supported views to render the available data.
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
or `192.168.1.3:4001`
<br />
@@ -51,9 +50,8 @@ IP.ADDRESS:4001/public > Public / Foyer view
IP.ADDRESS:4001/lower > Lower Thirds
IP.ADDRESS:4001/studio > Studio Clock
```
```
For management views
For management views
-------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
@@ -65,14 +63,14 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
- [x] Distribute data over network and render it in the browser
- [x] Different screen types
- Stage Timer
- Minimal Timer
- Clock
- Backstage Info
- Public Info
- Studio Clock
- Countdown
- [Make your own?](#make-your-own-viewer)
- Stage Timer
- Minimal Timer
- Clock
- Backstage Info
- Public Info
- Studio Clock
- Countdown
- [Make your own?](#make-your-own-viewer)
- [x] Configurable Lower Thirds
- [x] Collaborative editing with the cuesheet view
- [x] Send live messages to different screen types
@@ -85,19 +83,17 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
- [x] Roll mode: run standalone using the system clock
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
- [x] [Countdown to anything!](https://ontime.gitbook.io/v2/views/countdown): have
a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux)
a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
## Unopinionated
We want Ontime to be unique by targeting freelancers instead of roles.
We want Ontime to be unique by targeting freelancers instead of roles.
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your
many environments and workflows.
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your many environments and workflows.
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to
work.
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to work.
## Rich APIs for workflow integrations
@@ -117,8 +113,7 @@ Taking advantage of the integrations, we currently use Ontime with:
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language
that can run in the browser).
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language that can run in the browser).
<br />
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
how to get you started and read the docs about
@@ -126,11 +121,26 @@ the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-api
### Headless run
You can self-host and run Ontime in a docker image.
You can self-host and run Ontime in a docker image. The run command will:
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
- expose the necessary ports (listed in the Dockerfile)
- mount a local file to persist your data (in the example: ````$(pwd)/local-data````)
- the image name __getontime/ontime__
If you want to run this image in a Raspberry Pi, please see [the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
The docker image is
in [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
```bash
docker pull getontime/ontime
```
and use the included docker compose to get started
```bash
docker-compose up
```
Related information available [in the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
## Roadmap
@@ -138,32 +148,32 @@ If you want to run this image in a Raspberry Pi, please see [the docs](https://o
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
<br />
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
Have an idea? Reach out via [email](mail@getontime.no) or [open an issue](https://github.com/cpvalente/ontime/issues/new)
### Issues
We use Github's issue tracking for bug reporting and feature requests. <br />
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
#### Unsigned App
When installing the app you would see warning screens from the Operating System like:
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
```Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.```
or
`Ontime can't be opened because it is from an unidentified developer`
```Ontime can't be opened because it is from an unidentified developer```
or in Linux
`Could Not Display "ontime-linux.AppImage`
```Could Not Display "ontime-linux.AppImage```
You can circumvent this by allowing the execution of the app manually.
- In Windows: click more and select "Run Anyway"
- in macOS: the solution in macOS is different across versions, please refer to the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
- in macOS: after attempting to run the installer, navigate to System Preferences -> Security &
Privacy and allow the execution of the app
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
File as a Program
@@ -175,7 +185,6 @@ please [open an issue](https://github.com/cpvalente/ontime/issues/new)
#### Safari
There are known issues with Safari versions lower than 13:
- Spacing and text styles might have small inconsistencies
- Table view does not work
@@ -185,8 +194,7 @@ There is no plan for any further work on this.
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
<br /><br />
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a
Pull Request.
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a Pull Request.
<br />
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
+18 -2
View File
@@ -8,18 +8,34 @@
"browser": true,
"node": true
},
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@tanstack/eslint-plugin-query/recommended"
"plugin:@typescript-eslint/recommended",
"eslint-config-prettier",
"plugin:@tanstack/eslint-plugin-query/recommended",
"prettier"
],
"plugins": [
"react",
"testing-library",
"simple-import-sort",
"@tanstack/query"
"@tanstack/query",
"@typescript-eslint",
"prettier"
],
"rules": {
"@typescript-eslint/no-non-null-assertion": "warn",
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
],
"no-useless-concat": "warn",
"prefer-template": "warn",
"react/jsx-no-bind": [
"error",
{
-1
View File
@@ -1,5 +1,4 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
+34 -36
View File
@@ -1,35 +1,36 @@
{
"name": "ontime-ui",
"version": "3.0.0-alpha",
"version": "2.9.0",
"private": true,
"type": "module",
"dependencies": {
"@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.92.0",
"@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3",
"@sentry/react": "^7.46.0",
"@sentry/tracing": "^7.46.0",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.29.0",
"@tanstack/react-table": "^8.9.2",
"autosize": "^6.0.1",
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.4.5",
"csv-stringify": "^6.2.3",
"deepmerge": "^4.3.0",
"framer-motion": "^10.10.0",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12",
"react-fast-compare": "^3.2.0",
"react-hook-form": "^7.43.5",
"react-qr-code": "^2.0.11",
"react-router-dom": "^6.3.0",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1",
"zustand": "^4.4.7"
"zustand": "^4.3.6"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -37,15 +38,11 @@
"dev": "cross-env BROWSER=none vite",
"build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"lint": "eslint .",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
"analyse": "npx vite-bundle-visualizer"
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
},
"browserslist": {
"production": [
@@ -60,22 +57,23 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "^2.14.0",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@sentry/vite-plugin": "^0.4.0",
"@tanstack/eslint-plugin-query": "^4.26.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3",
"@types/luxon": "^3.2.0",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-prettier": "^5.0.1",
"@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1",
"@vitejs/plugin-react": "^3.0.1",
"eslint": "^8.31.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-jest": "^27.1.7",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
@@ -83,13 +81,13 @@
"jsdom": "^21.1.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "^3.0.3",
"prettier": "^2.8.3",
"sass": "^1.57.1",
"typescript": "^5.2.2",
"vite": "^5.1.0",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.2.2"
"typescript": "^4.9.4",
"vite": "^4.3.1",
"vite-plugin-compression2": "^0.9.0",
"vite-plugin-svgr": "^2.4.0",
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.30.1"
}
}
+18 -1
View File
@@ -15,7 +15,7 @@ const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withAlias(withData(TimerView));
@@ -31,20 +31,27 @@ const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
const Info = lazy(() => import('./features/info/InfoExport'));
export default function AppRouter() {
return (
<Suspense fallback={null}>
<Routes>
<Route path='/' element={<Navigate to='/timer' />} />
<Route path='/speaker' element={<STimer />} />
<Route path='/presenter' element={<STimer />} />
<Route path='/stage' element={<STimer />} />
<Route path='/timer' element={<STimer />} />
<Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/minimalTimer' element={<SMinimalTimer />} />
<Route path='/simpleTimer' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} />
<Route path='/sm' element={<SBackstage />} />
<Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
@@ -58,6 +65,8 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/cuelist' element={<Cuesheet />} />
<Route path='/table' element={<Cuesheet />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
@@ -84,6 +93,14 @@ export default function AppRouter() {
</EditorFeatureWrapper>
}
/>
<Route
path='/info'
element={
<EditorFeatureWrapper>
<Info />
</EditorFeatureWrapper>
}
/>
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</Routes>
+7 -10
View File
@@ -1,21 +1,18 @@
// REST stuff
export const ALIASES = ['aliases'];
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN_TABLE_KEY = 'rundown';
export const RUNDOWN_TABLE = [RUNDOWN_TABLE_KEY];
export const APP_INFO = ['appinfo'];
export const OSC_SETTINGS = ['oscSettings'];
export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings'];
export const RUNTIME = ['runtimeStore'];
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT;
+12 -8
View File
@@ -11,6 +11,7 @@ export function maybeAxiosError(error: unknown) {
const statusText = (error as AxiosError).response?.statusText ?? '';
let data = (error as AxiosError).response?.data ?? '';
if (typeof data === 'object') {
// TODO: use error instead, when migrated
if ('message' in data) {
data = JSON.stringify(data.message);
} else {
@@ -19,10 +20,7 @@ export function maybeAxiosError(error: unknown) {
}
return `${statusText}: ${data}`;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
return error as string;
}
}
@@ -38,9 +36,15 @@ export function logAxiosError(prepend: string, error: unknown) {
});
}
/**
* Utility function invalidates react-query caches
*/
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries();
await ontimeQueryClient.invalidateQueries([
'project',
'aliases',
'userFields',
'rundown',
'appinfo',
'oscSettings',
'appSettings',
'viewSettings',
]);
}
+2 -25
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { OntimeEvent, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { rundownURL } from './apiConstants';
@@ -7,16 +7,6 @@ import { rundownURL } from './apiConstants';
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchCachedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownURL}/cached`);
return res.data;
}
/**
* @deprecated use fetchCachedRundown instead
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchRundown(): Promise<OntimeRundown> {
const res = await axios.get(rundownURL);
return res.data;
@@ -26,7 +16,7 @@ export async function fetchRundown(): Promise<OntimeRundown> {
* @description HTTP request to post new event
* @return {Promise}
*/
export async function requestPostEvent(data: Partial<OntimeRundownEntry>) {
export async function requestPostEvent(data: OntimeRundownEntry) {
return axios.post(rundownURL, data);
}
@@ -38,19 +28,6 @@ export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
return axios.put(rundownURL, data);
}
type BatchEditEntry = {
data: Partial<OntimeEvent>;
ids: string[];
};
/**
* @description HTTP request to put multiple events
* @returns {Promise}
*/
export async function requestBatchPutEvents(data: BatchEditEntry) {
return axios.put(`${rundownURL}/batchEdit`, data);
}
export type ReorderEntry = {
eventId: string;
from: number;
+35 -187
View File
@@ -2,13 +2,10 @@ import axios, { AxiosResponse } from 'axios';
import {
Alias,
DatabaseModel,
GetInfo,
HttpSettings,
MessageResponse,
OntimeRundown,
OSCSettings,
OscSubscription,
ProjectData,
ProjectFileListResponse,
Settings,
UserFields,
ViewSettings,
@@ -16,7 +13,7 @@ import {
import { ExcelImportMap } from 'ontime-utils';
import { apiRepoLatest } from '../../externals';
import fileDownload from '../utils/fileDownload';
import { InfoType } from '../models/Info';
import { ontimeURL } from './apiConstants';
@@ -41,7 +38,7 @@ export async function postSettings(data: Settings) {
* @description HTTP request to retrieve application info
* @return {Promise}
*/
export async function getInfo(): Promise<GetInfo> {
export async function getInfo(): Promise<InfoType> {
const res = await axios.get(`${ontimeURL}/info`);
return res.data;
}
@@ -73,29 +70,13 @@ export async function getAliases(): Promise<Alias[]> {
}
/**
* @description HTTP request to create an alias
* @description HTTP request to mutate aliases
* @return {Promise}
*/
export async function postAlias(data: Alias) {
export async function postAliases(data: Alias[]) {
return axios.post(`${ontimeURL}/aliases`, data);
}
/**
* @description HTTP request to update aliases
* @return {Promise}
*/
export async function updateAliases(data: Partial<Alias>) {
return axios.put(`${ontimeURL}/aliases`, data);
}
/**
* @description HTTP request to delete alias
* @return {Promise}
*/
export async function deleteAlias(alias: string) {
return axios.delete(`${ontimeURL}/aliases/${alias}`);
}
/**
* @description HTTP request to retrieve user fields
* @return {Promise}
@@ -126,43 +107,45 @@ export async function getOSC(): Promise<OSCSettings> {
* @description HTTP request to mutate osc settings
* @return {Promise}
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
export async function postOSC(data: OSCSettings) {
return axios.post(`${ontimeURL}/osc`, data);
}
/**
* @description HTTP request to retrieve http settings
* @description HTTP request to mutate osc subscriptions
* @return {Promise}
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(`${ontimeURL}/http`);
return res.data;
export async function postOscSubscriptions(data: OscSubscription) {
return axios.post(`${ontimeURL}/osc-subscriptions`, data);
}
/**
* @description HTTP request to mutate http settings
* @description HTTP request to download db
* @return {Promise}
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(`${ontimeURL}/http`, data);
}
export const downloadRundown = async () => {
await axios({
url: `${ontimeURL}/db`,
method: 'GET',
responseType: 'blob', // important
}).then((response) => {
const headerLine = response.headers['Content-Disposition'];
let filename = 'rundown.json';
/**
* @description HTTP request to download db in CSV format
*/
export const downloadCSV = (fileName?: string) => {
return fileDownload(ontimeURL, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
};
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
filename = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
/**
* @description HTTP request to download db in JSON format
*/
export const downloadRundown = (fileName?: string) => {
return fileDownload(
ontimeURL,
{ name: fileName ?? 'rundown', type: 'json' },
{ type: 'application/json;charset=utf-8;' },
);
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
});
};
// TODO: should this be extracted to shared code?
@@ -183,6 +166,7 @@ export const uploadProjectFile = async (
formData.append('userFile', file);
const onlyRundown = Boolean(options?.onlyRundown);
console.log('debug here', onlyRundown, options);
await axios
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
@@ -208,6 +192,7 @@ export async function patchData(patchDb: Partial<DatabaseModel>) {
type PostPreviewExcelResponse = {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
};
@@ -254,143 +239,6 @@ export async function getLatestVersion(): Promise<HasUpdate> {
};
}
/**
* @description HTTP request to get the list of available project files
*/
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${ontimeURL}/projects`);
return res.data;
}
/**
* @description HTTP request to load a project file
*/
export async function loadProject(filename: string): Promise<MessageResponse> {
const res = await axios.post(`${ontimeURL}/load-project`, {
filename,
});
return res.data;
}
/**
* @description STEP 1
*/
export const uploadSheetClientFile = async (file: File) => {
const formData = new FormData();
formData.append('userFile', file);
const res = await axios
.post(`${ontimeURL}/sheet/clientsecret`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then((response) => response.data.id);
return res;
};
/**
* @description STEP 1 test
*/
export const getClientSecrect = async () => {
const response = await axios.get(`${ontimeURL}/sheet/clientsecret`);
return response.data;
};
/**
* @description STEP 2
*/
export const getSheetsAuthUrl = async () => {
const response = await axios.get(`${ontimeURL}/sheet/authentication/url`);
return response.data;
};
/**
* @description STEP 2 test
*/
export const getAuthentication = async () => {
const response = await axios.get(`${ontimeURL}/sheet/authentication`);
return response.data;
};
/**
* @description STEP 3
* @returns worksheetOptions
*/
export const postId = async (id: string) => {
const response = await axios.post(`${ontimeURL}/sheet/id`, { id });
return response.data;
};
/**
* @description STEP 4
*/
export const postWorksheet = async (id: string, worksheet: string) => {
const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { id, worksheet });
return response.data;
};
/**
* @description STEP 5
*/
export const postPreviewSheet = async (id: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet/pull`, { id, options });
return response.data.data;
};
/**
* @description STEP 5
*/
export const postPushSheet = async (id: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options });
return response.data.data;
};
/**
* @description HTTP request to rename a project file
*/
export async function renameProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}/rename`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* @description HTTP request to duplicate a project file
*/
export async function duplicateProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}/duplicate`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* @description HTTP request to delete a project file
*/
export async function deleteProject(filename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.delete(decodedUrl);
return res.data;
}
/**
* @description HTTP request to create a project file
*/
export async function createProject(
project: Partial<
ProjectData & {
filename: string;
}
>,
): Promise<MessageResponse> {
const url = `${ontimeURL}/project`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, project);
return res.data;
export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
@@ -1,12 +0,0 @@
@use "../../../theme/mixins" as *;
.link {
@include action-link;
font-size: $inner-section-text-size;
}
.linkIcon {
margin-left: $element-inner-spacing;
display: inline-block;
transform: rotate(45deg);
}
@@ -1,26 +0,0 @@
import { MouseEvent, ReactNode } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { openLink } from '../../utils/linkUtils';
import style from './AppLink.module.scss';
interface AppLinkProps {
href: string;
children: ReactNode;
}
export default function AppLink(props: AppLinkProps) {
const { href, children } = props;
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
{children} <IoArrowUp className={style.linkIcon} />
</a>
);
}
@@ -10,12 +10,14 @@ import {
IconButton,
Tooltip,
} from '@chakra-ui/react';
import { IoPowerOutline } from '@react-icons/all-files/io5/IoPowerOutline';
import { FiPower } from '@react-icons/all-files/fi/FiPower';
import { Size } from '../../models/Util.type';
import { useEmitLog } from '../../stores/logger';
interface QuitIconBtnProps {
clickHandler: () => void;
size?: Size;
disabled?: boolean;
}
@@ -40,7 +42,7 @@ const quitBtnStyle = {
};
export default function QuitIconBtn(props: QuitIconBtnProps) {
const { clickHandler, disabled, ...rest } = props;
const { clickHandler, size = 'lg', disabled, ...rest } = props;
const [isOpen, setIsOpen] = useState(false);
const { emitInfo } = useEmitLog();
const onClose = () => setIsOpen(false);
@@ -65,8 +67,8 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<Tooltip label='Quit Application'>
<IconButton
aria-label='Quit Application'
size='lg'
icon={<IoPowerOutline />}
size={size}
icon={<FiPower />}
onClick={() => setIsOpen(true)}
isDisabled={disabled}
{...quitBtnStyle}
@@ -79,7 +81,7 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
Ontime Shutdown
</AlertDialogHeader>
<AlertDialogBody>This will shutdown the Ontime server. Are you sure?</AlertDialogBody>
<AlertDialogBody>This will shutdown the program and all running servers. Are you sure?</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
Cancel
@@ -2,7 +2,7 @@ import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: (event: MouseEvent) => void | Promise<void>;
clickHandler: (event?: MouseEvent) => void | Promise<void>;
tooltip: string;
openDelay?: number;
}
@@ -0,0 +1,24 @@
@use '../../../theme/v2Styles' as *;
.header {
font-size: $inner-section-text-size;
font-weight: 600;
display: flex;
justify-content: space-between;
color: $section-white;
margin-top: $main-spacing;
border-bottom: 1px solid $border-color-ondark;
padding-bottom: $element-inner-spacing;
margin-bottom: $element-spacing;
cursor: pointer;
}
.moreExpanded {
transform: scaleY(-1);
transition: transform $transition-time-feedback;
}
.moreCollapsed {
transform: scaleY(1);
transition: transform $transition-time-feedback;
}
@@ -0,0 +1,20 @@
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import style from './CollapseBar.module.scss';
interface CollapseBarProps {
title: string;
isCollapsed: boolean;
onClick: () => void;
}
export default function CollapseBar(props: CollapseBarProps) {
const { title = 'Collapse bar', isCollapsed, onClick } = props;
return (
<div className={style.header} onClick={onClick}>
{title}
<FiChevronUp className={isCollapsed ? style.moreCollapsed : style.moreExpanded} />
</div>
);
}
@@ -1,5 +1,6 @@
.contextMenuButton {
position: absolute;
cursor: pointer;
}
.contextMenuBackdrop {
@@ -1,13 +1,11 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { ReactElement } from 'react';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { Fragment, ReactElement } from 'react';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
@@ -15,23 +13,14 @@ type ContextMenuCoords = {
y: number;
};
export type OptionWithoutGroup = {
export type Option = {
label: string;
isDisabled?: boolean;
icon: IconType;
onClick: () => void;
withDivider?: boolean;
isDisabled?: boolean;
};
export type OptionWithGroup = {
label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[];
};
export type Option = OptionWithoutGroup | OptionWithGroup;
const isOptionWithGroup = (option: Option): option is OptionWithGroup => 'group' in option;
type ContextMenuStore = {
coords: ContextMenuCoords;
options: Option[];
@@ -80,17 +69,14 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
}}
/>
<MenuList>
{options.map((option) =>
isOptionWithGroup(option) ? (
<MenuGroup key={option.label} title={option.label}>
{option.group.map((groupOption) => (
<ContextMenuOption key={groupOption.label} {...groupOption} />
))}
</MenuGroup>
) : (
<ContextMenuOption key={option.label} {...option} />
),
)}
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
<Fragment key={label}>
{withDivider && <MenuDivider />}
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</Fragment>
))}
</MenuList>
</Menu>
</>
@@ -1,12 +0,0 @@
import { MenuDivider, MenuItem } from '@chakra-ui/react';
import { OptionWithoutGroup } from './ContextMenu';
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
<>
{withDivider && <MenuDivider />}
<MenuItem icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
);
@@ -1,3 +1,5 @@
@use '../../../theme/v2Styles' as *;
.delaySymbol {
svg {
font-size: 1.5rem;
@@ -17,7 +17,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
@@ -27,7 +27,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
@@ -3,7 +3,7 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react';
import { runtimeStore } from '@/common/stores/runtime';
import { runtime } from '@/common/stores/runtime';
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
import style from './ErrorBoundary.module.scss';
@@ -23,13 +23,13 @@ class ErrorBoundary extends React.Component {
componentDidCatch(error, info) {
this.setState({
error,
error: error,
errorInfo: info,
});
Sentry.withScope((scope) => {
scope.setExtras('error', error);
scope.setExtras('store', runtimeStore.getState());
scope.setExtras('store', runtime.getState());
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
@@ -1,3 +1,5 @@
@use '../../../theme/v2Styles' as *;
.errorContainer {
width: 100%;
height: 100%;
@@ -1,17 +0,0 @@
.link {
display: flex;
align-items: center;
gap: 0.25rem;
color: $blue-500;
transition-property: color;
transition-duration: $transition-time-action;
&.inline {
display: inline-flex;
}
&:hover {
color: $ontime-color;
}
}
@@ -1,29 +0,0 @@
import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import style from './ExternalLink.module.scss';
interface ExternalLinkProps {
href: string;
children: ReactNode;
inline?: boolean;
}
export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props;
const classes = cx([style.link, inline ? style.inline : null]);
const handleClick = (event: MouseEvent) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
{children} <IoOpenOutline />
</a>
);
}
@@ -4,8 +4,6 @@ import { Textarea, TextareaProps } from '@chakra-ui/react';
import autosize from 'autosize/dist/autosize';
export const AutoTextArea = (props: TextareaProps) => {
const { value } = props;
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
@@ -14,7 +12,7 @@ export const AutoTextArea = (props: TextareaProps) => {
return () => {
autosize.destroy(node);
};
}, [value]);
}, []);
return (
<Textarea
@@ -1,24 +1,24 @@
.list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem
gap: 4px;
}
.swatch {
cursor: pointer;
aspect-ratio: 1;
width: 2rem;
height: 2rem;
aspect-ratio: 1;
border-radius: 99px;
border: 2px solid $gray-1200;
border-radius: 16px;
border: 4px solid #262626;
&.selected {
border: 2px solid $blue-500;
border: 2px solid #578AF4;
}
}
.center {
display: grid;
place-content: center;
color: $ui-white;
}
color: #578AF4;
}
@@ -1,13 +1,15 @@
import { useCallback } from 'react';
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
import Swatch from './Swatch';
import style from './SwatchSelect.module.scss';
interface ColourInputProps {
value: string;
name: 'colour';
handleChange: (newValue: 'colour', name: string) => void;
name: EditorUpdateFields;
handleChange: (newValue: EditorUpdateFields, name: string) => void;
}
const colours = [
@@ -1,3 +1,5 @@
@use '../../../../theme/v2Styles' as *;
$input-font-size: 15px;
.delayInput {
@@ -1,3 +1,6 @@
@use "../../../../theme/_ontimeColours" as *;
@use "../../../../theme/_v2Styles" as *;
.swatch {
width: 24px;
height: 24px;
@@ -1,7 +1,7 @@
import { useCallback, useRef } from 'react';
import { Input, Textarea } from '@chakra-ui/react';
import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditor';
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor';
import { Size } from '../../../models/Util.type';
import useReactiveTextInput from './useReactiveTextInput';
@@ -19,11 +19,9 @@ interface TextInputProps extends BaseProps {
isTextArea?: false;
}
type ResizeOptions = 'horizontal' | 'vertical' | 'none';
interface TextAreaProps extends BaseProps {
isTextArea: true;
resize?: ResizeOptions;
resize?: 'horizontal' | 'vertical' | 'none';
}
type InputProps = TextInputProps | TextAreaProps;
@@ -37,7 +35,7 @@ export default function TextInput(props: InputProps) {
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
let resize: ResizeOptions = 'none';
let resize = 'none';
if (isTextArea) {
resize = (props as TextAreaProps)?.resize ?? 'none';
}
@@ -1,4 +1,37 @@
@use "../../../../theme/v2Styles" as *;
$input-font-size: 15px;
$input-delayed-border-color: #E69056;
.timeInput {
width: fit-content !important;
.inputLeft {
max-width: fit-content;
}
.inputLeft,
.inputButton {
aspect-ratio: 1;
}
.inputField {
font-size: $input-font-size;
letter-spacing: 1px;
width: 6.5em;
width: 7.5em;
padding: 0 0 0 2.6em;
}
.warn {
&::after {
content: "*";
color: $warning-orange;
}
}
&.delayed {
.inputField {
border: 1px solid $input-delayed-border-color;
}
}
}
@@ -1,23 +1,53 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import { useEmitLog } from '../../../stores/logger';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import { cx } from '../../../utils/styleUtils';
import { TimeEntryField } from '../../../utils/timesManager';
import style from './TimeInput.module.scss';
interface TimeInputProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
interface TimeInputProps {
id?: TimeEntryField;
name: TimeEntryField;
submitHandler: (field: TimeEntryField, value: number) => void;
time?: number;
delay?: number;
placeholder: string;
disabled?: boolean;
className?: string;
validationHandler: (entry: TimeEntryField, val: number) => boolean;
previousEnd?: number;
warning?: string;
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
function ButtonInitial(name: TimeEntryField) {
if (name === 'timeStart') return 'S';
if (name === 'timeEnd') return 'E';
if (name === 'durationOverride') return 'D';
return '';
}
function ButtonTooltip(name: TimeEntryField, warning?: string) {
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`;
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`;
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`;
return '';
}
export default function TimeInput(props: TimeInputProps) {
const {
id,
name,
submitHandler,
time = 0,
delay = 0,
placeholder,
validationHandler,
previousEnd = 0,
warning,
} = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -53,20 +83,35 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
return false;
}
// we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) {
submitHandler(name, newValue);
let newValMillis = 0;
// check for known aliases
if (newValue === 'p' || newValue === 'prev' || newValue === 'previous') {
// string to pass should be the time of the end before
if (previousEnd != null) {
newValMillis = previousEnd;
}
} else if (newValue.startsWith('+') || newValue.startsWith('p+') || newValue.startsWith('p +')) {
// string to pass should add to the end before
const val = newValue.substring(1);
newValMillis = previousEnd + forgivingStringToMillis(val);
} else {
// convert entered value to milliseconds
newValMillis = forgivingStringToMillis(newValue);
}
const valueInMillis = forgivingStringToMillis(newValue);
if (valueInMillis === time) {
return false;
}
// check if time is different from before
if (newValMillis === time) return false;
// validate with parent
if (!validationHandler(name, newValMillis)) return false;
// update entry
submitHandler(name, newValMillis);
submitHandler(name, newValue);
return true;
},
[name, submitHandler, time],
[name, previousEnd, submitHandler, time, validationHandler],
);
/**
@@ -76,11 +121,15 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const validateAndSubmit = useCallback(
(newValue: string) => {
const success = handleSubmit(newValue);
if (!success) {
if (success) {
const ms = forgivingStringToMillis(newValue);
const delayed = name === 'timeEnd' ? Math.max(0, ms + delay) : Math.max(0, ms + delay);
setValue(millisToString(delayed));
} else {
resetValue();
}
},
[handleSubmit, resetValue],
[delay, handleSubmit, name, resetValue],
);
/**
@@ -120,26 +169,50 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
resetValue();
}, [resetValue, time]);
const timeInputClasses = cx([style.timeInput, className]);
const isDelayed = delay !== 0;
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null, warning ? style.warn : null]);
const TooltipLabel = useMemo(() => {
return ButtonTooltip(name, warning);
}, [name, warning]);
const ButtonText = useMemo(() => {
return ButtonInitial(name);
}, [name]);
return (
<Input
disabled={disabled}
size='sm'
ref={inputRef}
data-testid={`time-input-${name}`}
className={timeInputClasses}
fontSize='1rem'
type='text'
placeholder={placeholder}
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
/>
<InputGroup size='sm' className={inputClasses}>
<InputLeftElement className={style.inputLeft}>
<Tooltip label={TooltipLabel} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<Button
size='sm'
variant='ontime-subtle-white'
className={buttonClasses}
tabIndex={-1}
border={isDelayed ? '1px solid #E69056' : '1px solid transparent'}
borderRight='1px solid transparent'
borderRadius='2px 0 0 2px'
>
{ButtonText}
</Button>
</Tooltip>
</InputLeftElement>
<Input
ref={inputRef}
id={id}
data-testid={`time-input-${name}`}
className={style.inputField}
type='text'
placeholder={placeholder}
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
/>
</InputGroup>
);
}
@@ -1,12 +0,0 @@
$input-delayed-border-color: $ontime-delay-text;
.timeInput {
border: 1px solid transparent;
&.delayed {
border: 1px solid $input-delayed-border-color;
}
}
.inputField {
max-width: 7.75em;
}
@@ -1,40 +0,0 @@
import { PropsWithChildren } from 'react';
import { InputGroup, Tooltip } from '@chakra-ui/react';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import { cx } from '../../../utils/styleUtils';
import TimeInput from './TimeInput';
import style from './TimeInputWithButton.module.scss';
interface TimeInputWithButtonProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
hasDelay?: boolean;
disabled?: boolean;
placeholder: string;
}
export default function TimeInputWithButton<T extends string>(props: PropsWithChildren<TimeInputWithButtonProps<T>>) {
const { name, submitHandler, time, hasDelay, placeholder, disabled, children } = props;
const inputClasses = cx([style.timeInput, hasDelay ? style.delayed : null]);
return (
<InputGroup size='sm' className={inputClasses} width='fit-content'>
<Tooltip label={placeholder} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<TimeInput<T>
name={name}
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
className={style.inputField}
disabled={disabled}
/>
</Tooltip>
{children}
</InputGroup>
);
}
@@ -10,7 +10,6 @@ $progress-bar-br: 3px;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
display: flex;
overflow: hidden;
&--hidden {
display: none;
@@ -32,6 +31,7 @@ $progress-bar-br: 3px;
position: absolute;
height: inherit;
right: 0;
border-radius: $progress-bar-br;
width: 100%;
}
@@ -39,10 +39,12 @@ $progress-bar-br: 3px;
position: absolute;
height: inherit;
right: 0;
border-radius: 0 $progress-bar-br $progress-bar-br 0;
}
.multiprogress-bar__bg-danger {
position: absolute;
height: inherit;
right: 0;
border-radius: 0 $progress-bar-br $progress-bar-br 0;
}
@@ -1,16 +1,14 @@
import { MaybeNumber } from 'ontime-types';
import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: MaybeNumber;
now: number;
complete: number;
normalColor: string;
warning?: MaybeNumber;
warning: number;
warningColor: string;
danger?: MaybeNumber;
danger: number;
dangerColor: string;
hidden?: boolean;
className?: string;
@@ -19,27 +17,17 @@ interface MultiPartProgressBar {
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const percentComplete = 100 - clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
const dangerWidth = clamp((danger / complete) * 100, 0, 100);
const warningWidth = clamp((warning / complete) * 100, 0, 100);
return (
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
{now !== null && (
<>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div
className='multiprogress-bar__bg-warning'
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</>
)}
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div className='multiprogress-bar__bg-warning' style={{ width: `${warningWidth}%`, backgroundColor: warningColor }} />
<div className='multiprogress-bar__bg-danger' style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }} />
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</div>
);
}
@@ -1,4 +1,6 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/mixins' as *;
@use '../../../theme/ontimeColours' as *;
$menu-bg: $gray-1200;
$menu-hover-bg: $gray-1350;
@@ -8,6 +10,10 @@ $icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 48px;
.mirror {
transform: rotate(180deg);
}
.buttonContainer {
display: flex;
flex-direction: column;
@@ -69,6 +75,7 @@ $button-size: 48px;
@include action-link;
justify-content: space-between;
padding: 0.5rem 1rem;
cursor: pointer;
&:hover {
background-color: $menu-hover-bg;
@@ -1,4 +1,4 @@
import { memo, useEffect, useRef, useState } from 'react';
import { KeyboardEvent, memo, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
@@ -12,8 +12,8 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import RenameClientModal from './rename-client-modal/RenameClientModal';
@@ -23,19 +23,18 @@ function NavigationMenu() {
const location = useLocation();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
const { mirror, toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false));
const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev);
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen });
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
@@ -54,6 +53,7 @@ function NavigationMenu() {
};
}, []);
const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter';
const handleFullscreen = () => toggleFullScreen();
const handleMirror = () => toggleMirror();
@@ -63,7 +63,7 @@ function NavigationMenu() {
};
return createPortal(
<div id='navigation-menu-portal' ref={menuRef}>
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}>
<RenameClientModal isOpen={isOpen} onClose={onClose} />
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
@@ -30,7 +30,7 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
const handleRename = async () => {
if (newName) {
setClientName(newName);
await setClientName(newName);
persistName(newName);
onClose();
}
@@ -1,3 +1,5 @@
@use '../../../theme/v2Styles' as *;
.container {
display: grid;
place-content: center;
@@ -1,3 +1,5 @@
import Empty from '../state/Empty';
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
@@ -11,9 +13,8 @@ interface ScheduleProps {
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
// TODO: design a nice placeholder for empty schedules
if (paginatedEvents?.length < 1) {
return null;
return <Empty text='No events to show' />;
}
let selectedState: 'past' | 'now' | 'future' = 'past';
@@ -1,9 +1,7 @@
import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent } from 'ontime-types';
import { useInterval } from '../../hooks/useInterval';
import { isStringBoolean } from '../../utils/viewUtils';
interface ScheduleContextState {
events: OntimeEvent[];
@@ -21,6 +19,7 @@ interface ScheduleProviderProps {
events: OntimeEvent[];
selectedEventId: string | null;
isBackstage?: boolean;
eventsPerPage?: number;
time?: number;
}
@@ -29,29 +28,16 @@ export const ScheduleProvider = ({
events,
selectedEventId,
isBackstage = false,
eventsPerPage = 7,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
const [searchParams] = useSearchParams();
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? 7);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
const viewEvents = [...events];
if (hidePast) {
// we want to show the event after the next
viewEvents.splice(0, selectedEventIndex + 2);
selectedEventIndex = 0;
}
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
const numPages = Math.ceil(events.length / eventsPerPage);
const eventStart = eventsPerPage * visiblePage;
const eventEnd = eventsPerPage * (visiblePage + 1);
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
const paginatedEvents = events.slice(eventStart, eventEnd);
const selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
const resolveScheduleType = () => {
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
@@ -66,9 +52,7 @@ export const ScheduleProvider = ({
// every SCROLL_TIME go to the next array
useInterval(() => {
if (stopCycle) {
setVisiblePage(0);
} else if (events.length > eventsPerPage) {
if (events.length > eventsPerPage) {
const next = (visiblePage + 1) % numPages;
setVisiblePage(next);
}
@@ -1,13 +1,7 @@
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { formatTime } from '../../utils/time';
import './Schedule.scss';
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
interface ScheduleItemProps {
selected: 'past' | 'now' | 'future';
timeStart: number;
@@ -20,10 +14,19 @@ interface ScheduleItemProps {
}
export default function ScheduleItem(props: ScheduleItemProps) {
const { selected, timeStart, timeEnd, title, presenter, backstageEvent, colour, skip } = props;
const {
selected,
timeStart,
timeEnd,
title,
presenter,
backstageEvent,
colour,
skip,
} = props;
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
const start = formatTime(timeStart, { format: 'hh:mm' });
const end = formatTime(timeEnd, { format: 'hh:mm' });
const userColour = colour !== '' ? colour : '';
const selectStyle = `entry--${selected}`;
@@ -31,15 +34,12 @@ export default function ScheduleItem(props: ScheduleItemProps) {
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: userColour }} />
<div style={{ display: 'flex' }}>
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent ? '*' : ''}
</div>
{`${start}${end} ${backstageEvent ? '*' : ''}`}
</div>
<div className='entry-title'>{title}</div>
{presenter && <div className='entry-presenter'>{presenter}</div>}
{presenter && (
<div className='entry-presenter'>{presenter}</div>
)}
</li>
);
}
@@ -13,11 +13,12 @@ export default function ScheduleNav({ className }: ScheduleNavProps) {
<div className={`schedule-nav ${className}`}>
{numPages > 1 &&
[...Array(numPages).keys()].map((i) => (
<div
key={i}
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
/>
))}
<div
key={i}
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
/>
),
)}
</div>
);
}
@@ -1,3 +1,5 @@
@use '../../../theme/ontimeColours' as *;
.emptyContainer {
width: 100%;
text-align: center;
@@ -1,6 +1,6 @@
import { CSSProperties } from 'react';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
import style from './Empty.module.scss';
@@ -13,7 +13,7 @@ export default function Empty(props: EmptyProps) {
const { text, ...rest } = props;
return (
<div className={style.emptyContainer} {...rest}>
<EmptyImage className={style.empty} />
<Emptyimage className={style.empty} />
<span className={style.text}>{text}</span>
</div>
);
@@ -1,11 +1,6 @@
@use '../../../../theme/viewerDefs' as *;
@use '../../../theme/viewerDefs' as *;
.timer {
grid-area: clk;
white-space: nowrap;
max-width: 18.75rem;
font-family: var(--font-family-override, $viewer-font-family);
color: var(--timer-color-override, $timer-color);
line-height: 0.9em;
@@ -14,7 +9,7 @@
font-weight: 600;
font-size: 3.5rem;
&.finished {
&--finished {
color: $timer-finished-color;
}
}
@@ -0,0 +1,33 @@
import { memo } from 'react';
import { formatDisplay } from 'ontime-utils';
import './TimerDisplay.scss';
interface TimerDisplayProps {
time?: number | null;
}
/**
* Displays time in ms in formatted timetag
* @param props
* @constructor
*/
const TimerDisplay = (props: TimerDisplayProps) => {
const { time } = props;
let display = '';
if (time === null || typeof time === 'undefined' || isNaN(time)) {
display = '-- : -- : --';
} else {
display = formatDisplay(time);
}
const isNegative = (time ?? 0) < 0;
const classes = `timer ${isNegative ? 'timer--finished' : ''}`;
return <div className={classes}>{display}</div>;
};
export default memo(TimerDisplay);
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router-dom';
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
import { Input, Select, Switch } from '@chakra-ui/react';
import { isStringBoolean } from '../../utils/viewUtils';
@@ -9,22 +9,16 @@ interface EditFormInputProps {
paramField: ParamField;
}
export default function ParamInput(props: EditFormInputProps) {
export default function ParamInput({ paramField }: EditFormInputProps) {
const [searchParams] = useSearchParams();
const { paramField } = props;
const { id, type, defaultValue } = paramField;
const { id, type } = paramField;
if (type === 'option') {
const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || defaultValue;
const defaultOptionValue = optionFromParams || undefined;
return (
<Select
placeholder={defaultValue ? undefined : 'Select an option'}
variant='ontime'
name={id}
defaultValue={defaultOptionValue}
>
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}>
{Object.entries(paramField.values).map(([key, value]) => (
<option key={key} value={key}>
{value}
@@ -35,38 +29,19 @@ export default function ParamInput(props: EditFormInputProps) {
}
if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) ?? false;
// checked value should be 'true', so it can be captured by the form event
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
}
if (type === 'number') {
const { prefix, placeholder } = paramField;
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
const defaultNumberValue = searchParams.get(id) ?? '';
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input
type='number'
step='any'
variant='ontime-filled'
name={id}
defaultValue={defaultNumberValue}
placeholder={placeholder}
/>
</InputGroup>
);
return <Input type='number' step='any' variant='ontime-filled' name={id} defaultValue={defaultNumberValue} />;
}
const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { prefix, placeholder } = paramField;
const defaultStringValue = searchParams.get(id) ?? '';
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input name={id} defaultValue={defaultStringValue} placeholder={placeholder} />
</InputGroup>
);
return <Input variant='ontime-filled' name={id} defaultValue={defaultStringValue} />;
}
@@ -1,3 +1,6 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
.drawerContent {
background-color: $gray-1250;
}
@@ -35,6 +38,12 @@
gap: $element-inner-spacing;
}
.noHover {
&:hover {
background-color: inherit;
}
}
.title {
font-size: $inner-section-text-size;
display: block;
@@ -1,5 +1,5 @@
import { FormEvent, useEffect } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Drawer,
@@ -12,34 +12,11 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import ParamInput from './ParamInput';
import { ParamField } from './types';
import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>;
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
return { ...acc, [id]: String(defaultValue) };
}, {});
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) {
if (defaultValues[id] === value) {
return newSearchParams;
}
newSearchParams.set(id, value);
return newSearchParams;
}
return newSearchParams;
}, new URLSearchParams());
};
interface EditFormDrawerProps {
paramFields: ParamField[];
}
@@ -47,8 +24,6 @@ interface EditFormDrawerProps {
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure();
const { pathname } = useLocation();
const [storedViewParams, setStoredViewParams] = useLocalStorage<SavedViewParams>('ontime-views', {});
useEffect(() => {
const isEditing = searchParams.get('edit');
@@ -58,51 +33,36 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
}
}, [searchParams, onOpen]);
/**
* disabling this for now, this feature needs more testing
* - we seem to have a bug where this is conflicting with the aliases
* - I wonder if the logic below needs to be inside an effect,
* both localStorage and searchParams should trigger a component update when they change
useEffect(() => {
const viewParamsObjFromLocalStorage = storedViewParams[pathname];
if (viewParamsObjFromLocalStorage !== undefined) {
const defaultSearchParams = getURLSearchParamsFromObj(viewParamsObjFromLocalStorage);
setSearchParams(defaultSearchParams);
}
// linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps
// rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
*/
const onCloseWithoutSaving = () => {
const onEditDrawerClose = () => {
onClose();
searchParams.delete('edit');
setSearchParams(searchParams);
};
const resetParams = () => {
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
const clearParams = () => {
setSearchParams();
onClose();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
formEvent.preventDefault();
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
const newSearchParams = Object.entries(newParamsObject).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) {
newSearchParams.set(id, value);
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
return newSearchParams;
}
return newSearchParams;
}, new URLSearchParams());
setSearchParams(newSearchParams);
};
return (
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
<Drawer isOpen={isOpen} placement='right' onClose={onEditDrawerClose} size='lg'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader className={style.drawerHeader}>
@@ -125,10 +85,10 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
</DrawerBody>
<DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Reset
<Button variant='ontime-ghosted' onClick={clearParams} type='reset'>
Clear
</Button>
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
<Button variant='ontime-subtle' onClick={onEditDrawerClose}>
Cancel
</Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
@@ -2,64 +2,45 @@ import { UserFields } from 'ontime-types';
import { ParamField } from './types';
const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`;
return {
id: 'timeformat',
title: 'Time format string, taken from the Application Settings',
description: 'Format for auxiliar time fields (not the running), eg. HH:mm:ss or hh:mm:ss a, see docs for help',
type: 'string',
placeholder,
};
export const TIME_FORMAT_OPTION: ParamField = {
id: 'format',
title: '12 / 24 hour timer',
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
type: 'option',
values: { '12': '12 hour AM/PM', '24': '24 hour' },
};
const hideTimerSeconds: ParamField = {
id: 'hideTimerSeconds',
title: 'Hide seconds in timer',
description: 'Whether to hide seconds in the running timer',
type: 'boolean',
defaultValue: false,
};
export const getClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
export const CLOCK_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
{
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',
@@ -67,14 +48,12 @@ export const getClockOptions = (timeFormat: string): ParamField[] => [
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',
@@ -82,96 +61,47 @@ export const getClockOptions = (timeFormat: string): ParamField[] => [
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): ParamField[] => [
getTimeOption(timeFormat),
hideTimerSeconds,
{
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,
},
];
export const TIMER_OPTIONS: ParamField[] = [TIME_FORMAT_OPTION];
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',
@@ -179,14 +109,12 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
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',
@@ -194,225 +122,102 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
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)',
description: 'Whether to supress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidemessages',
title: 'Hide Message Overlay',
description: 'Whether to hide the overlay from showing timer screen messages',
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 LOWER_THIRD_OPTIONS: ParamField[] = [
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'event',
},
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
lowerMsg: 'Lower Thrid Message',
},
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the text source for the bottom element',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
lowerMsg: 'Lower Thrid Message',
},
defaultValue: 'subtitle',
},
{
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',
id: 'preset',
title: 'Preset',
description: 'Selects a style preset (0-1)',
type: 'number',
},
{
id: 'size',
title: 'Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
description: 'Transition in time in seconds (default 5)',
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: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
type: 'string',
},
{
id: 'bg',
title: 'Text Background',
description: 'Text background colour in hexadecimal',
type: 'string',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
description: 'Screen background colour in hexadecimal',
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): ParamField[] => [
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 overlow',
id: 'fadeout',
title: 'Fadeout',
description: 'Time (in seconds) the lower third displays before fading out',
type: 'number',
placeholder: '7 (default)',
},
];
export const getPublicOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
id: 'seconds',
title: 'Show Seconds',
description: 'Shows seconds in clock',
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 overlow',
type: 'number',
placeholder: '7 (default)',
},
];
export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
hideTimerSeconds,
];
export const getOperatorOptions = (userFields: UserFields, timeFormat: string): ParamField[] => {
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
return [
getTimeOption(timeFormat),
TIME_FORMAT_OPTION,
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
},
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
@@ -454,14 +259,5 @@ export const getOperatorOptions = (userFields: UserFields, timeFormat: string):
user9: userFields.user9 || 'user9',
},
},
{
id: 'shouldEdit',
title: 'Edit user field',
description: 'Allows editing an events user field by long pressing on it. Needs a selected highlighted field',
type: 'boolean',
defaultValue: false,
},
];
};
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
@@ -4,13 +4,9 @@ type BaseField = {
description: string;
};
type OptionsField = {
type: 'option';
values: Record<string, string>;
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean };
type OptionsField = { type: 'option'; values: Record<string, string> };
type StringField = { type: 'string' };
type BooleanField = { type: 'boolean' };
type NumberField = { type: 'number' };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
@@ -25,7 +25,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
const [operatorAuth, setOperatorAuth] = useState(true);
useEffect(() => {
if (status === 'pending') return;
if (status === 'loading') return;
if (!data) return;
const previousEditor = sessionStorage.getItem(storageKeys.editor);
@@ -1,35 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getHTTP, postHTTP } from '../api/ontimeApi';
import { httpPlaceholder } from '../models/Http';
import { ontimeQueryClient } from '../queryClient';
export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: httpPlaceholder,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
}
export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
});
return { isPending, mutateAsync };
}
@@ -1,5 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { GetInfo } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_INFO } from '../api/apiConstants';
@@ -7,7 +6,7 @@ import { getInfo } from '../api/ontimeApi';
import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
const { data, status, isError, refetch } = useQuery({
queryKey: APP_INFO,
queryFn: getInfo,
placeholderData: ontimePlaceholderInfo,
@@ -17,5 +16,5 @@ export default function useInfo() {
networkMode: 'always',
});
return { data, status, isError, refetch, isFetching };
return { data, status, isError, refetch };
}
@@ -1,9 +1,10 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { OSCSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getOSC, postOSC } from '../api/ontimeApi';
import { getOSC, postOSC, postOscSubscriptions } from '../api/ontimeApi';
import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient';
@@ -18,17 +19,25 @@ export default function useOscSettings() {
networkMode: 'always',
});
return { data: data ?? oscPlaceholderSettings, status, isFetching, isError, refetch };
// we need to jump through some hoops because of the type op port
return { data: data! as unknown as OSCSettings, status, isFetching, isError, refetch };
}
export function useOscSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
const { isLoading, mutateAsync } = useMutation({
mutationFn: postOSC,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
},
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isPending, mutateAsync };
return { isLoading, mutateAsync };
}
export function usePostOscSubscriptions() {
const { isLoading, mutateAsync } = useMutation({
mutationFn: postOscSubscriptions,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isLoading, mutateAsync };
}
@@ -16,5 +16,5 @@ export default function useProjectData() {
networkMode: 'always',
});
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
return { data, status, isFetching, isError, refetch };
}
@@ -1,24 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/apiConstants';
import { getProjects } from '../api/ontimeApi';
const placeholderProjectList: ProjectFileListResponse = {
files: [],
lastLoadedProject: '',
};
export function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: getProjects,
placeholderData: placeholderProjectList,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? placeholderProjectList, status, refetch };
}
@@ -1,41 +1,19 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN } from '../api/apiConstants';
import { fetchCachedRundown } from '../api/eventsApi';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
import { RUNDOWN_TABLE } from '../api/apiConstants';
import { fetchRundown } from '../api/eventsApi';
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
queryFn: fetchCachedRundown,
placeholderData: cachedRundownPlaceholder,
const { data, status, isError, refetch } = useQuery({
queryKey: RUNDOWN_TABLE,
queryFn: fetchRundown,
placeholderData: [],
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
export function useFlatRundown() {
const { data, status } = useRundown();
const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.rundown[id]);
setFlatRunDown(flatRundown);
setPrevRevision(data.revision);
}
}, [data.order, data.revision, data.rundown, prevRevision]);
return { data: flatRunDown, status };
return { data, status, isError, refetch };
}
@@ -6,6 +6,7 @@ export default function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
callback: ClickOutsideEventHandler,
) {
useEffect(() => {
function handleClick(event: MouseEvent) {
const element = ref?.current;
@@ -1,7 +1,7 @@
export default function useElectronEvent() {
const isElectron = window?.process?.type === 'renderer';
const sendToElectron = (channel: string, args?: string | Record<string, unknown>) => {
const sendToElectron = (channel: string, args?: string | Record<string, any>) => {
if (isElectron) {
window?.ipcRenderer.send(channel, args);
}
+69 -232
View File
@@ -1,14 +1,13 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
import { isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
import { RUNDOWN } from '../api/apiConstants';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import {
ReorderEntry,
requestApplyDelay,
requestBatchPutEvents,
requestDelete,
requestDeleteAll,
requestEventSwap,
@@ -18,7 +17,6 @@ import {
SwapEntry,
} from '../api/eventsApi';
import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
/**
* @description Set of utilities for events
@@ -34,9 +32,11 @@ export const useEventAction = () => {
* @private
*/
const _addEventMutation = useMutation({
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
mutationFn: requestPostEvent,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
@@ -67,13 +67,20 @@ export const useEventAction = () => {
after: options?.after,
};
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this has a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const { rundown } = rundownData;
if (newEvent?.cue === undefined) {
newEvent.cue = getCueCandidate(queryClient.getQueryData(RUNDOWN_TABLE) || [], options?.after);
}
// hard coding duration value to be as expected for now
// this until timeOptions gets implemented
if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) {
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
}
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
if (previousEvent !== undefined && previousEvent.type === 'event') {
newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd;
}
@@ -90,9 +97,10 @@ export const useEventAction = () => {
}
try {
// @ts-expect-error -- we know that the object is well formed now
await _addEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Failed adding event', error);
logAxiosError('Error fetching data', error);
}
},
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
@@ -107,31 +115,25 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async (newEvent) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, newEvent.id]);
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const eventId = newEvent.id;
const previousEvent = queryClient.getQueryData([RUNDOWN_TABLE_KEY, newEvent.id]);
if (previousData && eventId) {
// optimistically update object
const newRundown = { ...previousData.rundown };
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
}
// optimistically update object
queryClient.setQueryData([RUNDOWN_TABLE_KEY, newEvent.id], newEvent);
// Return a context with the previous and new events
return { previousData, newEvent };
return { previousEvent, newEvent };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
await queryClient.invalidateQueries([RUNDOWN_TABLE_KEY]);
},
networkMode: 'always',
});
@@ -150,154 +152,6 @@ export const useEventAction = () => {
[_updateEventMutation],
);
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
/**
* Updates time of existing event
*/
const updateTimer = useCallback(
async (eventId: string, field: TimeField, value: string) => {
const getPreviousEnd = (): number => {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) {
return 0;
}
const index = cachedRundown.order.indexOf(eventId);
if (index === 0) {
return 0;
}
let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) {
const event = cachedRundown.rundown[cachedRundown.order[i]];
if (isOntimeEvent(event)) {
previousEnd = event.timeEnd;
break;
}
}
return previousEnd;
};
let newValMillis = 0;
// check for previous keyword
if (value === 'p' || value === 'prev' || value === 'previous') {
newValMillis = getPreviousEnd();
// check for adding time keyword
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + forgivingStringToMillis(remainingString);
} else {
newValMillis = forgivingStringToMillis(value);
}
const newEvent = {
id: eventId,
[field]: newValMillis,
};
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation, queryClient],
);
/**
* Toggles link of an event to the previous
*/
const linkTimer = useCallback(
async (eventId: string, linkStart: MaybeString) => {
let newEvent: Partial<OntimeEvent> = { id: eventId };
if (!linkStart) {
newEvent.linkStart = null;
} else {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown) {
return;
}
const currentEvent = cachedRundown.rundown[eventId] as OntimeEvent;
if (!isOntimeEvent(currentEvent)) {
return;
}
const { previousEvent } = getPreviousEventNormal(cachedRundown.rundown, cachedRundown.order, eventId);
if (!previousEvent) {
newEvent.linkStart = null;
} else {
newEvent.linkStart = previousEvent.id;
const timePatch = getLinkedTimes(currentEvent, previousEvent);
newEvent = { ...newEvent, ...timePatch };
}
}
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation, queryClient],
);
/**
* Calls mutation to edit multiple events
* @private
*/
const _batchUpdateEventsMutation = useMutation({
mutationFn: requestBatchPutEvents,
onMutate: async ({ ids, data }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousEvents) {
const eventIds = new Set(ids);
const newRundown = { ...previousEvents.rundown };
eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
};
}
}
});
queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous and new events
return { previousEvents };
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousEvents);
},
networkMode: 'always',
});
const batchUpdateEvents = useCallback(
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
try {
await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
} catch (error) {
logAxiosError('Error updating events', error);
}
},
[_batchUpdateEventsMutation],
);
/**
* Calls mutation to delete an event
* @private
@@ -307,36 +161,28 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async (eventId) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, eventId]);
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
if (previousData) {
// optimistically update object
const newOrder = previousData.order.filter((id) => id !== eventId);
const newRundown = { ...previousData.rundown };
delete newRundown[eventId];
const filtered = [...(previousEvents as OntimeRundown)].filter((e) => e.id !== eventId);
queryClient.setQueryData(RUNDOWN, {
order: newOrder,
rundown: newRundown,
revision: -1,
});
}
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, filtered);
// Return a context with the previous and new events
return { previousData };
return { previousEvents };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
@@ -364,26 +210,26 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async () => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
// optimistically update object
queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 });
queryClient.setQueryData(RUNDOWN_TABLE, []);
// Return a context with the previous and new events
return { previousData };
return { previousEvents };
},
// Mutation fails, rollback undos optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
@@ -407,7 +253,7 @@ export const useEventAction = () => {
mutationFn: requestApplyDelay,
// Mutation finished, failed or successful
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
@@ -435,30 +281,30 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async (data) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
if (previousData) {
// optimistically update object
const newOrder = reorderArray(previousData.order, data.from, data.to);
const e = [...(previousEvents as OntimeRundown)];
const [reorderedItem] = e.splice(data.from, 1);
e.splice(data.to, 0, reorderedItem);
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 });
}
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, e);
// Return a context with the previous and new events
return { previousData };
return { previousEvents };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
@@ -470,9 +316,9 @@ export const useEventAction = () => {
async (eventId: string, from: number, to: number) => {
try {
const reorderObject: ReorderEntry = {
eventId,
from,
to,
eventId: eventId,
from: from,
to: to,
};
await _reorderEventMutation.mutateAsync(reorderObject);
} catch (error) {
@@ -491,39 +337,31 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async ({ from, to }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const newRundown = { ...previousData.rundown };
const eventA = previousData.rundown[from];
const eventB = previousData.rundown[to];
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return;
}
const fromEventIndex = rundown.findIndex((event) => event.id === from);
const toEventIndex = rundown.findIndex((event) => event.id === to);
const { newA, newB } = swapEventData(eventA, eventB);
newRundown[from] = newA;
newRundown[to] = newB;
const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
}
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, previousEvents);
// Return a context with the previous events
return { previousData };
return { previousEvents };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
@@ -533,6 +371,8 @@ export const useEventAction = () => {
*/
const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => {
// TODO: before calling `/swapEvents`,
// we should determine the events are of type `OntimeEvent`
try {
await _swapEvents.mutateAsync({ from, to });
} catch (error) {
@@ -544,14 +384,11 @@ export const useEventAction = () => {
return {
addEvent,
applyDelay,
batchUpdateEvents,
updateEvent,
deleteEvent,
deleteAllEvents,
linkTimer,
applyDelay,
reorderEvent,
swapEvents,
updateEvent,
updateTimer,
};
};
+29 -11
View File
@@ -1,4 +1,10 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
export type TLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';
@@ -20,13 +26,13 @@ const LOG_LEVEL: Record<TLogLevel, number> = {
};
const useFitText = ({
logLevel: logLevelOption = 'info',
maxFontSize = 100,
minFontSize = 20,
onFinish,
onStart,
resolution = 5,
}: TOptions = {}) => {
logLevel: logLevelOption = 'info',
maxFontSize = 100,
minFontSize = 20,
onFinish,
onStart,
resolution = 5,
}: TOptions = {}) => {
const logLevel = LOG_LEVEL[logLevelOption];
const initState = useCallback(() => {
@@ -106,7 +112,8 @@ const useFitText = ({
const isWithinResolution = Math.abs(fontSize - fontSizePrev) <= resolution;
const isOverflow =
!!ref.current &&
(ref.current.scrollHeight > ref.current.offsetHeight || ref.current.scrollWidth > ref.current.offsetWidth);
(ref.current.scrollHeight > ref.current.offsetHeight ||
ref.current.scrollWidth > ref.current.offsetWidth);
const isFailed = isOverflow && fontSize === fontSizePrev;
const isAsc = fontSize > fontSizePrev;
@@ -116,7 +123,9 @@ const useFitText = ({
if (isFailed) {
isCalculatingRef.current = false;
if (logLevel <= LOG_LEVEL.info) {
console.info(`[use-fit-text] reached \`minFontSize = ${minFontSize}\` without fitting text`);
console.info(
`[use-fit-text] reached \`minFontSize = ${minFontSize}\` without fitting text`,
);
}
} else if (isOverflow) {
setState({
@@ -151,7 +160,16 @@ const useFitText = ({
fontSizeMin: newMin,
fontSizePrev: fontSize,
});
}, [calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev, onFinish, ref, resolution]);
}, [
calcKey,
fontSize,
fontSizeMax,
fontSizeMin,
fontSizePrev,
onFinish,
ref,
resolution,
]);
return { fontSize: `${fontSize}%`, ref };
};
@@ -21,7 +21,7 @@ interface UseFollowComponentProps {
scrollRef: MutableRefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
setScrollFlag?: () => void;
}
export default function useFollowComponent(props: UseFollowComponentProps) {
@@ -34,15 +34,14 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
}
if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
setScrollFlag?.();
scrollToComponent(
followRef as MutableRefObject<HTMLElement>,
scrollRef as MutableRefObject<HTMLElement>,
topOffset,
);
setScrollFlag?.(false);
});
}
@@ -1,5 +1,3 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-nocheck -- working on it
import { useCallback, useEffect, useState } from 'react';
interface WebkitDocument extends Document {
+1 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef } from "react";
/**
* @description utility hook to around setInterval
+41 -34
View File
@@ -1,46 +1,53 @@
import { useSyncExternalStore } from 'react';
import { useEffect, useState } from 'react';
const STORAGE_EVENT = 'ontime-storage';
/**
* @description utility hook to handle state in local storage
* @param key
* @param initialValue
*/
export const useLocalStorage = <T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] => {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(`ontime-${key}`);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
function getSnapshot(key: string): string | null {
try {
return window.localStorage.getItem(`ontime-${key}`);
} catch {
return null;
}
}
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.storageArea === window.localStorage && event.key === key) {
try {
const newValue = event.newValue ? JSON.parse(event.newValue) : initialValue;
setStoredValue(newValue);
} catch (_) {
/* empty */
}
}
};
function getParsedJson<T>(localStorageValue: string | null, initialValue: T): T {
try {
return localStorageValue ? JSON.parse(localStorageValue) : initialValue;
} catch {
return initialValue;
}
}
window.addEventListener('storage', handleStorageChange);
export const useLocalStorage = <T>(key: string, initialValue: T) => {
const localStorageValue = useSyncExternalStore(subscribe, () => getSnapshot(key));
const parsedLocalStorageValue = getParsedJson(localStorageValue, initialValue);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [initialValue, key]);
/**
* @description Set value to local storage
* @param value
*/
const setLocalStorageValue = (value: T | ((val: T) => T)) => {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(parsedLocalStorageValue) : value;
const setValue = (value: T | ((val: T) => T)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
window.dispatchEvent(new StorageEvent(STORAGE_EVENT));
setStoredValue(valueToStore);
window.localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
} catch (error) {
console.log(error);
}
};
return [parsedLocalStorageValue, setLocalStorageValue] as const;
return [storedValue, setValue];
};
function subscribe(callback: () => void) {
window.addEventListener(STORAGE_EVENT, callback);
return () => {
window.removeEventListener(STORAGE_EVENT, callback);
};
}
@@ -1,64 +0,0 @@
import { MouseEvent, SyntheticEvent, TouchEvent, useMemo, useRef } from 'react';
type LongPressOptions = {
threshold?: number;
onStart?: (e: SyntheticEvent) => void;
onFinish?: (e: SyntheticEvent) => void;
onCancel?: (e: SyntheticEvent) => void;
};
type LongPressFns = {
onMouseDown: (e: MouseEvent) => void;
onMouseUp: (e: MouseEvent) => void;
onMouseLeave: (e: MouseEvent) => void;
onTouchStart: (e: TouchEvent) => void;
onTouchEnd: (e: TouchEvent) => void;
};
export default function useLongPress(callback: () => void, options: LongPressOptions = {}): LongPressFns {
const { threshold = 400, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timerId = useRef<NodeJS.Timer>();
return useMemo(() => {
const start = (event: SyntheticEvent) => {
if (onStart) {
onStart(event);
}
isPressed.current = true;
timerId.current = setTimeout(() => {
callback();
isLongPressActive.current = true;
}, threshold);
};
const cancel = (event: SyntheticEvent) => {
if (isLongPressActive.current) {
if (onFinish) {
onFinish(event);
}
} else if (isPressed.current) {
if (onCancel) {
onCancel(event);
}
}
isLongPressActive.current = false;
isPressed.current = false;
if (timerId.current) {
clearTimeout(timerId.current);
}
};
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
};
}, [callback, threshold, onCancel, onFinish, onStart]);
}
@@ -1,38 +0,0 @@
/**
* Shamelessly from https://ahooks.js.org/hooks/use-memoized-fn/
* Interesting technique discussed by Dan Abramov
* https://overreacted.io/making-setinterval-declarative-with-react-hooks/
*/
import { useMemo, useRef } from 'react';
import { isDev } from '../api/apiConstants';
type noop = (this: any, ...args: any[]) => any;
type PickFunction<T extends noop> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>;
export const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function';
export default function useMemoisedFn<T extends noop>(fn: T) {
if (isDev) {
if (!isFunction(fn)) {
console.error(`useMemoisedFn expected function as parameter, got ${typeof fn}`);
}
}
const fnRef = useRef<T>(fn);
// why not write `fnRef.current = fn`?
// https://github.com/alibaba/hooks/issues/728
fnRef.current = useMemo(() => fn, [fn]);
const memoizedFn = useRef<PickFunction<T>>();
if (!memoizedFn.current) {
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args);
};
}
return memoizedFn.current as T;
}
+43 -99
View File
@@ -1,73 +1,70 @@
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { RuntimeStore } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime';
import { deepCompare, useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket';
export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
nextEventId: state.eventNext?.id ?? null,
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
nextEventId: state.loaded.nextEventId,
});
return useRuntimeStore(featureSelector);
return useRuntimeStore(featureSelector, deepCompare);
};
export const useOperator = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
});
return useRuntimeStore(featureSelector);
return useRuntimeStore(featureSelector, deepCompare);
};
export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({
timer: state.message.timer,
public: state.message.public,
lower: state.message.lower,
external: state.message.external,
timerMessage: state.timerMessage,
publicMessage: state.publicMessage,
lowerMessage: state.lowerMessage,
onAir: state.onAir,
});
return useRuntimeStore(featureSelector);
return useRuntimeStore(featureSelector, deepCompare);
};
export const setMessage = {
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
publicText: (payload: string) => socketSendJson('message', { public: { text: payload } }),
publicVisible: (payload: boolean) => socketSendJson('message', { public: { visible: payload } }),
lowerText: (payload: string) => socketSendJson('message', { lower: { text: payload } }),
lowerVisible: (payload: boolean) => socketSendJson('message', { lower: { visible: payload } }),
externalText: (payload: string) => socketSendJson('message', { external: { visible: payload } }),
externalVisible: (payload: boolean) => socketSendJson('message', { external: { visible: payload } }),
onAir: (payload: boolean) => socketSendJson('onAir', payload),
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
presenterText: (payload: string) => socketSendJson('set-timer-message-text', payload),
presenterVisible: (payload: boolean) => socketSendJson('set-timer-message-visible', payload),
publicText: (payload: string) => socketSendJson('set-public-message-text', payload),
publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload),
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload),
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload),
onAir: (payload: boolean) => socketSendJson('set-onAir', payload),
timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload),
timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload),
};
export const usePlaybackControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
});
return useRuntimeStore(featureSelector);
return useRuntimeStore(featureSelector, deepCompare);
};
export const setPlayback = {
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
roll: () => socketSendJson('roll'),
startNext: () => socketSendJson('start', { next: true }),
startNext: () => socketSendJson('start-next'),
previous: () => {
socketSendJson('load', { previous: true });
socketSendJson('previous');
},
next: () => {
socketSendJson('load', { next: true });
socketSendJson('next');
},
stop: () => {
socketSendJson('stop');
@@ -75,8 +72,8 @@ export const setPlayback = {
reload: () => {
socketSendJson('reload');
},
addTime: (amount: number) => {
socketSendJson('addtime', amount);
delay: (amount: number) => {
socketSendJson('delay', amount);
},
};
@@ -84,52 +81,29 @@ export const useInfoPanel = () => {
const featureSelector = (state: RuntimeStore) => ({
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
});
return useRuntimeStore(featureSelector);
};
export const useExtraTimerTime = () => {
const featureSelector = (state: RuntimeStore) => state.timer1.current;
return useRuntimeStore(featureSelector);
};
export const useExtraTimerControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer1.playback,
direction: state.timer1.direction,
});
return useRuntimeStore(featureSelector);
};
export const setExtraTimer = {
start: () => socketSendJson('extratimer', SimplePlayback.Start),
pause: () => socketSendJson('extratimer', SimplePlayback.Pause),
stop: () => socketSendJson('extratimer', SimplePlayback.Stop),
setDirection: (direction: SimpleDirection) => socketSendJson('extratimer', { direction }),
setTime: (time: number) => socketSendJson('extratimer', { settime: time }),
return useRuntimeStore(featureSelector, deepCompare);
};
export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
titleNow: state.eventNow?.title || '',
});
return useRuntimeStore(featureSelector);
return useRuntimeStore(featureSelector, deepCompare);
};
export const setEventPlayback = {
loadEvent: (id: string) => socketSendJson('load', { id }),
startEvent: (id: string) => socketSendJson('start', { id }),
loadEvent: (eventId: string) => socketSendJson('loadid', eventId),
startEvent: (eventId: string) => socketSendJson('startid', eventId),
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
};
@@ -139,37 +113,7 @@ export const useTimer = () => {
...state.timer,
});
return useRuntimeStore(featureSelector);
};
export const useClock = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
});
return useRuntimeStore(featureSelector);
};
/** Used by the progress bar components */
export const useProgressData = () => {
const featureSelector = (state: RuntimeStore) => ({
addedTime: state.timer.addedTime,
current: state.timer.current,
duration: state.timer.duration,
timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null,
});
return useRuntimeStore(featureSelector);
return useRuntimeStore(featureSelector, deepCompare);
};
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
export const useRuntimeOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
});
return useRuntimeStore(featureSelector);
};
+25 -5
View File
@@ -1,6 +1,26 @@
import { HttpSettings } from 'ontime-types';
export const httpPlaceholder: HttpSettings = {
enabledOut: false,
subscriptions: [],
export const httpPlaceholder = {
onLoad: {
url: '',
enabled: false,
},
onStart: {
url: '',
enabled: false,
},
onUpdate: {
url: '',
enabled: false,
},
onPause: {
url: '',
enabled: false,
},
onStop: {
url: '',
enabled: false,
},
onFinish: {
url: '',
enabled: false,
},
};
+17 -9
View File
@@ -1,11 +1,19 @@
import { GetInfo } from 'ontime-types';
import { Settings } from 'ontime-types';
import { oscPlaceholderSettings } from './OscSettings';
export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [],
version: '2.0.0',
serverPort: 4001,
osc: oscPlaceholderSettings,
cssOverride: '',
type NetworkInterfaceType = {
name: string;
address: string;
};
export type InfoType = {
networkInterfaces: NetworkInterfaceType[];
settings: Pick<Settings, 'version' | 'serverPort'>;
};
export const ontimePlaceholderInfo: InfoType = {
networkInterfaces: [],
settings: {
version: 2,
serverPort: 4001,
},
};
@@ -2,7 +2,7 @@ import { Settings } from 'ontime-types';
export const ontimePlaceholderSettings: Settings = {
app: 'ontime',
version: '2.0.0',
version: 2,
serverPort: 4001,
editorKey: null,
operatorKey: null,
+18 -5
View File
@@ -1,10 +1,23 @@
import { OSCSettings } from 'ontime-types';
export const oscPlaceholderSettings: OSCSettings = {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
// in the placeholder, we pass strings to satisfy input type
export interface PlaceholderSettings extends Omit<OSCSettings, 'portIn' | 'portOut'> {
portIn: string;
portOut: string;
}
export const oscPlaceholderSettings: PlaceholderSettings = {
portIn: '',
portOut: '',
targetIP: '',
enabledIn: false,
enabledOut: false,
subscriptions: [],
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
};
@@ -1,19 +1,18 @@
import { MaybeNumber, Playback, TimerType } from 'ontime-types';
// first set extends TimerState
export type ViewExtendedTimer = {
addedTime: number;
current: MaybeNumber;
duration: MaybeNumber;
elapsed: MaybeNumber;
expectedFinish: MaybeNumber;
finishedAt: MaybeNumber;
playback: Playback;
secondaryTimer: MaybeNumber;
startedAt: MaybeNumber;
import { Playback, TimerType } from 'ontime-types';
export type TimeManagerType = {
clock: number;
timeDanger: MaybeNumber;
timeWarning: MaybeNumber;
current: null | number;
elapsed: null | number;
duration: null | number;
timerBehaviour?: string;
timerType: TimerType;
expectedFinish: null | number;
addedTime: number;
startedAt: null | number;
finishedAt: null | number;
secondaryTimer: null | number;
finished: boolean;
playback: Playback;
};
@@ -14,5 +14,4 @@ export type OverridableOptions = {
hideEndMessage?: boolean;
language?: string;
showProgressBar?: boolean;
hideTimerSeconds?: boolean;
};
@@ -4,6 +4,8 @@ export const viewsSettingsPlaceholder: ViewSettings = {
overrideStyles: false,
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '',
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { QueryClient } from '@tanstack/react-query';
export const ontimeQueryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 10, // 10 min
cacheTime: 1000 * 60 * 10, // 10 min
},
},
});
+47 -13
View File
@@ -8,29 +8,63 @@ export enum AppMode {
const appModeKey = 'ontime-app-mode';
function getModeFromSession() {
return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
return localStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
}
function persistModeToSession(mode: AppMode) {
sessionStorage.setItem(appModeKey, mode);
async function persistModeToSession(mode: AppMode) {
localStorage.setItem(appModeKey, mode);
}
type AppModeStore = {
mode: AppMode;
setMode: (mode: AppMode) => void;
cursor: string | null;
setCursor: (cursor: string | null) => void;
editId: string | null;
setMode: (mode: AppMode) => void;
setCursor: (id: string | null, isEditable?: boolean) => void;
setEditId: (id: string | null) => void;
};
export const useAppMode = create<AppModeStore>()((set) => ({
mode: getModeFromSession(),
cursor: null,
setMode: (mode: AppMode) => {
persistModeToSession(mode);
return set(() => {
return { mode };
});
},
setCursor: (cursor: string | null) => set(() => ({ cursor })),
editId: null,
setMode: (mode: AppMode) =>
set((state) => {
persistModeToSession(mode);
return mode === AppMode.Edit
? {
editId: state.cursor,
mode: mode,
}
: {
editId: null,
mode: mode,
};
}),
setCursor: (id: string | null, isEditable?: boolean) =>
set((state) => {
if (isEditable) {
return state.mode === AppMode.Edit
? {
cursor: id,
editId: id,
}
: {
cursor: id,
};
} else {
return { cursor: id, editId: null };
}
}),
setEditId: (id: string | null) =>
set((state) => {
return state.mode === AppMode.Edit
? {
cursor: id,
editId: id,
}
: {
editId: id,
};
}),
}));
@@ -6,6 +6,7 @@ type EditorSettings = {
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
defaultPublic: boolean;
showNif: boolean;
};
type EditorSettingsStore = {
@@ -14,12 +15,14 @@ type EditorSettingsStore = {
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setShowNif: (showNif: boolean) => void;
};
enum EditorSettingsKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public',
ShowNif = 'ontime-show-nif',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
@@ -27,6 +30,7 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
showNif: booleanFromLocalStorage(EditorSettingsKeys.ShowNif, true),
},
setLocalEventSettings: (value) =>
@@ -54,4 +58,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
setShowNif: (showNif) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowNif, String(showNif));
return { eventSettings: { ...state.eventSettings, showNif } };
}),
}));
+40 -45
View File
@@ -1,65 +1,60 @@
import isEqual from 'react-fast-compare';
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
import { Playback, RuntimeStore } from 'ontime-types';
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
export const runtimeStorePlaceholder: RuntimeStore = {
clock: 0,
export const runtimeStorePlaceholder = {
timer: {
addedTime: 0,
clock: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null,
finishedAt: null,
playback: Playback.Stop,
secondaryTimer: null,
addedTime: 0,
startedAt: null,
finishedAt: null,
secondaryTimer: null,
selectedEventId: null,
duration: null,
timerType: null,
endAction: null,
},
playback: Playback.Stop,
timerMessage: {
text: '',
visible: false,
timerBlink: false,
timerBlackout: false,
},
publicMessage: {
text: '',
visible: false,
},
lowerMessage: {
text: '',
visible: false,
},
onAir: false,
message: {
timer: {
text: '',
visible: false,
blink: false,
blackout: false,
},
public: {
text: '',
visible: false,
},
lower: {
text: '',
visible: false,
},
external: {
text: '',
visible: false,
},
},
runtime: {
loaded: {
numEvents: 0,
selectedEventIndex: null,
selectedEventId: null,
selectedPublicEventId: null,
nextEventId: null,
nextPublicEventId: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
publicEventNext: null,
timer1: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
},
};
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
export const runtime = createStore<RuntimeStore>(() => ({
...runtimeStorePlaceholder,
}));
export const runtimeStore = createWithEqualityFn<RuntimeStore>(
() => ({
...runtimeStorePlaceholder,
}),
deepCompare,
);
export const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
export const useRuntimeStore = <T>(
selector: (state: RuntimeStore) => T,
equalityFn?: (a: unknown, b: unknown) => boolean,
) => useStore(runtime, selector, equalityFn);
@@ -1,6 +1,5 @@
import { resolvePath } from 'react-router-dom';
import { generateURLFromAlias, getAliasRoute, validateAlias } from '../aliases';
import { validateAlias, generateURLFromAlias, getAliasRoute } from '../aliases';
describe('An alias fails if incorrect', () => {
const testsToFail = [
@@ -1,4 +1,95 @@
import { forgivingStringToMillis, millisToDelayString } from '../dateConfig';
import {
forgivingStringToMillis,
millisToDelayString,
millisToMinutes,
millisToSeconds,
secondsInMillis,
} from '../dateConfig';
describe('test secondsInMillis function', () => {
it('return 0 if value is null', () => {
expect(secondsInMillis(null)).toBe(0);
});
it('returns the seconds value of a millis date', () => {
const date = 1686255053619; // Thu Jun 08 2023 20:10:53
const seconds = secondsInMillis(date);
expect(seconds).toBe(53);
});
});
describe('test millisToSeconds function', () => {
it('test with null values', () => {
const t = { val: null, result: 0 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with valid millis', () => {
const t = { val: 3600000, result: 3600 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with negative millis', () => {
const t = { val: -3600000, result: -3600 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with 0', () => {
const t = { val: 0, result: 0 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with -0', () => {
const t = { val: -0, result: -0 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
});
it('test with 86401000 (24 hours and 1 second)', () => {
const t = { val: 86401000, result: 86401 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
});
it('test with -86401000 (-24 hours and 1 second)', () => {
const t = { val: -86401000, result: -86401 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
});
});
describe('test millisToMinutes function', () => {
it('test with null values', () => {
const t = { val: null, result: 0 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with valid millis', () => {
const t = { val: 3600000, result: 60 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with negative millis', () => {
const t = { val: -3600000, result: -60 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with 0', () => {
const t = { val: 0, result: 0 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with -0', () => {
const t = { val: -0, result: -0 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with 86401000 (24 hours and 1 second)', () => {
const t = { val: 86401000, result: 1440 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with -86401000 (-24 hours and 1 second)', () => {
const t = { val: -86401000, result: -1440 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
});
describe('test forgivingStringToMillis()', () => {
describe('function handles time with no separators', () => {
@@ -267,10 +358,10 @@ describe('millisToDelayString()', () => {
});
describe('converts values in seconds', () => {
it('shows a simple string with value in seconds', () => {
expect(millisToDelayString(10000, true)).toBe('+10 sec');
expect(millisToDelayString(10000)).toBe('+10 sec');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-10000, true)).toBe('-10 sec');
expect(millisToDelayString(-10000)).toBe('-10 sec');
});
const underAMinute = [1, 500, 1000, 6000, 55000, 59999];
@@ -284,32 +375,32 @@ describe('millisToDelayString()', () => {
describe('converts values in minutes', () => {
it('shows a simple string with value in minutes', () => {
expect(millisToDelayString(720000, true)).toBe('+12 min');
expect(millisToDelayString(720000)).toBe('+12 min');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-720000, true)).toBe('-12 min');
expect(millisToDelayString(-720000)).toBe('-12 min');
});
it('shows a simple string with value in minutes and seconds', () => {
expect(millisToDelayString(630000, true)).toBe('+00:10:30');
expect(millisToDelayString(630000)).toBe('+00:10:30');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-630000, true)).toBe('-00:10:30');
expect(millisToDelayString(-630000)).toBe('-00:10:30');
});
const underAnHour = [60000, 360000, 720000];
underAnHour.forEach((value) => {
it(`handles ${value}`, () => {
expect(millisToDelayString(value, true)?.endsWith('min')).toBe(true);
expect(millisToDelayString(value)?.endsWith('min')).toBe(true);
});
});
});
describe('converts values with full time string', () => {
it('positive added time', () => {
expect(millisToDelayString(45015000, true)).toBe('+12:30:15');
expect(millisToDelayString(45015000)).toBe('+12:30:15');
});
it('negative added time', () => {
expect(millisToDelayString(-45015000, true)).toBe('-12:30:15');
expect(millisToDelayString(-45015000)).toBe('-12:30:15');
});
});
});
@@ -31,8 +31,6 @@ describe('cloneEvent()', () => {
user7: 'user7',
user8: 'user8',
user9: 'user9',
timeWarning: 120000,
timeDanger: 60000,
} as OntimeEvent;
const cloned = cloneEvent(original);
@@ -53,7 +51,5 @@ describe('cloneEvent()', () => {
expect(cloned.colour).toBe(original.colour);
expect(cloned.type).toBe(SupportedEvent.Event);
expect(cloned.revision).toBe(0);
expect(cloned.timeWarning).toBe(original.timeWarning);
expect(cloned.timeDanger).toBe(original.timeDanger);
});
});
@@ -12,7 +12,7 @@ test('Clamps a set of numbers correctly', () => {
{ num: -50, min: 0, max: 0, result: 0 },
{ num: 50.5, min: 0, max: 100, result: 50.5 },
{ num: 50, min: 0, max: 20.32, result: 20.32 },
{ num: 10, min: 20.32, max: 40, result: 20.32 },
{ num: 10, min: 20.32, max: 40, result: 20.32 }
];
testCases.forEach((t) => expect(clamp(t.num, t.min, t.max)).toBe(t.result));
@@ -1,4 +1,4 @@
import { isIPAddress, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex';
import { isIPAddress, isOnlyNumbers } from '../regex';
describe('simple tests for regex', () => {
test('isOnlyNumbers', () => {
@@ -24,28 +24,4 @@ describe('simple tests for regex', () => {
expect(isIPAddress.test(t)).toBe(false);
});
});
test('startsWithHttp', () => {
const right = ['http://test'];
const wrong = ['https://test', 'testing', '123.0.1'];
right.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(false);
});
});
test('startsWithSlash', () => {
const right = ['//test'];
const wrong = ['testing', '123.0.1'];
right.forEach((t) => {
expect(startsWithSlash.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(startsWithSlash.test(t)).toBe(false);
});
});
});

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