* refactor: cleanup routes

* style: smaller base font

* chore: upgrade dependencies

* chore: lock node version to electron

* refactor: pass HTTP to integration controller (#652)

* refactor: deprecate onair control

* refactor: remove playback router

* Several project files user folder (#617)

* chore: automated screenshots (#667)

* feat: app settings (#658)

* refactor: remove deprecated event data (#674)

* Studio clock (#663)

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Feat: reorder events with alt+ctrl + arrow up/down (#645)

* Warning and danger per event (#677)

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* refactor: stabilise actionHandler (#683)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* improvement: hide seconds (#675)

* wip: overview (#688)

* fix: focus cursor (#695)

* refactor: update lower third (#665)

* Refactor/time formatting (#696)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* feat: multiple selection (#703)

---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Alex <ac@omnivox.dk>

* fix: test - go to `Edit mode` befor tying to click `Event options` button (#708)

* refactor: runtime service (#715)

* fix: issue with loosing cursor position on message (#719)

* remove info panel (#721)

* Event editor continue (#722)

* update API - part  (#709)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* refactor: update timers (#729)

* feat: many timers (#706)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* refactor: excel cleanup (#734)

* refactor: allow import of blocks and skip import (#735)

* Project manager (#697)

* refactor: UI for linking events (#763)

* upgraded pipeline actions (#777)

* Over under (#771)

* custom fields (#744)


---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Sheets settings (#774)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* style: tweaks to lower thirds (#785)

* refactor: delays account for gaps (#784)

* refactor: partial state updates (#780)

* feat: generate crash report (#787)

* Sheet use limited input device auth flow (#782)

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Custom fields views (#789)

* refactor: deprecate presenter and subtitle (#795)

* refactor: organise API around resources (#798)

---------

Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>

* Time to end (#804)

* Skip fixes (#805)

* fix: onair derives from playback

* Param nav (#822)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>

* refactor: download files from interface (#831)

* Quick options (#814)

* End pause (#832)

* chore: bump node version in docker (#834)

* refactor: follow in run mode (#840)

* fix: uncaught error in http integration (#837)

* Apply project (#843)

Co-authored-by: Matteo Gheza <matteo.gheza07@gmail.com>
Co-authored-by: Ary <arylmoraesn@gmail.com>
Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Alex Rohleder <alexrohleder96@gmail.com>
Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>
Co-authored-by: Fabian Posenau <fabianpos99+github@gmail.com>
This commit is contained in:
Carlos Valente
2024-04-13 10:06:46 +02:00
committed by GitHub
parent debdbd1c5a
commit 55d1aca9b6
640 changed files with 26702 additions and 21623 deletions
+12 -21
View File
@@ -8,37 +8,28 @@
"jest": true "jest": true
}, },
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"extends": [ "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"],
"eslint:recommended", "plugins": ["@typescript-eslint", "prettier"],
"plugin:@typescript-eslint/recommended",
"prettier",
"eslint-config-prettier"
],
"plugins": [
"@typescript-eslint",
"prettier"
],
"overrides": [ "overrides": [
{ {
"files": [ "files": ["e2e/**/**.spec.ts", "e2e/**/**.test.ts"],
"e2e/**/**.spec.ts", "extends": ["plugin:playwright/playwright-test"]
"e2e/**/**.test.ts"
],
"extends": [
"plugin:playwright/playwright-test"
]
} }
], ],
"rules": { "rules": {
"no-useless-concat": "warn", "no-useless-concat": "warn",
"prefer-template": "warn", "prefer-template": "warn",
"no-throw-literal": "error",
"no-console": [ "no-console": [
"warn", "warn",
{ {
"allow": [ "allow": ["warn", "error"]
"warn", }
"error" ],
] "no-restricted-imports": [
"error",
{
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
} }
], ],
"@typescript-eslint/no-non-null-assertion": "warn", "@typescript-eslint/no-non-null-assertion": "warn",
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18.18.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
@@ -49,7 +49,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18.18.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
@@ -82,7 +82,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18.18.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
+109
View File
@@ -0,0 +1,109 @@
name: Ontime build v3
on:
# Only trigger manually for now for testing
# push:
# tags: [ "*" ]
workflow_dispatch:
jobs:
build_macos:
runs-on: macOS-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-mac
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-win
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-win64.exe'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_ubuntu:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build
- name: Electron - Build app
run: pnpm dist-linux
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-linux.AppImage'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+8 -2
View File
@@ -17,7 +17,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18.18.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
@@ -73,7 +73,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18.18.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
@@ -98,3 +98,9 @@ jobs:
name: playwright-report name: playwright-report
path: playwright-report/ path: playwright-report/
retention-days: 30 retention-days: 30
- uses: actions/upload-artifact@v3
with:
name: automated-screenshots
path: automated-screenshots/
retention-days: 14
+107
View File
@@ -0,0 +1,107 @@
name: Ontime test v3
on:
# Only trigger manually for now for testing
# pull_request:
# branches: '*'
workflow_dispatch:
jobs:
unit-test:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CI: ''
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- 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
e2e-test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build client
run: pnpm build:local
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: pnpm e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v4
with:
name: automated-screenshots
path: automated-screenshots/
retention-days: 14
+4
View File
@@ -27,6 +27,7 @@ ontime.code-workspace
test-results test-results
playwright-report playwright-report
/playwright/.cache/ /playwright/.cache/
automated-screenshots
# production # production
build/ build/
@@ -44,3 +45,6 @@ apps/server/src/preloaded-db/db.json
# versioning file # versioning file
**/ONTIME_VERSION.js **/ONTIME_VERSION.js
# temporary write files
**.tmp
+1 -1
View File
@@ -1 +1 @@
v16.16.0 v18.18.2
+9 -2
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- __node__ (>=16.16) - __node__ (~18.18.2)
- __pnpm__ (>=7) - __pnpm__ (>=8)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
@@ -97,3 +97,10 @@ Other useful commands
- __List running processes__ by running `docker ps` - __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>` - __Kill running process__ by running `docker kill <process-id>`
## General Info
# APP Building
We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron.
Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:16-alpine FROM node:18.18-alpine
# Set environment variables # Set environment variables
# Environment Variable to signal that we are running production # Environment Variable to signal that we are running production
+17 -11
View File
@@ -1,5 +1,5 @@
[![Ontime build v2](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml) [![Ontime build v2](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Documentation in Gitbook](https://badges.aleen42.com/src/gitbook_2.svg)](https://ontime.gitbook.io) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
## Download the latest releases here ## Download the latest releases here
@@ -17,13 +17,14 @@ Ontime is an application for creating and managing event running order and timer
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 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 This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
outputs.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png) ![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png)
![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/overview.png) ![Views](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/overview.png)
[Read the docs to learn more](https://docs.getontime.no)
## Using Ontime ## Using Ontime
Once installed and running, Ontime starts a background server that is the heart of all processes. Once installed and running, Ontime starts a background server that is the heart of all processes.
@@ -37,7 +38,7 @@ You can then use the menu in the top left corner to select the desired view.
The menu will be initially hidden until there is mouse interaction. The menu will be initially hidden until there is mouse interaction.
In the case of unattended machines or automation, it is possible to use different URL to recall In the case of unattended machines or automation, it is possible to use different URL to recall
individual views and extend view settings using the URL aliases feature individual views and extend view settings using the URL presets feature
``` ```
For the presentation views For the presentation views
@@ -59,7 +60,7 @@ IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
``` ```
More documentation is available [in our docs](https://ontime.gitbook.io) More documentation is available [in our docs](https://docs.getontime.no)
## Feature List (in no specific order) ## Feature List (in no specific order)
@@ -84,7 +85,7 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
- WebSockets - WebSockets
- [x] Roll mode: run standalone using the system clock - [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] [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 - [x] [Countdown to anything!](https://docs.getontime.no/features/count-to-anything/): have
a countdown to any scheduled event a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux) - [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime) - [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
@@ -120,9 +121,14 @@ Ontime broadcasts its data over WebSockets. This allows you to consume its data
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language 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). that can run in the browser).
<br /> <br />
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on We have prepared a few resources to help here:
how to get you started and read the docs about - Shipped with Ontime there is a small clock to get you started, it is available at `http://localhost:4001/external/demo` and the [code can be found here](https://github.com/cpvalente/ontime/tree/master/apps/server/src/external/demo)
the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-apis#osc-and-websocket-api) - See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a template on
how to get you started
- See information about the [Websocket API](https://docs.getontime.no/api/osc-and-ws/)
<br />
More information [in the docs](https://docs.getontime.no/features/custom-views/)
### Headless run ### Headless run
@@ -130,7 +136,7 @@ You can self-host and run Ontime in a docker image.
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime) The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/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) If you want to run this image in a Raspberry Pi, please see [the docs](https://docs.getontime.no/additional-notes/use-with-rpi/)
## Roadmap ## Roadmap
@@ -192,7 +198,7 @@ Information about the project setup can be found in the [development documentati
# Help # Help
Help is underway! ... and can be found [here](https://ontime.gitbook.io) Help is underway! ... and can be found [here](https://docs.getontime.no)
# License # License
+25 -24
View File
@@ -1,36 +1,36 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "2.28.17", "version": "3.0.0-alpha",
"private": true, "private": true,
"type": "module",
"dependencies": { "dependencies": {
"@chakra-ui/react": "^2.7.0", "@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.0.8", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^7.0.2", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.1", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.46.0", "@sentry/react": "^7.92.0",
"@sentry/tracing": "^7.46.0", "@tanstack/react-query": "^5.17.9",
"@tanstack/react-query": "^5.8.4", "@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-query-devtools": "^5.8.4", "@tanstack/react-table": "^8.11.3",
"@tanstack/react-table": "^8.9.2",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.2.0", "axios": "^1.2.0",
"color": "^4.2.3", "color": "^4.2.3",
"csv-stringify": "^6.2.3", "csv-stringify": "^6.4.5",
"deepmerge": "^4.3.0",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.43.5", "react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.11", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"typeface-open-sans": "^1.1.13", "typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
"zustand": "^4.3.6" "zustand": "^4.5.0"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -45,7 +45,8 @@
"lint-staged": "eslint", "lint-staged": "eslint",
"test": "vitest", "test": "vitest",
"test:pipeline": "vitest run", "test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build" "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
"analyse": "npx vite-bundle-visualizer"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@@ -60,7 +61,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "^0.4.0", "@sentry/vite-plugin": "^2.14.0",
"@tanstack/eslint-plugin-query": "^5.8.4", "@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1", "@testing-library/react": "^13.1.1",
@@ -71,7 +72,7 @@
"@types/testing-library__jest-dom": "^5.14.5", "@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^3.0.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.53.0", "eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.6.0", "eslint-plugin-jest": "^27.6.0",
@@ -85,11 +86,11 @@
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "^5.2.2", "typescript": "^5.4.3",
"vite": "^4.3.1", "vite": "^5.1.0",
"vite-plugin-compression2": "^0.9.0", "vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^2.4.0", "vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.2.0", "vite-tsconfig-paths": "^4.3.1",
"vitest": "^0.30.1" "vitest": "^1.2.2"
} }
} }
+1 -2
View File
@@ -47,9 +47,8 @@ function App() {
} }
}; };
}, [isElectron, sendToElectron]); }, [isElectron, sendToElectron]);
return ( return (
<ChakraProvider resetCSS theme={theme}> <ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}> <QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider> <AppContextProvider>
<BrowserRouter> <BrowserRouter>
+16 -24
View File
@@ -1,12 +1,13 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import withAlias from './features/AliasWrapper'; import Log from './features/log/Log';
import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper'; import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor')); const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet')); const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/Operator')); const Operator = lazy(() => import('./features/operator/OperatorExport'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer')); const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
@@ -15,58 +16,49 @@ const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage')); const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public')); const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper')); const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock')); const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withAlias(withData(TimerView)); const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withAlias(withData(MinimalTimerView)); const SMinimalTimer = withPreset(withData(MinimalTimerView));
const SClock = withAlias(withData(ClockView)); const SClock = withPreset(withData(ClockView));
const SCountdown = withAlias(withData(Countdown)); const SCountdown = withPreset(withData(Countdown));
const SBackstage = withAlias(withData(Backstage)); const SBackstage = withPreset(withData(Backstage));
const SPublic = withAlias(withData(Public)); const SPublic = withPreset(withData(Public));
const SLowerThird = withAlias(withData(Lower)); const SLowerThird = withPreset(withData(Lower));
const SStudio = withAlias(withData(StudioClock)); const SStudio = withPreset(withData(StudioClock));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper')); const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport')); const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport')); const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport')); const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
const Info = lazy(() => import('./features/info/InfoExport'));
export default function AppRouter() { export default function AppRouter() {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<Routes> <Routes>
<Route path='/' element={<Navigate to='/timer' />} /> <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='/timer' element={<STimer />} />
<Route path='/minimal' element={<SMinimalTimer />} /> <Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/minimalTimer' element={<SMinimalTimer />} />
<Route path='/simpleTimer' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} /> <Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} /> <Route path='/countdown' element={<SCountdown />} />
<Route path='/sm' element={<SBackstage />} />
<Route path='/backstage' element={<SBackstage />} /> <Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} /> <Route path='/public' element={<SPublic />} />
<Route path='/studio' element={<SStudio />} /> <Route path='/studio' element={<SStudio />} />
{/*/!* Lower cannot have fallback *!/*/}
<Route path='/lower' element={<SLowerThird />} /> <Route path='/lower' element={<SLowerThird />} />
<Route path='/op' element={<Operator />} /> <Route path='/op' element={<Operator />} />
<Route path='/operator' element={<Operator />} />
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} /> <Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/cuelist' element={<Cuesheet />} />
<Route path='/table' element={<Cuesheet />} />
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
@@ -94,10 +86,10 @@ export default function AppRouter() {
} }
/> />
<Route <Route
path='/info' path='/log'
element={ element={
<EditorFeatureWrapper> <EditorFeatureWrapper>
<Info /> <Log />
</EditorFeatureWrapper> </EditorFeatureWrapper>
} }
/> />
@@ -1,25 +1,31 @@
// REST stuff // keys in tanstack store
export const PROJECT_DATA = ['project'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN = ['rundown'];
export const APP_INFO = ['appinfo']; export const APP_INFO = ['appinfo'];
export const OSC_SETTINGS = ['oscSettings'];
export const HTTP_SETTINGS = ['httpSettings'];
export const APP_SETTINGS = ['appSettings']; export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings']; export const CUSTOM_FIELDS = ['customFields'];
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 RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState']; export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
// resolve location
const location = window.location; const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production'; export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
// resolve port
const STATIC_PORT = 4001; const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT; export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`; export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`; export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const apiEntryUrl = `${serverURL}/data`;
export const projectDataURL = `${serverURL}/project`; export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`; export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`; export const ontimeURL = `${serverURL}/ontime`;
@@ -0,0 +1,38 @@
import axios from 'axios';
import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants';
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/**
* Requests list of known custom fields
*/
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(customFieldsPath);
return res.data;
}
/**
* Sets list of known custom fields
*/
export async function postCustomField(newField: CustomField): Promise<CustomFields> {
const res = await axios.post(customFieldsPath, { ...newField });
return res.data;
}
/**
* Edits single custom field
*/
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
return res.data;
}
/**
* Deletes single custom field
*/
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${label}`);
return res.data;
}
+168
View File
@@ -0,0 +1,168 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
const dbPath = `${apiEntryUrl}/db`;
/**
* HTTP request to the current DB
*/
async function getDb(): Promise<AxiosResponse<DatabaseModel>> {
return axios.get(`${dbPath}/download`);
}
/**
* Request download of the current project file
* @param fileName
*/
export async function downloadProject(fileName: string = 'ontime-project') {
try {
const { data, name } = await fileDownload(fileName);
const fileContent = JSON.stringify(data, null, 2);
const blob = createBlob(fileContent, 'application/json;charset=utf-8;');
downloadBlob(blob, `${name}.json`);
} catch (error) {
console.error(error);
}
}
/**
* Request download of the current rundown as a CSV file
* @param fileName
*/
export async function downloadCSV(fileName: string = 'rundown') {
try {
const { data, name } = await fileDownload(fileName);
const { project, rundown, customFields } = data;
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSV(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
} catch (error) {
console.error(error);
}
}
/**
* HTTP request to upload project file
*/
export async function uploadProjectFile(file: File): Promise<MessageResponse> {
const formData = new FormData();
formData.append('project', file);
const response = await axios.post(`${dbPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
}
/**
* Make patch changes to the objects in the db
*/
export async function patchData(patchDb: Partial<DatabaseModel>): Promise<AxiosResponse<DatabaseModel>> {
return await axios.patch(dbPath, patchDb);
}
/**
* HTTP request to create a project file
*/
export async function createProject(
project: Partial<
ProjectData & {
filename: string;
}
>,
): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/new`, project);
return res.data;
}
/**
* HTTP request to get the list of available project files
*/
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`);
return res.data;
}
/**
* HTTP request to load a project file
*/
export async function loadProject(filename: string): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/load`, {
filename,
});
return res.data;
}
/**
* HTTP request to duplicate a project file
*/
export async function duplicateProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${dbPath}/${filename}/duplicate`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* HTTP request to rename a project file
*/
export async function renameProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${dbPath}/${filename}/rename`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* HTTP request to delete a project file
*/
export async function deleteProject(filename: string): Promise<MessageResponse> {
const url = `${dbPath}/${filename}`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.delete(decodedUrl);
return res.data;
}
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${dbPath}/info`);
return res.data;
}
/**
* Utility function gets project from db
* @param fileName
* @returns
*/
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
const response = await getDb();
const headerLine = response.headers['Content-Disposition'];
// try and get the filename from the response
let name = fileName;
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
name = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
return { data: response.data, name };
}
-90
View File
@@ -1,90 +0,0 @@
import axios from 'axios';
import { GetRundownCached, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { rundownURL } from './apiConstants';
/**
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchCachedRundown(): Promise<GetRundownCached> {
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;
}
/**
* @description HTTP request to post new event
* @return {Promise}
*/
export async function requestPostEvent(data: OntimeRundownEntry) {
return axios.post(rundownURL, data);
}
/**
* @description HTTP request to put new event
* @return {Promise}
*/
export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
return axios.put(rundownURL, data);
}
export type ReorderEntry = {
eventId: string;
from: number;
to: number;
};
/**
* @description HTTP request to reorder events
* @return {Promise}
*/
export async function requestReorderEvent(data: ReorderEntry) {
return axios.patch(`${rundownURL}/reorder`, data);
}
/**
* @description HTTP request to request application of delay
* @return {Promise}
*/
export async function requestApplyDelay(eventId: string) {
return axios.patch(`${rundownURL}/applydelay/${eventId}`);
}
export type SwapEntry = {
from: string;
to: string;
};
/**
* @description HTTP request to swap two events
* @return {Promise}
*/
export async function requestEventSwap(data: SwapEntry) {
return axios.patch(`${rundownURL}/swap`, data);
}
/**
* @description HTTP request to delete given event
* @return {Promise}
*/
export async function requestDelete(eventId: string) {
return axios.delete(`${rundownURL}/${eventId}`);
}
/**
* @description HTTP request to delete all events
* @return {Promise}
*/
export async function requestDeleteAll() {
return axios.delete(`${rundownURL}/all`);
}
+42
View File
@@ -0,0 +1,42 @@
import axios, { AxiosResponse } from 'axios';
import { CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`;
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/**
* upload Excel file to server
* @return string - file ID op the uploaded file
*/
export async function upload(file: File) {
const formData = new FormData();
formData.append('excel', file);
await axios.post(`${excelPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/**
* Get Worksheet names
* @return string[] - array of available worksheets
*/
export async function getWorksheetNames(): Promise<string[]> {
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
return response.data;
}
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options,
});
return response.data;
}
+19
View File
@@ -0,0 +1,19 @@
import axios from 'axios';
import { apiRepoLatest } from '../../externals';
export type HasUpdate = {
url: string;
version: string;
};
/**
* HTTP request to get the latest version and url from github
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(`${apiRepoLatest}`);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
};
}
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { HttpSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const httpPath = `${apiEntryUrl}/http`;
/**
* HTTP request to retrieve http settings
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(httpPath);
return res.data;
}
/**
* HTTP request to mutate http settings
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(httpPath, data);
}
-320
View File
@@ -1,320 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import {
Alias,
DatabaseModel,
GetInfo,
HttpSettings,
OntimeRundown,
OSCSettings,
OscSubscription,
ProjectData,
Settings,
UserFields,
ViewSettings,
} from 'ontime-types';
import { ExcelImportMap } from 'ontime-utils';
import { apiRepoLatest } from '../../externals';
import fileDownload from '../utils/fileDownload';
import { ontimeURL } from './apiConstants';
/**
* @description HTTP request to retrieve application settings
* @return {Promise}
*/
export async function getSettings(): Promise<Settings> {
const res = await axios.get(`${ontimeURL}/settings`);
return res.data;
}
/**
* @description HTTP request to mutate application settings
* @return {Promise}
*/
export async function postSettings(data: Settings) {
return axios.post(`${ontimeURL}/settings`, data);
}
/**
* @description HTTP request to retrieve application info
* @return {Promise}
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${ontimeURL}/info`);
return res.data;
}
/**
* @description HTTP request to retrieve view settings
* @return {Promise}
*/
export async function getView(): Promise<ViewSettings> {
const res = await axios.get(`${ontimeURL}/views`);
return res.data;
}
/**
* @description HTTP request to mutate view settings
* @return {Promise}
*/
export async function postViewSettings(data: ViewSettings) {
return axios.post(`${ontimeURL}/views`, data);
}
/**
* @description HTTP request to retrieve aliases
* @return {Promise}
*/
export async function getAliases(): Promise<Alias[]> {
const res = await axios.get(`${ontimeURL}/aliases`);
return res.data;
}
/**
* @description HTTP request to mutate aliases
* @return {Promise}
*/
export async function postAliases(data: Alias[]) {
return axios.post(`${ontimeURL}/aliases`, data);
}
/**
* @description HTTP request to retrieve user fields
* @return {Promise}
*/
export async function getUserFields(): Promise<UserFields> {
const res = await axios.get(`${ontimeURL}/userfields`);
return res.data;
}
/**
* @description HTTP request to mutate user fields
* @return {Promise}
*/
export async function postUserFields(data: UserFields) {
return axios.post(`${ontimeURL}/userfields`, data);
}
/**
* @description HTTP request to retrieve osc settings
* @return {Promise}
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(`${ontimeURL}/osc`);
return res.data;
}
/**
* @description HTTP request to retrieve http settings
* @return {Promise}
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(`${ontimeURL}/http`);
return res.data;
}
/**
* @description HTTP request to mutate http settings
* @return {Promise}
*/
export async function postHTTP(data: HttpSettings) {
return axios.post(`${ontimeURL}/http`, data);
}
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
*/
export async function postOSC(data: OSCSettings) {
return axios.post(`${ontimeURL}/osc`, data);
}
/**
* @description HTTP request to mutate osc subscriptions
* @return {Promise}
*/
export async function postOscSubscriptions(data: OscSubscription) {
return axios.post(`${ontimeURL}/osc-subscriptions`, data);
}
/**
* @description HTTP request to download db in CSV format
*/
export const downloadCSV = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
};
/**
* @description HTTP request to download db in JSON format
*/
export const downloadRundown = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' });
};
// TODO: should this be extracted to shared code?
export type ProjectFileImportOptions = {
onlyRundown: boolean;
};
/**
* @description HTTP request to upload events db
* @return {Promise}
*/
export const uploadProjectFile = async (
file: File,
setProgress: (value: number) => void,
options?: Partial<ProjectFileImportOptions>,
) => {
const formData = new FormData();
formData.append('userFile', file);
const onlyRundown = Boolean(options?.onlyRundown);
await axios
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
})
.then((response) => response.data.id);
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise}
*/
export async function patchData(patchDb: Partial<DatabaseModel>) {
const response = await axios.patch(`${ontimeURL}/db`, patchDb);
return response;
}
type PostPreviewExcelResponse = {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise} - returns parsed rundown and userfields
*/
export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: ExcelImportMap) {
const formData = new FormData();
formData.append('userFile', file);
formData.append('options', JSON.stringify(options));
const response: AxiosResponse<PostPreviewExcelResponse> = await axios.post(
`${ontimeURL}/preview-spreadsheet`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
},
);
return response;
}
export type HasUpdate = {
url: string;
version: string;
};
/**
* @description HTTP request to get the latest version and url from github
* @return {Promise}
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(`${apiRepoLatest}`);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
};
}
export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
/**
* @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;
};
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { OSCSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const oscPath = `${apiEntryUrl}/osc`;
/**
* HTTP request to retrieve osc settings
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(oscPath);
return res.data;
}
/**
* HTTP request to mutate osc settings
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(oscPath, data);
}
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { ProjectData } from 'ontime-types';
import { apiEntryUrl } from './constants';
const projectPath = `${apiEntryUrl}/project`;
/**
* HTTP request to fetch project data
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectPath);
return res.data;
}
/**
* HTTP request to mutate project data
*/
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
return axios.post(projectPath, data);
}
@@ -1,21 +0,0 @@
import axios from 'axios';
import { ProjectData } from 'ontime-types';
import { projectDataURL } from './apiConstants';
/**
* @description HTTP request to fetch project data
* @return {Promise}
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectDataURL);
return res.data;
}
/**
* @description HTTP request to mutate project data
* @return {Promise}
*/
export async function postProjectData(data: ProjectData) {
return axios.post(projectDataURL, data);
}
+86
View File
@@ -0,0 +1,86 @@
import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`;
/**
* HTTP request to fetch all events
*/
export async function fetchNormalisedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownPath}/normalised`);
return res.data;
}
/**
* HTTP request to post new event
*/
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data);
}
/**
* HTTP request to put new event
*/
export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.put(rundownPath, data);
}
type BatchEditEntry = {
data: Partial<OntimeEvent>;
ids: string[];
};
/**
* HTTP request to put multiple events
*/
export async function requestBatchPutEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.put(`${rundownPath}/batch`, data);
}
export type ReorderEntry = {
eventId: string;
from: number;
to: number;
};
/**
* HTTP request to reorder events
*/
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.patch(`${rundownPath}/reorder`, data);
}
export type SwapEntry = {
from: string;
to: string;
};
/**
* HTTP request to swap two events
*/
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/swap`, data);
}
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/applydelay/${eventId}`);
}
/**
* HTTP request to delete given event
*/
export async function requestDelete(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(`${rundownPath}/${eventId}`);
}
/**
* HTTP request to delete all events
*/
export async function requestDeleteAll(): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(`${rundownPath}/all`);
}
+21
View File
@@ -0,0 +1,21 @@
import axios, { AxiosResponse } from 'axios';
import { Settings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const settingsPath = `${apiEntryUrl}/settings`;
/**
* HTTP request to retrieve application settings
*/
export async function getSettings(): Promise<Settings> {
const res = await axios.get(settingsPath);
return res.data;
}
/**
* HTTP request to mutate application settings
*/
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data);
}
+75
View File
@@ -0,0 +1,75 @@
import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
const sheetsPath = `${apiEntryUrl}/sheets`;
/**
* HTTP request to verify whether we are authenticated with Google Sheet service
*/
export const verifyAuthenticationStatus = async (): Promise<{
authenticated: AuthenticationStatus;
sheetId: string;
}> => {
const response = await axios.get(`${sheetsPath}/connect`);
return response.data;
};
/**
* HTTP request to initiate the authentication service with google
*/
export const requestConnection = async (
file: File,
sheetId: string,
): Promise<{
verification_url: string;
user_code: string;
}> => {
const formData = new FormData();
formData.append('client_secret', file);
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
/**
* HTTP request to revoke authentication to google sheet
*/
export const revokeAuthentication = async (): Promise<{ authenticated: AuthenticationStatus }> => {
const response = await axios.post(`${sheetsPath}/revoke`);
return response.data;
};
/**
* HTTP request to upload preview the contents of a google sheet as rundown
*/
export const previewRundown = async (
sheetId: string,
options: ImportMap,
): Promise<{
rundown: OntimeRundown;
customFields: CustomFields;
}> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
return response.data;
};
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
return response.data;
};
/**
* HTTP request to upload the rundown to a google sheet
*/
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
return response.data;
};
+21
View File
@@ -0,0 +1,21 @@
import axios from 'axios';
import { URLPreset } from 'ontime-types';
import { apiEntryUrl } from './constants';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
* HTTP request to retrieve aliases
*/
export async function getUrlPresets(): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath);
return res.data;
}
/**
* HTTP request to mutate aliases
*/
export async function postUrlPresets(data: URLPreset[]): Promise<URLPreset[]> {
return axios.post(urlPresetsPath, data);
}
@@ -6,6 +6,11 @@ import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time'; import { nowInMillis } from '../utils/time';
/**
* Utility unrwap a potential axios error
* @param error
* @returns
*/
export function maybeAxiosError(error: unknown) { export function maybeAxiosError(error: unknown) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? ''; const statusText = (error as AxiosError).response?.statusText ?? '';
@@ -26,6 +31,11 @@ export function maybeAxiosError(error: unknown) {
} }
} }
/**
* Utility unrwaps a potential axios error and sends to logger
* @param prepend
* @param error
*/
export function logAxiosError(prepend: string, error: unknown) { export function logAxiosError(prepend: string, error: unknown) {
const message = `${prepend}: ${maybeAxiosError(error)}`; const message = `${prepend}: ${maybeAxiosError(error)}`;
@@ -42,12 +52,32 @@ export function logAxiosError(prepend: string, error: unknown) {
* Utility function invalidates react-query caches * Utility function invalidates react-query caches
*/ */
export async function invalidateAllCaches() { export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] }); await ontimeQueryClient.invalidateQueries();
await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] }); }
await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] }); /**
await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] }); * Creates blob from content
await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] }); * @param fileContent
await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] }); * @param type
await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] }); * @returns
*/
export function createBlob(fileContent: string, type: string): Blob {
return new Blob([fileContent], { type });
}
/**
* downloads a blob
* @param downloadUrl
* @param fileName
*/
export function downloadBlob(blob: Blob, fileName: string) {
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', downloadUrl);
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(downloadUrl);
} }
@@ -0,0 +1,21 @@
import axios from 'axios';
import { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
/**
* HTTP request to retrieve view settings
*/
export async function getView(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath);
return res.data;
}
/**
* HTTP request to mutate view settings
*/
export async function postViewSettings(data: ViewSettings) {
return axios.post(viewSettingsPath, data);
}
@@ -0,0 +1,12 @@
@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);
}
@@ -0,0 +1,26 @@
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,14 +10,12 @@ import {
IconButton, IconButton,
Tooltip, Tooltip,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FiPower } from '@react-icons/all-files/fi/FiPower'; import { IoPowerOutline } from '@react-icons/all-files/io5/IoPowerOutline';
import { Size } from '../../models/Util.type';
import { useEmitLog } from '../../stores/logger'; import { useEmitLog } from '../../stores/logger';
interface QuitIconBtnProps { interface QuitIconBtnProps {
clickHandler: () => void; clickHandler: () => void;
size?: Size;
disabled?: boolean; disabled?: boolean;
} }
@@ -42,7 +40,7 @@ const quitBtnStyle = {
}; };
export default function QuitIconBtn(props: QuitIconBtnProps) { export default function QuitIconBtn(props: QuitIconBtnProps) {
const { clickHandler, size = 'lg', disabled, ...rest } = props; const { clickHandler, disabled, ...rest } = props;
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { emitInfo } = useEmitLog(); const { emitInfo } = useEmitLog();
const onClose = () => setIsOpen(false); const onClose = () => setIsOpen(false);
@@ -67,8 +65,8 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<Tooltip label='Quit Application'> <Tooltip label='Quit Application'>
<IconButton <IconButton
aria-label='Quit Application' aria-label='Quit Application'
size={size} size='lg'
icon={<FiPower />} icon={<IoPowerOutline />}
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
isDisabled={disabled} isDisabled={disabled}
{...quitBtnStyle} {...quitBtnStyle}
@@ -81,7 +79,7 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<AlertDialogHeader fontSize='lg' fontWeight='bold'> <AlertDialogHeader fontSize='lg' fontWeight='bold'>
Ontime Shutdown Ontime Shutdown
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogBody>This will shutdown the program and all running servers. Are you sure?</AlertDialogBody> <AlertDialogBody>This will shutdown the Ontime server. Are you sure?</AlertDialogBody>
<AlertDialogFooter> <AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant='ghost'> <Button ref={cancelRef} onClick={onClose} variant='ghost'>
Cancel Cancel
@@ -2,7 +2,7 @@ import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react'; import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps { interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: (event?: MouseEvent) => void | Promise<void>; clickHandler: (event: MouseEvent) => void | Promise<void>;
tooltip: string; tooltip: string;
openDelay?: number; openDelay?: number;
} }
@@ -1,24 +0,0 @@
@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;
}
@@ -1,20 +0,0 @@
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,6 +1,5 @@
.contextMenuButton { .contextMenuButton {
position: absolute; position: absolute;
cursor: pointer;
} }
.contextMenuBackdrop { .contextMenuBackdrop {
@@ -1,11 +1,13 @@
// logic (with some modifications) culled from: // logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx // https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { Fragment, ReactElement } from 'react'; import { ReactElement } from 'react';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react'; import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files'; import { IconType } from '@react-icons/all-files';
import { create } from 'zustand'; import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
import style from './ContextMenu.module.scss'; import style from './ContextMenu.module.scss';
type ContextMenuCoords = { type ContextMenuCoords = {
@@ -13,14 +15,23 @@ type ContextMenuCoords = {
y: number; y: number;
}; };
export type Option = { export type OptionWithoutGroup = {
label: string; label: string;
isDisabled?: boolean;
icon: IconType; icon: IconType;
onClick: () => void; onClick: () => void;
withDivider?: boolean; 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 = { type ContextMenuStore = {
coords: ContextMenuCoords; coords: ContextMenuCoords;
options: Option[]; options: Option[];
@@ -57,7 +68,7 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
<> <>
{children} {children}
<div className={style.contextMenuBackdrop} /> <div className={style.contextMenuBackdrop} />
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'> <Menu isOpen size='sm' gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton <MenuButton
className={style.contextMenuButton} className={style.contextMenuButton}
aria-hidden aria-hidden
@@ -69,14 +80,17 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
}} }}
/> />
<MenuList> <MenuList>
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => ( {options.map((option) =>
<Fragment key={label}> isOptionWithGroup(option) ? (
{withDivider && <MenuDivider />} <MenuGroup key={option.label} title={option.label}>
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}> {option.group.map((groupOption) => (
{label} <ContextMenuOption key={groupOption.label} {...groupOption} />
</MenuItem> ))}
</Fragment> </MenuGroup>
))} ) : (
<ContextMenuOption key={option.label} {...option} />
),
)}
</MenuList> </MenuList>
</Menu> </Menu>
</> </>
@@ -0,0 +1,12 @@
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 style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
);
@@ -10,20 +10,28 @@ interface CopyTagProps {
label: string; label: string;
className?: string; className?: string;
size?: Size; size?: Size;
disabled?: boolean;
} }
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) { export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
const { label, className, size = 'xs', children } = props; const { label, className, size = 'xs', disabled, children } = props;
const handleClick = () => copyToClipboard(children as string); const handleClick = () => copyToClipboard(children as string);
return ( return (
<Tooltip label={label} openDelay={tooltipDelayFast}> <Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup size={size} isAttached className={className}> <ButtonGroup size={size} isAttached className={className}>
<Button variant='ontime-subtle' tabIndex={-1}> <Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
{children} {children}
</Button> </Button>
<IconButton aria-label={label} icon={<IoCopy />} variant='ontime-filled' tabIndex={-1} onClick={handleClick} /> <IconButton
aria-label={label}
icon={<IoCopy />}
variant='ontime-filled'
tabIndex={-1}
onClick={handleClick}
isDisabled={disabled}
/>
</ButtonGroup> </ButtonGroup>
</Tooltip> </Tooltip>
); );
@@ -1,5 +1,3 @@
@use '../../../theme/v2Styles' as *;
.delaySymbol { .delaySymbol {
svg { svg {
font-size: 1.5rem; font-size: 1.5rem;
@@ -3,7 +3,7 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module. // skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { runtime } from '@/common/stores/runtime'; import { runtimeStore } from '@/common/stores/runtime';
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket'; import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
import style from './ErrorBoundary.module.scss'; import style from './ErrorBoundary.module.scss';
@@ -23,13 +23,13 @@ class ErrorBoundary extends React.Component {
componentDidCatch(error, info) { componentDidCatch(error, info) {
this.setState({ this.setState({
error: error, error,
errorInfo: info, errorInfo: info,
}); });
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setExtras('error', error); scope.setExtras('error', error);
scope.setExtras('store', runtime.getState()); scope.setExtras('store', runtimeStore.getState());
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts }); scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
@@ -1,5 +1,3 @@
@use '../../../theme/v2Styles' as *;
.errorContainer { .errorContainer {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -1,14 +1,12 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeColours' as *;
.link { .link {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 0.25rem;
color: $blue-500; color: $blue-500;
transition-property: color; transition-property: color;
transition-duration: $transition-time-action; transition-duration: $transition-time-action;
width: fit-content;
&.inline { &.inline {
display: inline-flex; display: inline-flex;
@@ -1,18 +1,18 @@
import { MouseEvent, ReactNode } from 'react'; import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline'; import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../common/utils/linkUtils'; import { openLink } from '../../utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './ModalLink.module.scss'; import style from './ExternalLink.module.scss';
interface ModalLinkProps { interface ExternalLinkProps {
href: string; href: string;
children: ReactNode; children: ReactNode;
inline?: boolean; inline?: boolean;
} }
export default function ModalLink(props: ModalLinkProps) { export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props; const { href, inline, children } = props;
const classes = cx([style.link, inline ? style.inline : null]); const classes = cx([style.link, inline ? style.inline : null]);
@@ -4,6 +4,8 @@ import { Textarea, TextareaProps } from '@chakra-ui/react';
import autosize from 'autosize/dist/autosize'; import autosize from 'autosize/dist/autosize';
export const AutoTextArea = (props: TextareaProps) => { export const AutoTextArea = (props: TextareaProps) => {
const { value } = props;
const ref = useRef<HTMLTextAreaElement>(null); const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => { useEffect(() => {
@@ -12,7 +14,7 @@ export const AutoTextArea = (props: TextareaProps) => {
return () => { return () => {
autosize.destroy(node); autosize.destroy(node);
}; };
}, []); }, [value]);
return ( return (
<Textarea <Textarea
@@ -6,21 +6,24 @@ import style from './SwatchSelect.module.scss';
interface SwatchProps { interface SwatchProps {
color: string; color: string;
onClick: (color: string) => void; onClick?: (color: string) => void;
isSelected?: boolean; isSelected?: boolean;
} }
export default function Swatch(props: SwatchProps) { export default function Swatch(props: SwatchProps) {
const { color, isSelected, onClick } = props; const { color, isSelected, onClick } = props;
const classes = cx([style.swatch, isSelected ? style.selected : null]); const handleClick = () => {
onClick?.(color);
};
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
if (!color) { if (!color) {
return ( return (
<div className={`${classes} ${style.center}`} onClick={() => onClick('')}> <div className={`${classes} ${style.center}`} onClick={handleClick}>
<IoBan /> <IoBan />
</div> </div>
); );
} }
return <div className={classes} style={{ backgroundColor: `${color}` }} onClick={() => onClick(color)} />; return <div className={classes} style={{ backgroundColor: `${color}` }} onClick={handleClick} />;
} }
@@ -1,24 +1,27 @@
.list { .list {
display: flex; display: flex;
gap: 4px; flex-wrap: wrap;
gap: 0.5rem
} }
.swatch { .swatch {
cursor: pointer;
aspect-ratio: 1;
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
border-radius: 16px; aspect-ratio: 1;
border: 4px solid #262626; border-radius: 99px;
border: 2px solid $gray-1200;
&.selected { &.selected {
border: 2px solid #578AF4; border: 2px solid $blue-500;
}
&.selectable {
cursor: pointer;
} }
} }
.center { .center {
display: grid; display: grid;
place-content: center; place-content: center;
color: #578AF4; color: $ui-white;
} }
@@ -1,15 +1,13 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
import Swatch from './Swatch'; import Swatch from './Swatch';
import style from './SwatchSelect.module.scss'; import style from './SwatchSelect.module.scss';
interface ColourInputProps { interface ColourInputProps {
value: string; value: string;
name: EditorUpdateFields; name: 'colour';
handleChange: (newValue: EditorUpdateFields, name: string) => void; handleChange: (newValue: 'colour', name: string) => void;
} }
const colours = [ const colours = [
@@ -1,5 +1,3 @@
@use '../../../../theme/v2Styles' as *;
$input-font-size: 15px; $input-font-size: 15px;
.delayInput { .delayInput {
@@ -21,6 +21,7 @@ export default function DelayInput(props: DelayInputProps) {
// avoid wrong submit on cancel // avoid wrong submit on cancel
let ignoreChange = false; let ignoreChange = false;
// set internal value on duration change
useEffect(() => { useEffect(() => {
if (typeof duration === 'undefined') { if (typeof duration === 'undefined') {
return; return;
@@ -1,6 +1,3 @@
@use "../../../../theme/_ontimeColours" as *;
@use "../../../../theme/_v2Styles" as *;
.swatch { .swatch {
width: 24px; width: 24px;
height: 24px; height: 24px;
@@ -1,7 +1,7 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { Input, Textarea } from '@chakra-ui/react'; import { Input, Textarea } from '@chakra-ui/react';
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor'; import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditor';
import { Size } from '../../../models/Util.type'; import { Size } from '../../../models/Util.type';
import useReactiveTextInput from './useReactiveTextInput'; import useReactiveTextInput from './useReactiveTextInput';
@@ -1,37 +1,4 @@
@use "../../../../theme/v2Styles" as *;
$input-font-size: 15px;
$input-delayed-border-color: #E69056;
.timeInput { .timeInput {
width: fit-content !important;
.inputLeft {
max-width: fit-content;
}
.inputLeft,
.inputButton {
aspect-ratio: 1;
}
.inputField {
font-size: $input-font-size;
letter-spacing: 1px; letter-spacing: 1px;
width: 7.5em; width: 6.5em;
padding: 0 0 0 2.6em;
}
.warn {
&::after {
content: "*";
color: $warning-orange;
}
}
&.delayed {
.inputField {
border: 1px solid $input-delayed-border-color;
}
}
} }
@@ -1,42 +1,23 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react'; import { Input } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import { useEmitLog } from '../../../stores/logger'; import { useEmitLog } from '../../../stores/logger';
import { forgivingStringToMillis } from '../../../utils/dateConfig'; import { forgivingStringToMillis } from '../../../utils/dateConfig';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
import { TimeEntryField } from '../../../utils/timesManager';
import style from './TimeInput.module.scss'; import style from './TimeInput.module.scss';
interface TimeInputProps<T extends string> {
interface TimeInputProps { name: T;
id?: TimeEntryField; submitHandler: (field: T, value: string) => void;
name: TimeEntryField;
submitHandler: (field: TimeEntryField, value: number) => void;
time?: number; time?: number;
delay?: number;
placeholder: string; placeholder: string;
previousEnd?: number; disabled?: boolean;
tooltip?: string; className?: string;
} }
function ButtonInitial(name: TimeEntryField) { export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
if (name === 'timeStart') return 'S'; const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
if (name === 'timeEnd') return 'E';
if (name === 'durationOverride') return 'D';
return '';
}
function ButtonTooltip(name: TimeEntryField, tooltip?: string) {
if (name === 'timeStart') return `Start${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'timeEnd') return `End${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'durationOverride') return `Duration${tooltip ? `: ${tooltip}` : ''}`;
return '';
}
export default function TimeInput(props: TimeInputProps) {
const { id, name, submitHandler, time = 0, delay = 0, placeholder, previousEnd = 0 } = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
@@ -72,32 +53,20 @@ export default function TimeInput(props: TimeInputProps) {
return false; return false;
} }
let newValMillis = 0; // we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) {
// check for known aliases submitHandler(name, newValue);
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);
} }
// check if time is different from before const valueInMillis = forgivingStringToMillis(newValue);
if (newValMillis === time) return false; if (valueInMillis === time) {
return false;
// update entry }
submitHandler(name, newValMillis);
submitHandler(name, newValue);
return true; return true;
}, },
[name, previousEnd, submitHandler, time], [name, submitHandler, time],
); );
/** /**
@@ -107,15 +76,11 @@ export default function TimeInput(props: TimeInputProps) {
const validateAndSubmit = useCallback( const validateAndSubmit = useCallback(
(newValue: string) => { (newValue: string) => {
const success = handleSubmit(newValue); 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(); resetValue();
} }
}, },
[delay, handleSubmit, name, resetValue], [handleSubmit, resetValue],
); );
/** /**
@@ -126,9 +91,6 @@ export default function TimeInput(props: TimeInputProps) {
(event: KeyboardEvent<HTMLInputElement>) => { (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
inputRef.current?.blur(); inputRef.current?.blur();
validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value);
} }
if (event.key === 'Escape') { if (event.key === 'Escape') {
ignoreChange.current = true; ignoreChange.current = true;
@@ -136,7 +98,7 @@ export default function TimeInput(props: TimeInputProps) {
resetValue(); resetValue();
} }
}, },
[resetValue, validateAndSubmit], [resetValue],
); );
const onBlurHandler = useCallback( const onBlurHandler = useCallback(
@@ -155,51 +117,26 @@ export default function TimeInput(props: TimeInputProps) {
resetValue(); resetValue();
}, [resetValue, time]); }, [resetValue, time]);
const isDelayed = delay !== 0; const timeInputClasses = cx([style.timeInput, className]);
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null]);
const TooltipLabel = useMemo(() => {
return ButtonTooltip(name, '');
}, [name]);
const ButtonText = useMemo(() => {
return ButtonInitial(name);
}, [name]);
return ( return (
<InputGroup size='sm' className={inputClasses}> <Input
<InputLeftElement className={style.inputLeft}> disabled={disabled}
<Tooltip label={TooltipLabel} openDelay={tooltipDelayFast} variant='ontime-ondark'> size='sm'
<Button ref={inputRef}
size='sm' data-testid={`time-input-${name}`}
variant='ontime-subtle-white' className={timeInputClasses}
className={buttonClasses} fontSize='1rem'
tabIndex={-1} type='text'
border={isDelayed ? '1px solid #E69056' : '1px solid transparent'} placeholder={placeholder}
borderRight='1px solid transparent' variant='ontime-filled'
borderRadius='2px 0 0 2px' onFocus={handleFocus}
> onChange={(event) => setValue(event.target.value)}
{ButtonText} onBlur={onBlurHandler}
</Button> onKeyDown={onKeyDownHandler}
</Tooltip> value={value}
</InputLeftElement> maxLength={8}
<Input autoComplete='off'
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}
autoComplete='off'
/>
</InputGroup>
); );
} }
@@ -0,0 +1,12 @@
$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;
}
@@ -0,0 +1,37 @@
import { PropsWithChildren } from 'react';
import { InputGroup } from '@chakra-ui/react';
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'>
<TimeInput<T>
name={name}
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
className={style.inputField}
disabled={disabled}
/>
{children}
</InputGroup>
);
}
@@ -1,17 +1,16 @@
@use '../../../theme/ontimeColours' as *; $loader-size: 4rem;
.screenLoader { .overlay {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: grid; display: grid;
place-content: center; place-content: center;
top: 0; background-color: $black-10;
background-color: $white-60; backdrop-filter: blur(5px);
} }
$loader-size: 48px;
.loader { .loader {
width: $loader-size; width: $loader-size;
@@ -0,0 +1,9 @@
import style from './LoaderOverlay.module.scss';
export default function LoaderOverlay() {
return (
<div className={style.overlay}>
<span className={style.loader} />
</div>
);
}
@@ -1,14 +1,16 @@
import { MaybeNumber } from 'ontime-types';
import { clamp } from '../../utils/math'; import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss'; import './MultiPartProgressBar.scss';
interface MultiPartProgressBar { interface MultiPartProgressBar {
now: number | null; now: MaybeNumber;
complete: number; complete: number;
normalColor: string; normalColor: string;
warning: number; warning?: MaybeNumber;
warningColor: string; warningColor: string;
danger: number; danger?: MaybeNumber;
dangerColor: string; dangerColor: string;
hidden?: boolean; hidden?: boolean;
className?: string; className?: string;
@@ -17,10 +19,10 @@ interface MultiPartProgressBar {
export default function MultiPartProgressBar(props: MultiPartProgressBar) { export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props; 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 percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = clamp((danger / complete) * 100, 0, 100); const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = clamp((warning / complete) * 100, 0, 100); const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
return ( return (
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}> <div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
@@ -35,7 +37,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className='multiprogress-bar__bg-danger' className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }} style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/> />
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} /> <div className='multiprogress-bar__indicator' style={{ width: `${percentRemaining}%` }} />
</> </>
)} )}
</div> </div>
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { debounce } from '../../utils/debounce';
import style from './NavigationMenu.module.scss';
interface FloatingNavigationProps {
toggleMenu: () => void;
toggleSettings: () => void;
}
export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props;
const [showButton, setShowButton] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
return (
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
className={style.navButton}
data-testid='navigation__toggle-menu'
>
<IoApps />
</button>
<button
className={style.button}
onClick={toggleSettings}
aria-label='toggle settings'
data-testid='navigation__toggle-settings'
>
<IoSettingsOutline />
</button>
</div>
);
}
@@ -1,14 +1,11 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/mixins' as *; @use '../../../theme/mixins' as *;
@use '../../../theme/ontimeColours' as *;
$menu-bg: $gray-1200;
$menu-hover-bg: $gray-1350; $menu-hover-bg: $gray-1350;
$menu-focus-bg: $gray-1300; $menu-focus-bg: $gray-1300;
$icon-color: $ui-white; $icon-color: $ui-white;
$button-bg: $gray-1050; $button-bg: $gray-1050;
$button-size: 48px; $button-size: 3rem;
.buttonContainer { .buttonContainer {
display: flex; display: flex;
@@ -23,7 +20,7 @@ $button-size: 48px;
position: fixed; position: fixed;
left: 0; left: 0;
top: 0; top: 0;
z-index: 2; z-index: 12;
&.hidden { &.hidden {
opacity: 0; opacity: 0;
@@ -31,7 +28,7 @@ $button-size: 48px;
} }
.button { .button {
font-size: 24px; font-size: 1.5rem;
color: $icon-color; color: $icon-color;
background-color: $button-bg; background-color: $button-bg;
width: $button-size; width: $button-size;
@@ -46,32 +43,10 @@ $button-size: 48px;
z-index: 3; z-index: 3;
} }
.menuContainer {
top: 0;
left: 0;
height: fit-content;
position: absolute;
background-color: $menu-bg;
min-width: 200px;
border-radius: 0 0 24px 0;
border-right: 1px solid $border-color-ondark;
box-shadow: $box-shadow-l2;
padding-bottom: 1rem;
max-height: 100vh;
overflow-y: auto;
}
.buttonsContainer {
margin-top: calc(56px + 1rem);
}
.link { .link {
@include action-link; @include action-link;
justify-content: space-between; padding: 0.75rem 1.5rem;
padding: 0.5rem 1rem; gap: 0.5rem;
cursor: pointer;
&:hover { &:hover {
background-color: $menu-hover-bg; background-color: $menu-hover-bg;
@@ -94,7 +69,7 @@ $button-size: 48px;
} }
.linkIcon { .linkIcon {
display: inline-block; margin-left: auto;
transform: rotate(45deg); transform: rotate(45deg);
} }
@@ -1,99 +1,79 @@
import { KeyboardEvent, memo, useEffect, useRef, useState } from 'react'; import { memo, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Link, useLocation, useSearchParams } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react'; import {
import { IoApps } from '@react-icons/all-files/io5/IoApps'; Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract'; import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand'; import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoPencilSharp } from '@react-icons/all-files/io5/IoPencilSharp'; import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside'; import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import RenameClientModal from './rename-client-modal/RenameClientModal'; import RenameClientModal from './rename-client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
function NavigationMenu() { interface NavigationMenuProps {
const location = useLocation(); isOpen: boolean;
onClose: () => void;
}
const { isFullScreen, toggleFullScreen } = useFullscreen(); function NavigationMenu(props: NavigationMenuProps) {
const { isOpen, onClose } = props;
const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore(); const { toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false)); useClickOutside(menuRef, () => onClose);
const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
document.addEventListener('mousemove', setShowMenuTrue);
return () => {
document.removeEventListener('mousemove', setShowMenuTrue);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter';
const handleFullscreen = () => toggleFullScreen();
const handleMirror = () => toggleMirror();
const showEditFormDrawer = () => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
};
return createPortal( return createPortal(
<div id='navigation-menu-portal' ref={menuRef}> <div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal isOpen={isOpen} onClose={onClose} /> <RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} />
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}> <Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}> <DrawerOverlay />
<IoApps /> <DrawerContent>
</button> <DrawerHeader>
<button className={style.button} onClick={showEditFormDrawer}> <DrawerCloseButton size='lg' />
<IoPencilSharp /> Ontime
</button> </DrawerHeader>
{showMenu && ( <DrawerBody padding={0}>
<div className={style.menuContainer} data-testid='navigation-menu'>
<div className={style.buttonsContainer}> <div className={style.buttonsContainer}>
<div <div
className={style.link} className={style.link}
tabIndex={0} tabIndex={0}
role='button' role='button'
onClick={handleFullscreen} onClick={toggle}
onKeyDown={(event) => { onKeyDown={(event) => {
isKeyEnter(event) && handleFullscreen(); isKeyEnter(event) && toggle();
}} }}
> >
Toggle Fullscreen Toggle Fullscreen
{isFullScreen ? <IoContract /> : <IoExpand />} {fullscreen ? <IoContract /> : <IoExpand />}
</div> </div>
<div <div
className={style.link} className={style.link}
tabIndex={0} tabIndex={0}
role='button' role='button'
onClick={handleMirror} onClick={() => toggleMirror()}
onKeyDown={(event) => { onKeyDown={(event) => {
isKeyEnter(event) && handleMirror(); isKeyEnter(event) && toggleMirror();
}} }}
> >
Flip Screen Flip Screen
@@ -103,20 +83,35 @@ function NavigationMenu() {
className={style.link} className={style.link}
tabIndex={0} tabIndex={0}
role='button' role='button'
onClick={onOpen} onClick={onRenameOpen}
onKeyDown={(event) => { onKeyDown={(event) => {
isKeyEnter(event) && onOpen(); isKeyEnter(event) && onRenameOpen();
}} }}
> >
Rename Client Rename Client
</div> </div>
</div> </div>
<hr className={style.separator} /> <hr className={style.separator} />
<Link to='/cuesheet' className={style.link} tabIndex={0}> <Link
to='/editor'
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline />
Editor
<IoArrowUp className={style.linkIcon} />
</Link>
<Link
to='/cuesheet'
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
tabIndex={0}
>
<IoLockClosedOutline />
Cuesheet Cuesheet
<IoArrowUp className={style.linkIcon} /> <IoArrowUp className={style.linkIcon} />
</Link> </Link>
<Link to='/op' className={style.link} tabIndex={0}> <Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
<IoLockClosedOutline />
Operator Operator
<IoArrowUp className={style.linkIcon} /> <IoArrowUp className={style.linkIcon} />
</Link> </Link>
@@ -132,11 +127,10 @@ function NavigationMenu() {
<IoArrowUp className={style.linkIcon} /> <IoArrowUp className={style.linkIcon} />
</Link> </Link>
))} ))}
</div> </DrawerBody>
)} </DrawerContent>
</div> </Drawer>
</div>, </div>,
document.body, document.body,
); );
} }
@@ -0,0 +1,16 @@
import { memo } from 'react';
import NavigationMenu from './NavigationMenu';
interface ProductionNavigationMenuProps {
isMenuOpen: boolean;
onMenuClose: () => void;
}
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
const { isMenuOpen, onMenuClose } = props;
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
}
export default memo(ProductionNavigationMenu);
@@ -0,0 +1,27 @@
import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu';
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
return (
<>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
<NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
</>
);
}
export default memo(ViewNavigationMenu);
@@ -1,5 +0,0 @@
.modalBody {
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -13,8 +13,6 @@ import {
import { setClientName } from '../../../hooks/useSocket'; import { setClientName } from '../../../hooks/useSocket';
import { useSocketClientName } from '../../../stores/connectionName'; import { useSocketClientName } from '../../../stores/connectionName';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps { interface RenameClientModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -30,7 +28,7 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
const handleRename = async () => { const handleRename = async () => {
if (newName) { if (newName) {
await setClientName(newName); setClientName(newName);
persistName(newName); persistName(newName);
onClose(); onClose();
} }
@@ -45,18 +43,18 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
motionPreset='slideInBottom' motionPreset='slideInBottom'
scrollBehavior='inside' scrollBehavior='inside'
preserveScrollBarGap preserveScrollBarGap
variant='ontime-small' variant='ontime'
> >
<ModalOverlay /> <ModalOverlay />
<ModalContent> <ModalContent>
<ModalHeader>Rename client</ModalHeader> <ModalHeader>Rename client</ModalHeader>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody className={style.modalBody}> <ModalBody>
<Input <Input
placeholder='Connection must have a name' placeholder='Connection must have a name'
defaultValue={newName} defaultValue={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
variant='ontime-filled-on-light' variant='ontime-filled'
/> />
<Button <Button
isDisabled={newName === clientName || !newName} isDisabled={newName === clientName || !newName}
@@ -1,13 +1,14 @@
@use '../../../theme/viewerDefs' as *; @use '../../../theme/viewerDefs' as *;
$progress-bar-size: 12px; $progress-bar-size: 12px;
$progress-bar-br: 6px; $progress-bar-br: 3px;
.progress-bar__bg { .progress-bar__bg {
width: 100%; width: 100%;
height: $progress-bar-size; height: $progress-bar-size;
border-radius: $progress-bar-br; border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color); background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
overflow: clip;
&--hidden { &--hidden {
display: none; display: none;
@@ -18,6 +19,5 @@ $progress-bar-br: 6px;
height: $progress-bar-size; height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color); background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear; transition: 1s linear;
border-radius: $progress-bar-br 0 0 $progress-bar-br;
transition-property: width; transition-property: width;
} }
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react'; import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark'; import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import style from './ProtectRoute.module.scss'; import style from './ProtectRoute.module.scss';
@@ -39,8 +39,8 @@ export default function PinPage(props: PinPageProps) {
return ( return (
<div className={style.container}> <div className={style.container}>
{`Ontime ${permission || ''}`} {`Ontime ${permission}`}
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}> <div className={failed ? style.pin__failed : style.pin}>
<PinInput <PinInput
type='alphanumeric' type='alphanumeric'
size='lg' size='lg'
@@ -57,8 +57,15 @@ export default function PinPage(props: PinPageProps) {
<PinInputField /> <PinInputField />
<PinInputField /> <PinInputField />
</PinInput> </PinInput>
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} /> <IconButton
</HStack> variant='ontime-filled'
aria-label='Enter'
size='lg'
isRound
icon={<IoCheckmark />}
onClick={validate}
/>
</div>
</div> </div>
); );
} }
@@ -1,29 +1,31 @@
@use '../../../theme/v2Styles' as *;
.container { .container {
display: grid; display: flex;
place-content: center; flex-direction: column;
height: 100vh; align-items: center;
padding-bottom: 30vh; padding-top: 25vh;
background: $bg-container-l1;
color: $ontime-color; color: $ontime-color;
font-family: $ontime-font-family;
font-weight: 200; font-weight: 200;
text-align: center; font-size: 3rem;
font-size: 3vw;
} }
.pin, .pin,
.pin__failed { .pin__failed {
padding: 20px; display: flex;
gap: 0.125em;
padding-block: 0.5em;
input { input {
border-radius: 50%; border-radius: 99px;
border-color: $gray-500;
&:hover {
border-color: $blue-500;
}
} }
button { button {
margin-left: 20px; margin-left: 1em;
} }
} }
@@ -35,9 +37,9 @@
@keyframes colourFade { @keyframes colourFade {
from { from {
background: $action-blue; background: $red-500;
} }
to { to {
background: rgba($action-blue, 0); background: rgba($red-500, 0);
} }
} }
@@ -31,6 +31,12 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.entry-secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:last-child) { &:not(:last-child) {
padding-bottom: 8px; padding-bottom: 8px;
} }
@@ -68,4 +74,3 @@
} }
} }
} }
@@ -2,8 +2,8 @@ import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { OntimeEvent } from 'ontime-types'; import { OntimeEvent } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { useInterval } from '../../hooks/useInterval'; import { useInterval } from '../../hooks/useInterval';
import { isStringBoolean } from '../../utils/viewUtils';
interface ScheduleContextState { interface ScheduleContextState {
events: OntimeEvent[]; events: OntimeEvent[];
@@ -4,7 +4,8 @@ import { formatTime } from '../../utils/time';
import './Schedule.scss'; import './Schedule.scss';
const formatOptions = { const formatOptions = {
format: 'hh:mm a', format12: 'hh:mm a',
format24: 'HH:mm',
}; };
interface ScheduleItemProps { interface ScheduleItemProps {
@@ -12,14 +13,13 @@ interface ScheduleItemProps {
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
title: string; title: string;
presenter?: string;
backstageEvent: boolean; backstageEvent: boolean;
colour: string; colour: string;
skip: boolean; skip: boolean;
} }
export default function ScheduleItem(props: ScheduleItemProps) { export default function ScheduleItem(props: ScheduleItemProps) {
const { selected, timeStart, timeEnd, title, presenter, backstageEvent, colour, skip } = props; const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
const start = formatTime(timeStart, formatOptions); const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions); const end = formatTime(timeEnd, formatOptions);
@@ -38,7 +38,6 @@ export default function ScheduleItem(props: ScheduleItemProps) {
</div> </div>
</div> </div>
<div className='entry-title'>{title}</div> <div className='entry-title'>{title}</div>
{presenter && <div className='entry-presenter'>{presenter}</div>}
</li> </li>
); );
} }
@@ -1,9 +1,7 @@
@use '../../../theme/ontimeColours' as *;
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: $gray-1350; color: $white-10;
.empty { .empty {
width: 100%; width: 100%;
@@ -1,11 +1,11 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg'; import EmptyImage from '../../../assets/images/empty.svg?react';
import style from './Empty.module.scss'; import style from './Empty.module.scss';
interface EmptyProps { interface EmptyProps {
text: string; text?: string;
style?: CSSProperties; style?: CSSProperties;
} }
@@ -13,8 +13,8 @@ export default function Empty(props: EmptyProps) {
const { text, ...rest } = props; const { text, ...rest } = props;
return ( return (
<div className={style.emptyContainer} {...rest}> <div className={style.emptyContainer} {...rest}>
<Emptyimage className={style.empty} /> <EmptyImage className={style.empty} />
<span className={style.text}>{text}</span> {text && <span className={style.text}>{text}</span>}
</div> </div>
); );
} }
@@ -1,10 +1,8 @@
@use "../../../../theme/_ontimeColours" as *;
.tag { .tag {
font-size: calc(1rem - 3px); font-size: calc(1rem - 3px);
letter-spacing: 0.5px; letter-spacing: 0.5px;
background-color: $gray-100; background-color: $gray-900;
color: $ui-black; color: $ui-white;
border-radius: 2px; border-radius: 2px;
padding: 0 0.25rem; padding: 0 0.25rem;
white-space: nowrap; white-space: nowrap;
@@ -1,33 +0,0 @@
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);
@@ -3,34 +3,38 @@
.title-card { .title-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 0.5rem;
}
.inline { .inline {
display: flex; display: flex;
} }
.title { .title-card__title {
font-weight: 600; font-weight: 600;
font-size: clamp(32px, 3.5vw, 50px); font-size: clamp(32px, 3.5vw, 50px);
color: var(--color-override, $viewer-color); color: var(--color-override, $viewer-color);
line-height: 1.1em; line-height: 1.1em;
} }
.subtitle, .presenter { .title-card__label {
font-size: clamp(24px, 2vw, 35px); font-size: clamp(1rem, 1.5vw, 1.5rem);
color: var(--secondary-color-override, $viewer-secondary-color); font-weight: 400;
line-height: 1.1em; color: var(--secondary-color-override, $viewer-secondary-color);
} margin-left: auto;
text-transform: uppercase;
.label { &--accent {
font-size: clamp(16px, 1.5vw, 24px); color: var(--accent-color-override, $accent-color);
font-weight: 400; }
color: var(--secondary-color-override, $viewer-secondary-color); }
margin-left: auto;
text-transform: uppercase; .title-card__secondary {
font-size: clamp(1.5rem, 2vw, 2.25rem);
&.accent { color: var(--secondary-color-override, $viewer-secondary-color);
color: var(--accent-color-override, $accent-color); line-height: 1.1em;
}
&::after {
content: '\200b';
} }
} }
@@ -4,13 +4,12 @@ import './TitleCard.scss';
interface TitleCardProps { interface TitleCardProps {
label: 'now' | 'next'; label: 'now' | 'next';
title: string | null; title: string;
subtitle: string | null; secondary?: string;
presenter: string | null;
} }
export default function TitleCard(props: TitleCardProps) { export default function TitleCard(props: TitleCardProps) {
const { label, title, subtitle, presenter } = props; const { label, title, secondary } = props;
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const accent = label === 'now'; const accent = label === 'now';
@@ -18,11 +17,12 @@ export default function TitleCard(props: TitleCardProps) {
return ( return (
<div className='title-card'> <div className='title-card'>
<div className='inline'> <div className='inline'>
<span className='presenter'>{presenter}</span> <span className='title-card__title'>{title}</span>
<span className={accent ? 'label accent' : 'label'}>{getLocalizedString(`common.${label}`)}</span> <span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
{getLocalizedString(`common.${label}`)}
</span>
</div> </div>
<div className='title'>{title}</div> <div className='title-card__secondary'>{secondary}</div>
<div className='subtitle'>{subtitle}</div>
</div> </div>
); );
} }
@@ -1,7 +1,7 @@
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react'; import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
import { isStringBoolean } from '../../utils/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { ParamField } from './types'; import { ParamField } from './types';
@@ -1,27 +1,9 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
.drawerContent {
background-color: $gray-1250;
}
.drawerHeader {
@extend .drawerContent;
color: $section-white;
}
.drawerFooter { .drawerFooter {
@extend .drawerContent;
display: flex; display: flex;
justify-content: start; justify-content: end;
gap: $element-spacing; gap: $section-spacing;
button[type='reset'] { button {
padding: 0 2em;
margin-right: auto;
}
button[type='submit'] {
padding: 0 2em; padding: 0 2em;
} }
} }
@@ -29,13 +11,16 @@
.label { .label {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray; color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem
} }
.columnSection { .columnSection {
display: flex; display: flex;
padding: $element-spacing; padding: $section-spacing 0;
flex-direction: column; flex-direction: column;
gap: $element-inner-spacing; gap: $element-spacing;
} }
.title { .title {
@@ -1,5 +1,5 @@
import { FormEvent, useEffect } from 'react'; import { FormEvent, useEffect } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
Button, Button,
Drawer, Drawer,
@@ -12,30 +12,26 @@ import {
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { ParamField } from './types'; import { ParamField } from './types';
import style from './ViewParamsEditor.module.scss'; import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue }; type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>;
/**
* Makes a new URLSearchParams object from the given params object
*/
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => { const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.map(({ defaultValue }) => String(defaultValue)); const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
acc[id] = String(defaultValue);
return acc;
}, {});
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => { return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) { if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
if (defaultValues.includes(value)) {
return newSearchParams;
}
newSearchParams.set(id, value); newSearchParams.set(id, value);
return newSearchParams;
} }
return newSearchParams; return newSearchParams;
}, new URLSearchParams()); }, new URLSearchParams());
}; };
@@ -47,8 +43,6 @@ interface EditFormDrawerProps {
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) { export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen, onClose, onOpen } = useDisclosure();
const { pathname } = useLocation();
const [storedViewParams, setStoredViewParams] = useLocalStorage<SavedViewParams>('ontime-views', {});
useEffect(() => { useEffect(() => {
const isEditing = searchParams.get('edit'); const isEditing = searchParams.get('edit');
@@ -58,37 +52,16 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
} }
}, [searchParams, onOpen]); }, [searchParams, onOpen]);
/** const handleClose = () => {
* 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 = () => {
onClose();
searchParams.delete('edit'); searchParams.delete('edit');
setSearchParams(searchParams); setSearchParams(searchParams);
onClose();
}; };
const resetParams = () => { const resetParams = () => {
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
setSearchParams(); setSearchParams();
onClose();
}; };
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => { const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -96,21 +69,21 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
onClose();
}; };
return ( return (
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'> <Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
<DrawerOverlay /> <DrawerOverlay />
<DrawerContent> <DrawerContent>
<DrawerHeader className={style.drawerHeader}> <DrawerHeader>
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' /> <DrawerCloseButton size='lg' />
Customise Customise
</DrawerHeader> </DrawerHeader>
<DrawerBody className={style.drawerContent}> <DrawerBody>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}> <form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{paramFields.map((field) => ( {paramFields.map((field) => (
<div key={field.title} className={style.columnSection}> <div key={field.title} className={style.columnSection}>
@@ -126,10 +99,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
<DrawerFooter className={style.drawerFooter}> <DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'> <Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Reset Reset to default
</Button>
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
Cancel
</Button> </Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'> <Button variant='ontime-filled' form='edit-params-form' type='submit'>
Save Save
@@ -1,18 +1,36 @@
import { UserFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { TimeFormat } from 'ontime-types/src/definitions/core/TimeFormat.type';
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils';
import { ParamField } from './types'; import { ParamField } from './types';
export const getTimeOption = (timeFormat: TimeFormat): ParamField => ({ const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
id: 'format', const customFieldOptions = Object.keys(customFields).reduce((acc, key) => {
title: '12 / 24 hour timer', return { ...acc, [`custom-${key}`]: `Custom: ${capitaliseFirstLetter(key)}` };
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences', }, additionalOptions ?? {});
type: 'option', return customFieldOptions;
values: { '12': '12 hour AM/PM', '24': '24 hour' }, };
defaultValue: timeFormat,
});
export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [ 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,
};
};
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), getTimeOption(timeFormat),
{ {
id: 'key', id: 'key',
@@ -84,46 +102,59 @@ export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [
}, },
]; ];
export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [ export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
getTimeOption(timeFormat), const secondaryOptions = makeOptionsFromCustomFields(customFields);
{ return [
id: 'hideClock', getTimeOption(timeFormat),
title: 'Hide Time Now', hideTimerSeconds,
description: 'Hides the Time Now field', {
type: 'boolean', id: 'hideClock',
defaultValue: false, title: 'Hide Time Now',
}, description: 'Hides the Time Now field',
{ type: 'boolean',
id: 'hideCards', defaultValue: false,
title: 'Hide Cards', },
description: 'Hides the Now and Next cards', {
type: 'boolean', id: 'secondary-src',
defaultValue: false, title: 'Secondary text',
}, description: 'Select the data source for the secondary text',
{ type: 'option',
id: 'hideProgress', values: secondaryOptions,
title: 'Hide progress bar', defaultValue: '',
description: 'Hides the progress bar', },
type: 'boolean', {
defaultValue: false, id: 'hideCards',
}, title: 'Hide Cards',
{ description: 'Hides the Now and Next cards',
id: 'hideMessage', type: 'boolean',
title: 'Hide Presenter Message', defaultValue: false,
description: 'Prevents the screen from displaying messages from the presenter', },
type: 'boolean', {
defaultValue: false, id: 'hideProgress',
}, title: 'Hide progress bar',
{ description: 'Hides the progress bar',
id: 'hideExternal', type: 'boolean',
title: 'Hide External', defaultValue: false,
description: 'Prevents the screen from displaying the external field', },
type: 'boolean', {
defaultValue: false, id: 'hideMessage',
}, title: 'Hide Presenter Message',
]; description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
};
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
hideTimerSeconds,
{ {
id: 'key', id: 'key',
title: 'Key Colour', title: 'Key Colour',
@@ -215,124 +246,220 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
}, },
]; ];
export const LOWER_THIRDS_OPTIONS: ParamField[] = [ export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
{ const topSourceOptions = makeOptionsFromCustomFields(customFields, {
id: 'size', title: 'Title',
title: 'Size', lowerMsg: 'Lower Third Message',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)', });
type: 'number',
placeholder: '1 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffffa (default)',
},
{
id: 'bg',
title: 'Text Background',
description: 'Text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Screen background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
},
{
id: 'fadeout',
title: 'Fadeout',
description: 'Time (in seconds) the lower third displays before fading out',
type: 'number',
placeholder: '3 (default)',
},
];
export const getBackstageOptions = (timeFormat: TimeFormat): ParamField[] => [ const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
getTimeOption(timeFormat), title: 'Title',
{ lowerMsg: 'Lower Third Message',
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',
type: 'number',
placeholder: '7 (default)',
},
];
export const getPublicOptions = (timeFormat: TimeFormat): ParamField[] => [ return [
getTimeOption(timeFormat), {
{ id: 'trigger',
id: 'hidePast', title: 'Animation Trigger',
title: 'Hide past events', description: '',
description: 'Scheduler will only show upcoming events', type: 'option',
type: 'boolean', values: {
defaultValue: false, event: 'Event Load',
}, manual: 'Manual',
{ },
id: 'stopCycle', defaultValue: 'event',
title: 'Stop cycling through event pages', },
description: 'Schedule will not auto-cycle through events', {
type: 'boolean', id: 'top-src',
defaultValue: false, title: 'Top Text',
}, description: '',
{ type: 'option',
id: 'eventsPerPage', values: topSourceOptions,
title: 'Events per page', defaultValue: 'title',
description: 'Sets the number of events on the page, can cause overlow', },
type: 'number', {
placeholder: '7 (default)', id: 'bottom-src',
}, title: 'Bottom Text',
]; description: 'Select the data source for the bottom element',
export const getStudioClockOptions = (timeFormat: TimeFormat): ParamField[] => [ type: 'option',
getTimeOption(timeFormat), values: bottomSourceOptions,
{ defaultValue: 'lowerMsg',
id: 'seconds', },
title: 'Show Seconds', {
description: 'Shows seconds in clock', id: 'top-colour',
type: 'boolean', title: 'Top Text Colour',
defaultValue: false, description: 'Top text colour in hexadecimal',
}, prefix: '#',
]; type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
};
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeFormat): ParamField[] => {
return [ return [
getTimeOption(timeFormat), getTimeOption(timeFormat),
{ {
id: 'showseconds', id: 'hidePast',
title: 'Show seconds', title: 'Hide past events',
description: 'Schedule shows hh:mm:ss', description: 'Scheduler will only show upcoming events',
type: 'boolean', type: 'boolean',
defaultValue: false, 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)',
},
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
];
};
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
];
};
export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
hideTimerSeconds,
];
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.keys(customFields).reduce((acc, key) => {
return { ...acc, [key]: `Custom: ${capitaliseFirstLetter(key)}` };
}, {});
return [
getTimeOption(timeFormat),
{ {
id: 'hidepast', id: 'hidepast',
title: 'Hide Past Events', title: 'Hide Past Events',
@@ -345,47 +472,33 @@ export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeForma
title: 'Main data field', title: 'Main data field',
description: 'Field to be shown in the first line of text', description: 'Field to be shown in the first line of text',
type: 'option', type: 'option',
values: { values: fieldOptions,
title: 'Title', defaultValue: 'title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
}, },
{ {
id: 'secondary', id: 'secondary',
title: 'Secondary data field', title: 'Secondary data field',
description: 'Field to be shown in the second line of text', description: 'Field to be shown in the second line of text',
type: 'option', type: 'option',
values: { values: fieldOptions,
title: 'Title', defaultValue: '',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
}, },
{ {
id: 'subscribe', id: 'subscribe',
title: 'Highlight Field', title: 'Highlight Field',
description: 'Choose a field to highlight', description: 'Choose a custom field to highlight',
type: 'option', type: 'option',
values: { values: customFieldSelect,
user0: userFields.user0 || 'user0', defaultValue: '',
user1: userFields.user1 || 'user1',
user2: userFields.user2 || 'user2',
user3: userFields.user3 || 'user3',
user4: userFields.user4 || 'user4',
user5: userFields.user5 || 'user5',
user6: userFields.user6 || 'user6',
user7: userFields.user7 || 'user7',
user8: userFields.user8 || 'user8',
user9: userFields.user9 || 'user9',
},
}, },
{ {
id: 'shouldEdit', id: 'shouldEdit',
title: 'Edit user field', title: 'Edit custom field',
description: 'Allows editing an events user field by long pressing on it. Needs a selected highlighted field', description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
]; ];
}; };
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
@@ -26,7 +26,6 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
useEffect(() => { useEffect(() => {
if (status === 'pending') return; if (status === 'pending') return;
if (!data) return;
const previousEditor = sessionStorage.getItem(storageKeys.editor); const previousEditor = sessionStorage.getItem(storageKeys.editor);
if (previousEditor && previousEditor === data.editorKey) { if (previousEditor && previousEditor === data.editorKey) {
@@ -53,10 +52,6 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
return savedPin == null || savedPin === '' || pin === savedPin; return savedPin == null || savedPin === '' || pin === savedPin;
} }
if (!data) {
return false;
}
if (permission === 'editor') { if (permission === 'editor') {
const correct = isValid(pin, data.editorKey); const correct = isValid(pin, data.editorKey);
if (correct) { if (correct) {
@@ -1,19 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { ALIASES } from '../api/apiConstants';
import { getAliases } from '../api/ontimeApi';
export default function useAliases() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: ALIASES,
queryFn: getAliases,
placeholderData: [],
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data, status, isFetching, isError, refetch };
}
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { CustomFields } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { CUSTOM_FIELDS } from '../api/constants';
import { getCustomFields } from '../api/customFields';
const placeholder: CustomFields = {};
export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
});
return { data: data ?? placeholder, status, isFetching, isError, refetch };
}
@@ -1,10 +1,9 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { HttpSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/apiConstants'; import { HTTP_SETTINGS } from '../api/constants';
import { logAxiosError } from '../api/apiUtils'; import { getHTTP, postHTTP } from '../api/http';
import { getHTTP, postHTTP } from '../api/ontimeApi'; import { logAxiosError } from '../api/utils';
import { httpPlaceholder } from '../models/Http'; import { httpPlaceholder } from '../models/Http';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
@@ -12,21 +11,23 @@ export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS, queryKey: HTTP_SETTINGS,
queryFn: getHTTP, queryFn: getHTTP,
placeholderData: httpPlaceholder, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt: number) => attempt * 2500, retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
}); });
// we need to jump through some hoops because of the type op port return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch };
} }
export function usePostHttpSettings() { export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({ const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP, mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error), onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }), onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
}); });
return { isPending, mutateAsync }; return { isPending, mutateAsync };
@@ -2,20 +2,20 @@ import { useQuery } from '@tanstack/react-query';
import { GetInfo } from 'ontime-types'; import { GetInfo } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_INFO } from '../api/apiConstants'; import { APP_INFO } from '../api/constants';
import { getInfo } from '../api/ontimeApi'; import { getInfo } from '../api/db';
import { ontimePlaceholderInfo } from '../models/Info'; import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() { export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({ const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO, queryKey: APP_INFO,
queryFn: getInfo, queryFn: getInfo,
placeholderData: ontimePlaceholderInfo, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
}); });
return { data, status, isError, refetch, isFetching }; return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
} }
@@ -1,20 +1,17 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants'; import { OSC_SETTINGS } from '../api/constants';
import { logAxiosError } from '../api/apiUtils'; import { getOSC, postOSC } from '../api/osc';
import { getOSC, postOSC, postOscSubscriptions } from '../api/ontimeApi'; import { logAxiosError } from '../api/utils';
import { oscPlaceholderSettings } from '../models/OscSettings'; import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() { export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS, queryKey: OSC_SETTINGS,
queryFn: async () => { queryFn: getOSC,
const oscData = await getOSC(); placeholderData: (previousData, _previousQuery) => previousData,
return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) };
},
placeholderData: oscPlaceholderSettings,
retry: 5, retry: 5,
retryDelay: (attempt: number) => attempt * 2500, retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -28,16 +25,9 @@ export function useOscSettingsMutation() {
const { isPending, mutateAsync } = useMutation({ const { isPending, mutateAsync } = useMutation({
mutationFn: postOSC, mutationFn: postOSC,
onError: (error) => logAxiosError('Error saving OSC settings', error), onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data), onSuccess: (res) => {
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }), ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
}); },
return { isPending, mutateAsync };
}
export function usePostOscSubscriptions() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postOscSubscriptions,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }), onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
}); });
return { isPending, mutateAsync }; return { isPending, mutateAsync };
@@ -1,20 +1,20 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_DATA } from '../api/apiConstants'; import { PROJECT_DATA } from '../api/constants';
import { getProjectData } from '../api/projectDataApi'; import { getProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData'; import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() { export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA, queryKey: PROJECT_DATA,
queryFn: getProjectData, queryFn: getProjectData,
placeholderData: projectDataPlaceholder, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
}); });
return { data, status, isFetching, isError, refetch }; return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
} }
@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants';
import { getProjects } from '../api/db';
const placeholderProjectList: ProjectFileListResponse = {
files: [],
lastLoadedProject: '',
};
export function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: getProjects,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? placeholderProjectList, status, refetch };
}
@@ -1,29 +1,41 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { GetRundownCached } from 'ontime-types'; import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig'; import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN } from '../api/apiConstants'; import { RUNDOWN } from '../api/constants';
import { fetchCachedRundown } from '../api/eventsApi'; import { fetchNormalisedRundown } from '../api/rundown';
const cachedRundownPlaceholder = { rundown: [], revision: -1 }; // revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
// TODO: can we leverage structural sharing to see if data has changed?
export default function useRundown() { export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<GetRundownCached>({ const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchCachedRundown, queryFn: fetchNormalisedRundown,
placeholderData: cachedRundownPlaceholder, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval, refetchInterval: queryRefetchInterval,
networkMode: 'always', networkMode: 'always',
// structuralSharing: (oldData: GetRundownCached | undefined, newData: GetRundownCached) => {
// if (oldData === undefined) {
// return cachedRundownPlaceholder;
// }
// const hasDataChanged = oldData?.revision === newData.revision;
// return hasDataChanged ? oldData : newData;
// },
}); });
return { data: data?.rundown ?? [], status, isError, refetch, isFetching }; 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 };
} }
@@ -1,20 +1,31 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { unobfuscate } from 'ontime-utils';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_SETTINGS } from '../api/apiConstants'; import { APP_SETTINGS } from '../api/constants';
import { getSettings } from '../api/ontimeApi'; import { getSettings } from '../api/settings';
import { ontimePlaceholderSettings } from '../models/OntimeSettings'; import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() { export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS, queryKey: APP_SETTINGS,
queryFn: getSettings, queryFn: getSettings,
placeholderData: ontimePlaceholderSettings, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
select: (data) => {
const unobfuscated = { ...data };
if (data.editorKey) {
unobfuscated.editorKey = unobfuscate(data.editorKey);
}
if (data.operatorKey) {
unobfuscated.operatorKey = unobfuscate(data.operatorKey);
}
return unobfuscated;
},
}); });
return { data, status, isFetching, isError, refetch }; return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
} }
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { URL_PRESETS } from '../api/constants';
import { getUrlPresets } from '../api/urlPresets';
export default function useUrlPresets() {
const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? [], status, isError, refetch };
}
@@ -1,20 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchInterval } from '../../ontimeConfig';
import { USERFIELDS } from '../api/apiConstants';
import { getUserFields } from '../api/ontimeApi';
import { userFieldsPlaceholder } from '../models/UserFields';
export default function useUserFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: USERFIELDS,
queryFn: getUserFields,
placeholderData: userFieldsPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
});
return { data, status, isFetching, isError, refetch };
}
@@ -1,20 +1,20 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { VIEW_SETTINGS } from '../api/apiConstants'; import { VIEW_SETTINGS } from '../api/constants';
import { getView } from '../api/ontimeApi'; import { getView } from '../api/viewSettings';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type'; import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() { export default function useViewSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: VIEW_SETTINGS, queryKey: VIEW_SETTINGS,
queryFn: getView, queryFn: getView,
placeholderData: viewsSettingsPlaceholder, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always', networkMode: 'always',
}); });
return { data, status, isError, refetch, isFetching }; return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching };
} }
+227 -76
View File
@@ -1,13 +1,13 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types'; import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils'; import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/apiConstants'; import { RUNDOWN } from '../api/constants';
import { logAxiosError } from '../api/apiUtils';
import { import {
ReorderEntry, ReorderEntry,
requestApplyDelay, requestApplyDelay,
requestBatchPutEvents,
requestDelete, requestDelete,
requestDeleteAll, requestDeleteAll,
requestEventSwap, requestEventSwap,
@@ -15,25 +15,23 @@ import {
requestPutEvent, requestPutEvent,
requestReorderEvent, requestReorderEvent,
SwapEntry, SwapEntry,
} from '../api/eventsApi'; } from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
/** /**
* @description Set of utilities for events * @description Set of utilities for events
*/ */
export const useEventAction = () => { export const useEventAction = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const eventSettings = useEditorSettings((state) => state.eventSettings); const { defaultPublic, linkPrevious, defaultDuration } = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
/** /**
* Calls mutation to add new event * Calls mutation to add new event
* @private * @private
*/ */
const _addEventMutation = useMutation({ const _addEventMutation = useMutation({
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
mutationFn: requestPostEvent, mutationFn: requestPostEvent,
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
@@ -45,11 +43,12 @@ export const useEventAction = () => {
after?: string; after?: string;
}; };
type EventOptions = BaseOptions & { type EventOptions = BaseOptions &
defaultPublic?: boolean; Partial<{
lastEventId?: string; defaultPublic: boolean;
startTimeIsLastEnd?: boolean; linkPrevious: boolean;
}; lastEventId: string;
}>;
/** /**
* Adds an event to rundown * Adds an event to rundown
@@ -61,35 +60,31 @@ export const useEventAction = () => {
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) { if (isOntimeEvent(newEvent)) {
const applicationOptions = { const applicationOptions = {
defaultPublic: options?.defaultPublic ?? defaultPublic,
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
lastEventId: options?.lastEventId,
after: options?.after, after: options?.after,
defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
}; };
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? []; if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
newEvent.linkStart = applicationOptions.lastEventId;
if (newEvent?.cue === undefined) { } else if (applicationOptions?.lastEventId) {
newEvent.cue = getCueCandidate(rundown, options?.after); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
} const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const { rundown } = rundownData;
// hard coding duration value to be as expected for now const previousEvent = rundown[applicationOptions.lastEventId];
// this until timeOptions gets implemented if (isOntimeEvent(previousEvent)) {
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.find((event) => event.id === applicationOptions.lastEventId);
if (previousEvent !== undefined && previousEvent.type === 'event') {
newEvent.timeStart = previousEvent.timeEnd; newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd;
} }
} }
if (applicationOptions.defaultPublic) { if (applicationOptions.defaultPublic) {
newEvent.isPublic = true; newEvent.isPublic = true;
} }
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
newEvent.duration = forgivingStringToMillis(defaultDuration);
}
} }
// handle adding options that concern all event type // handle adding options that concern all event type
@@ -98,13 +93,12 @@ export const useEventAction = () => {
} }
try { try {
// @ts-expect-error -- we know that the object is well formed now
await _addEventMutation.mutateAsync(newEvent); await _addEventMutation.mutateAsync(newEvent);
} catch (error) { } catch (error) {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
}, },
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd], [_addEventMutation, defaultDuration, defaultPublic, linkPrevious],
); );
/** /**
@@ -119,19 +113,15 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const eventId = newEvent.id;
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN); if (previousData && eventId) {
if (previousData) {
// optimistically update object // optimistically update object
const optimisticRundown = [...previousData.rundown]; const newRundown = { ...previousData.rundown };
const index = optimisticRundown.findIndex((event) => event.id === newEvent.id); // @ts-expect-error -- we expect the events to be of same type
if (index > -1) { newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
// @ts-expect-error -- we expect the event types to match queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
optimisticRundown[index] = { ...optimisticRundown[index], ...newEvent };
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -163,6 +153,161 @@ export const useEventAction = () => {
[_updateEventMutation], [_updateEventMutation],
); );
const updateCustomField = useCallback(
async (eventId: string, field: string, value: string) => {
updateEvent({ id: eventId, custom: { [field]: { value } } });
},
[updateEvent],
);
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 * Calls mutation to delete an event
* @private * @private
@@ -175,20 +320,19 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const optimisticRundown = [...previousData.rundown]; const newOrder = previousData.order.filter((id) => id !== eventId);
const index = optimisticRundown.findIndex((event) => event.id === eventId); const newRundown = { ...previousData.rundown };
if (index > -1) { delete newRundown[eventId];
optimisticRundown.splice(index, 1);
queryClient.setQueryData(RUNDOWN, { queryClient.setQueryData(RUNDOWN, {
rundown: optimisticRundown, order: newOrder,
revision: -1, rundown: newRundown,
}); revision: -1,
} });
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -233,10 +377,10 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
// optimistically update object // optimistically update object
queryClient.setQueryData(RUNDOWN, { rundown: [], revision: -1 }); queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 });
// Return a context with the previous and new events // Return a context with the previous and new events
return { previousData }; return { previousData };
@@ -304,15 +448,13 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const optimisticRundown = [...previousData.rundown]; const newOrder = reorderArray(previousData.order, data.from, data.to);
const [reorderedItem] = optimisticRundown.splice(data.from, 1);
optimisticRundown.splice(data.to, 0, reorderedItem);
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 }); queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 });
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -338,9 +480,9 @@ export const useEventAction = () => {
async (eventId: string, from: number, to: number) => { async (eventId: string, from: number, to: number) => {
try { try {
const reorderObject: ReorderEntry = { const reorderObject: ReorderEntry = {
eventId: eventId, eventId,
from: from, from,
to: to, to,
}; };
await _reorderEventMutation.mutateAsync(reorderObject); await _reorderEventMutation.mutateAsync(reorderObject);
} catch (error) { } catch (error) {
@@ -362,15 +504,22 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const fromEventIndex = previousData.rundown.findIndex((event) => event.id === from); const newRundown = { ...previousData.rundown };
const toEventIndex = previousData.rundown.findIndex((event) => event.id === to); const eventA = previousData.rundown[from];
const eventB = previousData.rundown[to];
const optimisticRundown = swapOntimeEvents(previousData.rundown, fromEventIndex, toEventIndex); if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return;
}
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 }); const { newA, newB } = swapEventData(eventA, eventB);
newRundown[from] = newA;
newRundown[to] = newB;
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
} }
// Return a context with the previous events // Return a context with the previous events
@@ -394,8 +543,6 @@ export const useEventAction = () => {
*/ */
const swapEvents = useCallback( const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => { async ({ from, to }: SwapEntry) => {
// TODO: before calling `/swapEvents`,
// we should determine the events are of type `OntimeEvent`
try { try {
await _swapEvents.mutateAsync({ from, to }); await _swapEvents.mutateAsync({ from, to });
} catch (error) { } catch (error) {
@@ -407,11 +554,15 @@ export const useEventAction = () => {
return { return {
addEvent, addEvent,
updateEvent, applyDelay,
batchUpdateEvents,
deleteEvent, deleteEvent,
deleteAllEvents, deleteAllEvents,
applyDelay, linkTimer,
reorderEvent, reorderEvent,
swapEvents, swapEvents,
updateEvent,
updateTimer,
updateCustomField,
}; };
}; };

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