Compare commits

..

3 Commits

Author SHA1 Message Date
Carlos Valente 7eab0bb300 chore: version bump (#885) 2024-04-13 10:16:00 +02:00
Carlos Valente 55d1aca9b6 V3 (#657)
* 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>
2024-04-13 10:06:46 +02:00
Fabian Posenau debdbd1c5a Fix danger time not shown (#775)
* wrong order of operations

* chore: version bump
2024-02-14 12:44:06 +01:00
640 changed files with 26703 additions and 21624 deletions
+13 -22
View File
@@ -8,37 +8,28 @@
"jest": true
},
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"eslint-config-prettier"
],
"plugins": [
"@typescript-eslint",
"prettier"
],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"],
"plugins": ["@typescript-eslint", "prettier"],
"overrides": [
{
"files": [
"e2e/**/**.spec.ts",
"e2e/**/**.test.ts"
],
"extends": [
"plugin:playwright/playwright-test"
]
"files": ["e2e/**/**.spec.ts", "e2e/**/**.test.ts"],
"extends": ["plugin:playwright/playwright-test"]
}
],
"rules": {
"no-useless-concat": "warn",
"prefer-template": "warn",
"no-throw-literal": "error",
"no-console": [
"warn",
{
"allow": [
"warn",
"error"
]
"allow": ["warn", "error"]
}
],
"no-restricted-imports": [
"error",
{
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
}
],
"@typescript-eslint/no-non-null-assertion": "warn",
@@ -59,4 +50,4 @@
}
]
}
}
}
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
@@ -49,7 +49,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
@@ -82,7 +82,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18.18.2
- name: Setup pnpm
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
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
@@ -73,7 +73,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
@@ -98,3 +98,9 @@ jobs:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v3
with:
name: automated-screenshots
path: automated-screenshots/
retention-days: 14
+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
playwright-report
/playwright/.cache/
automated-screenshots
# production
build/
@@ -44,3 +45,6 @@ apps/server/src/preloaded-db/db.json
# versioning file
**/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.
Other dependencies will be installed as part of the setup
- __node__ (>=16.16)
- __pnpm__ (>=7)
- __node__ (~18.18.2)
- __pnpm__ (>=8)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
@@ -97,3 +97,10 @@ Other useful commands
- __List running processes__ by running `docker ps`
- __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
# 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)
[![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
@@ -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.
This will then populate a series of screens which are available to be rendered by any device in the Network.
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video
outputs.
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png)
![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
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.
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
@@ -59,7 +60,7 @@ IP.ADDRESS:4001/editor > the control interface, same as the app
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)
@@ -84,7 +85,7 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
- WebSockets
- [x] Roll mode: run standalone using the system clock
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
- [x] [Countdown to anything!](https://ontime.gitbook.io/v2/views/countdown): have
- [x] [Countdown to anything!](https://docs.getontime.no/features/count-to-anything/): have
a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [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
that can run in the browser).
<br />
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
how to get you started and read the docs about
the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-apis#osc-and-websocket-api)
We have prepared a few resources to help here:
- 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)
- 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
@@ -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)
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
@@ -192,7 +198,7 @@ Information about the project setup can be found in the [development documentati
# 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
+25 -24
View File
@@ -1,36 +1,36 @@
{
"name": "ontime-ui",
"version": "2.28.16",
"version": "3.0.0-beta.1",
"private": true,
"type": "module",
"dependencies": {
"@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.46.0",
"@sentry/tracing": "^7.46.0",
"@tanstack/react-query": "^5.8.4",
"@tanstack/react-query-devtools": "^5.8.4",
"@tanstack/react-table": "^8.9.2",
"@sentry/react": "^7.92.0",
"@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3",
"autosize": "^6.0.1",
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.2.3",
"deepmerge": "^4.3.0",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0",
"react-hook-form": "^7.43.5",
"react-qr-code": "^2.0.11",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1",
"zustand": "^4.3.6"
"zustand": "^4.5.0"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -45,7 +45,8 @@
"lint-staged": "eslint",
"test": "vitest",
"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": {
"production": [
@@ -60,7 +61,7 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "^0.4.0",
"@sentry/vite-plugin": "^2.14.0",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
@@ -71,7 +72,7 @@
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^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-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.6.0",
@@ -85,11 +86,11 @@
"ontime-utils": "workspace:*",
"prettier": "^3.0.3",
"sass": "^1.57.1",
"typescript": "^5.2.2",
"vite": "^4.3.1",
"vite-plugin-compression2": "^0.9.0",
"vite-plugin-svgr": "^2.4.0",
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.30.1"
"typescript": "^5.4.3",
"vite": "^5.1.0",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.2.2"
}
}
+1 -2
View File
@@ -47,9 +47,8 @@ function App() {
}
};
}, [isElectron, sendToElectron]);
return (
<ChakraProvider resetCSS theme={theme}>
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
+16 -24
View File
@@ -1,12 +1,13 @@
import { lazy, Suspense } from 'react';
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';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
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 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 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 STimer = withAlias(withData(TimerView));
const SMinimalTimer = withAlias(withData(MinimalTimerView));
const SClock = withAlias(withData(ClockView));
const SCountdown = withAlias(withData(Countdown));
const SBackstage = withAlias(withData(Backstage));
const SPublic = withAlias(withData(Public));
const SLowerThird = withAlias(withData(Lower));
const SStudio = withAlias(withData(StudioClock));
const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView));
const SClock = withPreset(withData(ClockView));
const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage));
const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
const Info = lazy(() => import('./features/info/InfoExport'));
export default function AppRouter() {
return (
<Suspense fallback={null}>
<Routes>
<Route path='/' element={<Navigate to='/timer' />} />
<Route path='/speaker' element={<STimer />} />
<Route path='/presenter' element={<STimer />} />
<Route path='/stage' element={<STimer />} />
<Route path='/timer' element={<STimer />} />
<Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/minimalTimer' element={<SMinimalTimer />} />
<Route path='/simpleTimer' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} />
<Route path='/sm' element={<SBackstage />} />
<Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
<Route path='/studio' element={<SStudio />} />
{/*/!* Lower cannot have fallback *!/*/}
<Route path='/lower' element={<SLowerThird />} />
<Route path='/op' element={<Operator />} />
<Route path='/operator' element={<Operator />} />
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/cuelist' element={<Cuesheet />} />
<Route path='/table' element={<Cuesheet />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
@@ -94,10 +86,10 @@ export default function AppRouter() {
}
/>
<Route
path='/info'
path='/log'
element={
<EditorFeatureWrapper>
<Info />
<Log />
</EditorFeatureWrapper>
}
/>
@@ -1,25 +1,31 @@
// REST stuff
export const PROJECT_DATA = ['project'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN = ['rundown'];
// keys in tanstack store
export const APP_INFO = ['appinfo'];
export const OSC_SETTINGS = ['oscSettings'];
export const HTTP_SETTINGS = ['httpSettings'];
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 SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
// resolve location
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
// resolve port
const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const apiEntryUrl = `${serverURL}/data`;
export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`;
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 { nowInMillis } from '../utils/time';
/**
* Utility unrwap a potential axios error
* @param error
* @returns
*/
export function maybeAxiosError(error: unknown) {
if (axios.isAxiosError(error)) {
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) {
const message = `${prepend}: ${maybeAxiosError(error)}`;
@@ -42,12 +52,32 @@ export function logAxiosError(prepend: string, error: unknown) {
* Utility function invalidates react-query caches
*/
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] });
await ontimeQueryClient.invalidateQueries();
}
/**
* Creates blob from content
* @param fileContent
* @param type
* @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,
Tooltip,
} 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';
interface QuitIconBtnProps {
clickHandler: () => void;
size?: Size;
disabled?: boolean;
}
@@ -42,7 +40,7 @@ const quitBtnStyle = {
};
export default function QuitIconBtn(props: QuitIconBtnProps) {
const { clickHandler, size = 'lg', disabled, ...rest } = props;
const { clickHandler, disabled, ...rest } = props;
const [isOpen, setIsOpen] = useState(false);
const { emitInfo } = useEmitLog();
const onClose = () => setIsOpen(false);
@@ -67,8 +65,8 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<Tooltip label='Quit Application'>
<IconButton
aria-label='Quit Application'
size={size}
icon={<FiPower />}
size='lg'
icon={<IoPowerOutline />}
onClick={() => setIsOpen(true)}
isDisabled={disabled}
{...quitBtnStyle}
@@ -81,7 +79,7 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
Ontime Shutdown
</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>
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
Cancel
@@ -2,7 +2,7 @@ import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: (event?: MouseEvent) => void | Promise<void>;
clickHandler: (event: MouseEvent) => void | Promise<void>;
tooltip: string;
openDelay?: number;
}
@@ -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 {
position: absolute;
cursor: pointer;
}
.contextMenuBackdrop {
@@ -1,11 +1,13 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { Fragment, ReactElement } from 'react';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { ReactElement } from 'react';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
@@ -13,14 +15,23 @@ type ContextMenuCoords = {
y: number;
};
export type Option = {
export type OptionWithoutGroup = {
label: string;
isDisabled?: boolean;
icon: IconType;
onClick: () => void;
withDivider?: boolean;
isDisabled?: boolean;
};
export type OptionWithGroup = {
label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[];
};
export type Option = OptionWithoutGroup | OptionWithGroup;
const isOptionWithGroup = (option: Option): option is OptionWithGroup => 'group' in option;
type ContextMenuStore = {
coords: ContextMenuCoords;
options: Option[];
@@ -57,7 +68,7 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
<>
{children}
<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
className={style.contextMenuButton}
aria-hidden
@@ -69,14 +80,17 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
}}
/>
<MenuList>
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
<Fragment key={label}>
{withDivider && <MenuDivider />}
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</Fragment>
))}
{options.map((option) =>
isOptionWithGroup(option) ? (
<MenuGroup key={option.label} title={option.label}>
{option.group.map((groupOption) => (
<ContextMenuOption key={groupOption.label} {...groupOption} />
))}
</MenuGroup>
) : (
<ContextMenuOption key={option.label} {...option} />
),
)}
</MenuList>
</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;
className?: string;
size?: Size;
disabled?: boolean;
}
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);
return (
<Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup size={size} isAttached className={className}>
<Button variant='ontime-subtle' tabIndex={-1}>
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
{children}
</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>
</Tooltip>
);
@@ -1,5 +1,3 @@
@use '../../../theme/v2Styles' as *;
.delaySymbol {
svg {
font-size: 1.5rem;
@@ -3,7 +3,7 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react';
import { runtime } from '@/common/stores/runtime';
import { runtimeStore } from '@/common/stores/runtime';
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
import style from './ErrorBoundary.module.scss';
@@ -23,13 +23,13 @@ class ErrorBoundary extends React.Component {
componentDidCatch(error, info) {
this.setState({
error: error,
error,
errorInfo: info,
});
Sentry.withScope((scope) => {
scope.setExtras('error', error);
scope.setExtras('store', runtime.getState());
scope.setExtras('store', runtimeStore.getState());
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
@@ -1,5 +1,3 @@
@use '../../../theme/v2Styles' as *;
.errorContainer {
width: 100%;
height: 100%;
@@ -1,14 +1,12 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeColours' as *;
.link {
display: flex;
align-items: center;
gap: 4px;
gap: 0.25rem;
color: $blue-500;
transition-property: color;
transition-duration: $transition-time-action;
width: fit-content;
&.inline {
display: inline-flex;
@@ -1,18 +1,18 @@
import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
import { openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import style from './ModalLink.module.scss';
import style from './ExternalLink.module.scss';
interface ModalLinkProps {
interface ExternalLinkProps {
href: string;
children: ReactNode;
inline?: boolean;
}
export default function ModalLink(props: ModalLinkProps) {
export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props;
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';
export const AutoTextArea = (props: TextareaProps) => {
const { value } = props;
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
@@ -12,7 +14,7 @@ export const AutoTextArea = (props: TextareaProps) => {
return () => {
autosize.destroy(node);
};
}, []);
}, [value]);
return (
<Textarea
@@ -6,21 +6,24 @@ import style from './SwatchSelect.module.scss';
interface SwatchProps {
color: string;
onClick: (color: string) => void;
onClick?: (color: string) => void;
isSelected?: boolean;
}
export default function Swatch(props: SwatchProps) {
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) {
return (
<div className={`${classes} ${style.center}`} onClick={() => onClick('')}>
<div className={`${classes} ${style.center}`} onClick={handleClick}>
<IoBan />
</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 {
display: flex;
gap: 4px;
flex-wrap: wrap;
gap: 0.5rem
}
.swatch {
cursor: pointer;
aspect-ratio: 1;
width: 2rem;
height: 2rem;
border-radius: 16px;
border: 4px solid #262626;
aspect-ratio: 1;
border-radius: 99px;
border: 2px solid $gray-1200;
&.selected {
border: 2px solid #578AF4;
border: 2px solid $blue-500;
}
&.selectable {
cursor: pointer;
}
}
.center {
display: grid;
place-content: center;
color: #578AF4;
}
color: $ui-white;
}
@@ -1,15 +1,13 @@
import { useCallback } from 'react';
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
import Swatch from './Swatch';
import style from './SwatchSelect.module.scss';
interface ColourInputProps {
value: string;
name: EditorUpdateFields;
handleChange: (newValue: EditorUpdateFields, name: string) => void;
name: 'colour';
handleChange: (newValue: 'colour', name: string) => void;
}
const colours = [
@@ -1,5 +1,3 @@
@use '../../../../theme/v2Styles' as *;
$input-font-size: 15px;
.delayInput {
@@ -21,6 +21,7 @@ export default function DelayInput(props: DelayInputProps) {
// avoid wrong submit on cancel
let ignoreChange = false;
// set internal value on duration change
useEffect(() => {
if (typeof duration === 'undefined') {
return;
@@ -1,6 +1,3 @@
@use "../../../../theme/_ontimeColours" as *;
@use "../../../../theme/_v2Styles" as *;
.swatch {
width: 24px;
height: 24px;
@@ -1,7 +1,7 @@
import { useCallback, useRef } from 'react';
import { Input, Textarea } from '@chakra-ui/react';
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor';
import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditor';
import { Size } from '../../../models/Util.type';
import useReactiveTextInput from './useReactiveTextInput';
@@ -1,37 +1,4 @@
@use "../../../../theme/v2Styles" as *;
$input-font-size: 15px;
$input-delayed-border-color: #E69056;
.timeInput {
width: fit-content !important;
.inputLeft {
max-width: fit-content;
}
.inputLeft,
.inputButton {
aspect-ratio: 1;
}
.inputField {
font-size: $input-font-size;
letter-spacing: 1px;
width: 7.5em;
padding: 0 0 0 2.6em;
}
.warn {
&::after {
content: "*";
color: $warning-orange;
}
}
&.delayed {
.inputField {
border: 1px solid $input-delayed-border-color;
}
}
width: 6.5em;
}
@@ -1,42 +1,23 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import { useEmitLog } from '../../../stores/logger';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import { cx } from '../../../utils/styleUtils';
import { TimeEntryField } from '../../../utils/timesManager';
import style from './TimeInput.module.scss';
interface TimeInputProps {
id?: TimeEntryField;
name: TimeEntryField;
submitHandler: (field: TimeEntryField, value: number) => void;
interface TimeInputProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
delay?: number;
placeholder: string;
previousEnd?: number;
tooltip?: string;
disabled?: boolean;
className?: string;
}
function ButtonInitial(name: TimeEntryField) {
if (name === 'timeStart') return 'S';
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;
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -72,32 +53,20 @@ export default function TimeInput(props: TimeInputProps) {
return false;
}
let newValMillis = 0;
// check for known aliases
if (newValue === 'p' || newValue === 'prev' || newValue === 'previous') {
// string to pass should be the time of the end before
if (previousEnd != null) {
newValMillis = previousEnd;
}
} else if (newValue.startsWith('+') || newValue.startsWith('p+') || newValue.startsWith('p +')) {
// string to pass should add to the end before
const val = newValue.substring(1);
newValMillis = previousEnd + forgivingStringToMillis(val);
} else {
// convert entered value to milliseconds
newValMillis = forgivingStringToMillis(newValue);
// we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) {
submitHandler(name, newValue);
}
// check if time is different from before
if (newValMillis === time) return false;
// update entry
submitHandler(name, newValMillis);
const valueInMillis = forgivingStringToMillis(newValue);
if (valueInMillis === time) {
return false;
}
submitHandler(name, newValue);
return true;
},
[name, previousEnd, submitHandler, time],
[name, submitHandler, time],
);
/**
@@ -107,15 +76,11 @@ export default function TimeInput(props: TimeInputProps) {
const validateAndSubmit = useCallback(
(newValue: string) => {
const success = handleSubmit(newValue);
if (success) {
const ms = forgivingStringToMillis(newValue);
const delayed = name === 'timeEnd' ? Math.max(0, ms + delay) : Math.max(0, ms + delay);
setValue(millisToString(delayed));
} else {
if (!success) {
resetValue();
}
},
[delay, handleSubmit, name, resetValue],
[handleSubmit, resetValue],
);
/**
@@ -126,9 +91,6 @@ export default function TimeInput(props: TimeInputProps) {
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
inputRef.current?.blur();
validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value);
}
if (event.key === 'Escape') {
ignoreChange.current = true;
@@ -136,7 +98,7 @@ export default function TimeInput(props: TimeInputProps) {
resetValue();
}
},
[resetValue, validateAndSubmit],
[resetValue],
);
const onBlurHandler = useCallback(
@@ -155,51 +117,26 @@ export default function TimeInput(props: TimeInputProps) {
resetValue();
}, [resetValue, time]);
const isDelayed = delay !== 0;
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]);
const timeInputClasses = cx([style.timeInput, className]);
return (
<InputGroup size='sm' className={inputClasses}>
<InputLeftElement className={style.inputLeft}>
<Tooltip label={TooltipLabel} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<Button
size='sm'
variant='ontime-subtle-white'
className={buttonClasses}
tabIndex={-1}
border={isDelayed ? '1px solid #E69056' : '1px solid transparent'}
borderRight='1px solid transparent'
borderRadius='2px 0 0 2px'
>
{ButtonText}
</Button>
</Tooltip>
</InputLeftElement>
<Input
ref={inputRef}
id={id}
data-testid={`time-input-${name}`}
className={style.inputField}
type='text'
placeholder={placeholder}
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
/>
</InputGroup>
<Input
disabled={disabled}
size='sm'
ref={inputRef}
data-testid={`time-input-${name}`}
className={timeInputClasses}
fontSize='1rem'
type='text'
placeholder={placeholder}
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
/>
);
}
@@ -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;
z-index: 10;
width: 100%;
height: 100%;
display: grid;
place-content: center;
top: 0;
background-color: $white-60;
background-color: $black-10;
backdrop-filter: blur(5px);
}
$loader-size: 48px;
.loader {
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 './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: number | null;
now: MaybeNumber;
complete: number;
normalColor: string;
warning: number;
warning?: MaybeNumber;
warningColor: string;
danger: number;
danger?: MaybeNumber;
dangerColor: string;
hidden?: boolean;
className?: string;
@@ -17,10 +19,10 @@ interface MultiPartProgressBar {
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const 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 warningWidth = clamp((warning / complete) * 100, 0, 100);
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
return (
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
@@ -35,7 +37,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
<div className='multiprogress-bar__indicator' style={{ width: `${percentRemaining}%` }} />
</>
)}
</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/ontimeColours' as *;
$menu-bg: $gray-1200;
$menu-hover-bg: $gray-1350;
$menu-focus-bg: $gray-1300;
$icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 48px;
$button-size: 3rem;
.buttonContainer {
display: flex;
@@ -23,7 +20,7 @@ $button-size: 48px;
position: fixed;
left: 0;
top: 0;
z-index: 2;
z-index: 12;
&.hidden {
opacity: 0;
@@ -31,7 +28,7 @@ $button-size: 48px;
}
.button {
font-size: 24px;
font-size: 1.5rem;
color: $icon-color;
background-color: $button-bg;
width: $button-size;
@@ -46,32 +43,10 @@ $button-size: 48px;
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 {
@include action-link;
justify-content: space-between;
padding: 0.5rem 1rem;
cursor: pointer;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
&:hover {
background-color: $menu-hover-bg;
@@ -94,7 +69,7 @@ $button-size: 48px;
}
.linkIcon {
display: inline-block;
margin-left: auto;
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 { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { Link } from 'react-router-dom';
import {
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 { IoContract } from '@react-icons/all-files/io5/IoContract';
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 { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import RenameClientModal from './rename-client-modal/RenameClientModal';
import style from './NavigationMenu.module.scss';
function NavigationMenu() {
const location = useLocation();
interface NavigationMenuProps {
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 [showButton, setShowButton] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false));
const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev);
// 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);
};
useClickOutside(menuRef, () => onClose);
return createPortal(
<div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal isOpen={isOpen} onClose={onClose} />
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
<IoApps />
</button>
<button className={style.button} onClick={showEditFormDrawer}>
<IoPencilSharp />
</button>
{showMenu && (
<div className={style.menuContainer} data-testid='navigation-menu'>
<RenameClientModal isOpen={isRenameOpen} onClose={onRenameClose} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>
<DrawerCloseButton size='lg' />
Ontime
</DrawerHeader>
<DrawerBody padding={0}>
<div className={style.buttonsContainer}>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={handleFullscreen}
onClick={toggle}
onKeyDown={(event) => {
isKeyEnter(event) && handleFullscreen();
isKeyEnter(event) && toggle();
}}
>
Toggle Fullscreen
{isFullScreen ? <IoContract /> : <IoExpand />}
{fullscreen ? <IoContract /> : <IoExpand />}
</div>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={handleMirror}
onClick={() => toggleMirror()}
onKeyDown={(event) => {
isKeyEnter(event) && handleMirror();
isKeyEnter(event) && toggleMirror();
}}
>
Flip Screen
@@ -103,20 +83,35 @@ function NavigationMenu() {
className={style.link}
tabIndex={0}
role='button'
onClick={onOpen}
onClick={onRenameOpen}
onKeyDown={(event) => {
isKeyEnter(event) && onOpen();
isKeyEnter(event) && onRenameOpen();
}}
>
Rename Client
</div>
</div>
<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
<IoArrowUp className={style.linkIcon} />
</Link>
<Link to='/op' className={style.link} tabIndex={0}>
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
<IoLockClosedOutline />
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
@@ -132,11 +127,10 @@ function NavigationMenu() {
<IoArrowUp className={style.linkIcon} />
</Link>
))}
</div>
)}
</div>
</DrawerBody>
</DrawerContent>
</Drawer>
</div>,
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 { useSocketClientName } from '../../../stores/connectionName';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps {
isOpen: boolean;
onClose: () => void;
@@ -30,7 +28,7 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
const handleRename = async () => {
if (newName) {
await setClientName(newName);
setClientName(newName);
persistName(newName);
onClose();
}
@@ -45,18 +43,18 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
motionPreset='slideInBottom'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-small'
variant='ontime'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rename client</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.modalBody}>
<ModalBody>
<Input
placeholder='Connection must have a name'
defaultValue={newName}
onChange={(e) => setNewName(e.target.value)}
variant='ontime-filled-on-light'
variant='ontime-filled'
/>
<Button
isDisabled={newName === clientName || !newName}
@@ -1,13 +1,14 @@
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 12px;
$progress-bar-br: 6px;
$progress-bar-br: 3px;
.progress-bar__bg {
width: 100%;
height: $progress-bar-size;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
overflow: clip;
&--hidden {
display: none;
@@ -18,6 +19,5 @@ $progress-bar-br: 6px;
height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
border-radius: $progress-bar-br 0 0 $progress-bar-br;
transition-property: width;
}
@@ -1,5 +1,5 @@
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 style from './ProtectRoute.module.scss';
@@ -39,8 +39,8 @@ export default function PinPage(props: PinPageProps) {
return (
<div className={style.container}>
{`Ontime ${permission || ''}`}
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
{`Ontime ${permission}`}
<div className={failed ? style.pin__failed : style.pin}>
<PinInput
type='alphanumeric'
size='lg'
@@ -57,8 +57,15 @@ export default function PinPage(props: PinPageProps) {
<PinInputField />
<PinInputField />
</PinInput>
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
</HStack>
<IconButton
variant='ontime-filled'
aria-label='Enter'
size='lg'
isRound
icon={<IoCheckmark />}
onClick={validate}
/>
</div>
</div>
);
}
@@ -1,29 +1,31 @@
@use '../../../theme/v2Styles' as *;
.container {
display: grid;
place-content: center;
height: 100vh;
padding-bottom: 30vh;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 25vh;
background: $bg-container-l1;
color: $ontime-color;
font-family: $ontime-font-family;
font-weight: 200;
text-align: center;
font-size: 3vw;
font-size: 3rem;
}
.pin,
.pin__failed {
padding: 20px;
display: flex;
gap: 0.125em;
padding-block: 0.5em;
input {
border-radius: 50%;
border-radius: 99px;
border-color: $gray-500;
&:hover {
border-color: $blue-500;
}
}
button {
margin-left: 20px;
margin-left: 1em;
}
}
@@ -35,9 +37,9 @@
@keyframes colourFade {
from {
background: $action-blue;
background: $red-500;
}
to {
background: rgba($action-blue, 0);
background: rgba($red-500, 0);
}
}
}
@@ -31,6 +31,12 @@
text-overflow: ellipsis;
}
.entry-secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:last-child) {
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 { OntimeEvent } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { useInterval } from '../../hooks/useInterval';
import { isStringBoolean } from '../../utils/viewUtils';
interface ScheduleContextState {
events: OntimeEvent[];
@@ -4,7 +4,8 @@ import { formatTime } from '../../utils/time';
import './Schedule.scss';
const formatOptions = {
format: 'hh:mm a',
format12: 'hh:mm a',
format24: 'HH:mm',
};
interface ScheduleItemProps {
@@ -12,14 +13,13 @@ interface ScheduleItemProps {
timeStart: number;
timeEnd: number;
title: string;
presenter?: string;
backstageEvent: boolean;
colour: string;
skip: boolean;
}
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 end = formatTime(timeEnd, formatOptions);
@@ -38,7 +38,6 @@ export default function ScheduleItem(props: ScheduleItemProps) {
</div>
</div>
<div className='entry-title'>{title}</div>
{presenter && <div className='entry-presenter'>{presenter}</div>}
</li>
);
}
@@ -1,9 +1,7 @@
@use '../../../theme/ontimeColours' as *;
.emptyContainer {
width: 100%;
text-align: center;
color: $gray-1350;
color: $white-10;
.empty {
width: 100%;
@@ -1,11 +1,11 @@
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';
interface EmptyProps {
text: string;
text?: string;
style?: CSSProperties;
}
@@ -13,8 +13,8 @@ export default function Empty(props: EmptyProps) {
const { text, ...rest } = props;
return (
<div className={style.emptyContainer} {...rest}>
<Emptyimage className={style.empty} />
<span className={style.text}>{text}</span>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
</div>
);
}
@@ -1,10 +1,8 @@
@use "../../../../theme/_ontimeColours" as *;
.tag {
font-size: calc(1rem - 3px);
letter-spacing: 0.5px;
background-color: $gray-100;
color: $ui-black;
background-color: $gray-900;
color: $ui-white;
border-radius: 2px;
padding: 0 0.25rem;
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 {
display: flex;
flex-direction: column;
gap: 8px;
gap: 0.5rem;
}
.inline {
display: flex;
}
.inline {
display: flex;
}
.title {
font-weight: 600;
font-size: clamp(32px, 3.5vw, 50px);
color: var(--color-override, $viewer-color);
line-height: 1.1em;
}
.title-card__title {
font-weight: 600;
font-size: clamp(32px, 3.5vw, 50px);
color: var(--color-override, $viewer-color);
line-height: 1.1em;
}
.subtitle, .presenter {
font-size: clamp(24px, 2vw, 35px);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.1em;
}
.title-card__label {
font-size: clamp(1rem, 1.5vw, 1.5rem);
font-weight: 400;
color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase;
.label {
font-size: clamp(16px, 1.5vw, 24px);
font-weight: 400;
color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase;
&.accent {
color: var(--accent-color-override, $accent-color);
}
&--accent {
color: var(--accent-color-override, $accent-color);
}
}
.title-card__secondary {
font-size: clamp(1.5rem, 2vw, 2.25rem);
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.1em;
&::after {
content: '\200b';
}
}
@@ -4,13 +4,12 @@ import './TitleCard.scss';
interface TitleCardProps {
label: 'now' | 'next';
title: string | null;
subtitle: string | null;
presenter: string | null;
title: string;
secondary?: string;
}
export default function TitleCard(props: TitleCardProps) {
const { label, title, subtitle, presenter } = props;
const { label, title, secondary } = props;
const { getLocalizedString } = useTranslation();
const accent = label === 'now';
@@ -18,11 +17,12 @@ export default function TitleCard(props: TitleCardProps) {
return (
<div className='title-card'>
<div className='inline'>
<span className='presenter'>{presenter}</span>
<span className={accent ? 'label accent' : 'label'}>{getLocalizedString(`common.${label}`)}</span>
<span className='title-card__title'>{title}</span>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
{getLocalizedString(`common.${label}`)}
</span>
</div>
<div className='title'>{title}</div>
<div className='subtitle'>{subtitle}</div>
<div className='title-card__secondary'>{secondary}</div>
</div>
);
}
@@ -1,7 +1,7 @@
import { useSearchParams } from 'react-router-dom';
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';
@@ -1,27 +1,9 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
.drawerContent {
background-color: $gray-1250;
}
.drawerHeader {
@extend .drawerContent;
color: $section-white;
}
.drawerFooter {
@extend .drawerContent;
display: flex;
justify-content: start;
gap: $element-spacing;
justify-content: end;
gap: $section-spacing;
button[type='reset'] {
padding: 0 2em;
margin-right: auto;
}
button[type='submit'] {
button {
padding: 0 2em;
}
}
@@ -29,13 +11,16 @@
.label {
font-size: $inner-section-text-size;
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem
}
.columnSection {
display: flex;
padding: $element-spacing;
padding: $section-spacing 0;
flex-direction: column;
gap: $element-inner-spacing;
gap: $element-spacing;
}
.title {
@@ -1,5 +1,5 @@
import { FormEvent, useEffect } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Drawer,
@@ -12,30 +12,26 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import ParamInput from './ParamInput';
import { ParamField } from './types';
import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>;
/**
* Makes a new URLSearchParams object from the given params object
*/
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]) => {
if (typeof value === 'string' && value.length) {
if (defaultValues.includes(value)) {
return newSearchParams;
}
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
newSearchParams.set(id, value);
return newSearchParams;
}
return newSearchParams;
}, new URLSearchParams());
};
@@ -47,8 +43,6 @@ interface EditFormDrawerProps {
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure();
const { pathname } = useLocation();
const [storedViewParams, setStoredViewParams] = useLocalStorage<SavedViewParams>('ontime-views', {});
useEffect(() => {
const isEditing = searchParams.get('edit');
@@ -58,37 +52,16 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
}
}, [searchParams, onOpen]);
/**
* disabling this for now, this feature needs more testing
* - we seem to have a bug where this is conflicting with the aliases
* - I wonder if the logic below needs to be inside an effect,
* both localStorage and searchParams should trigger a component update when they change
useEffect(() => {
const viewParamsObjFromLocalStorage = storedViewParams[pathname];
if (viewParamsObjFromLocalStorage !== undefined) {
const defaultSearchParams = getURLSearchParamsFromObj(viewParamsObjFromLocalStorage);
setSearchParams(defaultSearchParams);
}
// linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps
// rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
*/
const onCloseWithoutSaving = () => {
onClose();
const handleClose = () => {
searchParams.delete('edit');
setSearchParams(searchParams);
onClose();
};
const resetParams = () => {
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
setSearchParams();
onClose();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -96,21 +69,21 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
setSearchParams(newSearchParams);
onClose();
};
return (
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
<Drawer isOpen={isOpen} placement='right' onClose={handleClose} variant='ontime' size='lg'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader className={style.drawerHeader}>
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' />
<DrawerHeader>
<DrawerCloseButton size='lg' />
Customise
</DrawerHeader>
<DrawerBody className={style.drawerContent}>
<DrawerBody>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{paramFields.map((field) => (
<div key={field.title} className={style.columnSection}>
@@ -126,10 +99,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
<DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Reset
</Button>
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
Cancel
Reset to default
</Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
Save
@@ -1,18 +1,36 @@
import { UserFields } from 'ontime-types';
import { TimeFormat } from 'ontime-types/src/definitions/core/TimeFormat.type';
import { CustomFields } from 'ontime-types';
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils';
import { ParamField } from './types';
export const getTimeOption = (timeFormat: TimeFormat): ParamField => ({
id: 'format',
title: '12 / 24 hour timer',
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
type: 'option',
values: { '12': '12 hour AM/PM', '24': '24 hour' },
defaultValue: timeFormat,
});
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
const customFieldOptions = Object.keys(customFields).reduce((acc, key) => {
return { ...acc, [`custom-${key}`]: `Custom: ${capitaliseFirstLetter(key)}` };
}, additionalOptions ?? {});
return customFieldOptions;
};
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),
{
id: 'key',
@@ -84,46 +102,59 @@ export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [
},
];
export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
getTimeOption(timeFormat),
hideTimerSeconds,
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
};
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
hideTimerSeconds,
{
id: 'key',
title: 'Key Colour',
@@ -215,124 +246,220 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
},
];
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
{
id: 'size',
title: 'Size',
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 getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
lowerMsg: 'Lower Third Message',
});
export const getBackstageOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
];
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
lowerMsg: 'Lower Third Message',
});
export const getPublicOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
];
export const getStudioClockOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'seconds',
title: 'Show Seconds',
description: 'Shows seconds in clock',
type: 'boolean',
defaultValue: false,
},
];
return [
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'event',
},
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: topSourceOptions,
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'lowerMsg',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
};
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeFormat): ParamField[] => {
return [
getTimeOption(timeFormat),
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
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 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',
title: 'Hide Past Events',
@@ -345,47 +472,33 @@ export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeForma
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
values: fieldOptions,
defaultValue: 'title',
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
values: fieldOptions,
defaultValue: '',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a field to highlight',
description: 'Choose a custom field to highlight',
type: 'option',
values: {
user0: userFields.user0 || 'user0',
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',
},
values: customFieldSelect,
defaultValue: '',
},
{
id: 'shouldEdit',
title: 'Edit user field',
description: 'Allows editing an events user field by long pressing on it. Needs a selected highlighted field',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
];
};
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
@@ -26,7 +26,6 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
useEffect(() => {
if (status === 'pending') return;
if (!data) return;
const previousEditor = sessionStorage.getItem(storageKeys.editor);
if (previousEditor && previousEditor === data.editorKey) {
@@ -53,10 +52,6 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
return savedPin == null || savedPin === '' || pin === savedPin;
}
if (!data) {
return false;
}
if (permission === 'editor') {
const correct = isValid(pin, data.editorKey);
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 { HttpSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getHTTP, postHTTP } from '../api/ontimeApi';
import { HTTP_SETTINGS } from '../api/constants';
import { getHTTP, postHTTP } from '../api/http';
import { logAxiosError } from '../api/utils';
import { httpPlaceholder } from '../models/Http';
import { ontimeQueryClient } from '../queryClient';
@@ -12,21 +11,23 @@ export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: httpPlaceholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch };
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
}
export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
});
return { isPending, mutateAsync };
@@ -2,20 +2,20 @@ import { useQuery } from '@tanstack/react-query';
import { GetInfo } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_INFO } from '../api/apiConstants';
import { getInfo } from '../api/ontimeApi';
import { APP_INFO } from '../api/constants';
import { getInfo } from '../api/db';
import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO,
queryFn: getInfo,
placeholderData: ontimePlaceholderInfo,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
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 { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getOSC, postOSC, postOscSubscriptions } from '../api/ontimeApi';
import { OSC_SETTINGS } from '../api/constants';
import { getOSC, postOSC } from '../api/osc';
import { logAxiosError } from '../api/utils';
import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: async () => {
const oscData = await getOSC();
return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) };
},
placeholderData: oscPlaceholderSettings,
queryFn: getOSC,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -28,16 +25,9 @@ export function useOscSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postOSC,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isPending, mutateAsync };
}
export function usePostOscSubscriptions() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postOscSubscriptions,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isPending, mutateAsync };
@@ -1,20 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_DATA } from '../api/apiConstants';
import { getProjectData } from '../api/projectDataApi';
import { PROJECT_DATA } from '../api/constants';
import { getProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: projectDataPlaceholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
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 { GetRundownCached } from 'ontime-types';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN } from '../api/apiConstants';
import { fetchCachedRundown } from '../api/eventsApi';
import { RUNDOWN } from '../api/constants';
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() {
const { data, status, isError, refetch, isFetching } = useQuery<GetRundownCached>({
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
queryFn: fetchCachedRundown,
placeholderData: cachedRundownPlaceholder,
queryFn: fetchNormalisedRundown,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
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 { unobfuscate } from 'ontime-utils';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_SETTINGS } from '../api/apiConstants';
import { getSettings } from '../api/ontimeApi';
import { APP_SETTINGS } from '../api/constants';
import { getSettings } from '../api/settings';
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: getSettings,
placeholderData: ontimePlaceholderSettings,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
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 { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { VIEW_SETTINGS } from '../api/apiConstants';
import { getView } from '../api/ontimeApi';
import { VIEW_SETTINGS } from '../api/constants';
import { getView } from '../api/viewSettings';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: getView,
placeholderData: viewsSettingsPlaceholder,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
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 { useMutation, useQueryClient } from '@tanstack/react-query';
import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { RUNDOWN } from '../api/constants';
import {
ReorderEntry,
requestApplyDelay,
requestBatchPutEvents,
requestDelete,
requestDeleteAll,
requestEventSwap,
@@ -15,25 +15,23 @@ import {
requestPutEvent,
requestReorderEvent,
SwapEntry,
} from '../api/eventsApi';
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
/**
* @description Set of utilities for events
*/
export const useEventAction = () => {
const queryClient = useQueryClient();
const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
const { defaultPublic, linkPrevious, defaultDuration } = useEditorSettings((state) => state.eventSettings);
/**
* Calls mutation to add new event
* @private
*/
const _addEventMutation = useMutation({
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
mutationFn: requestPostEvent,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
@@ -45,11 +43,12 @@ export const useEventAction = () => {
after?: string;
};
type EventOptions = BaseOptions & {
defaultPublic?: boolean;
lastEventId?: string;
startTimeIsLastEnd?: boolean;
};
type EventOptions = BaseOptions &
Partial<{
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
}>;
/**
* Adds an event to rundown
@@ -61,35 +60,31 @@ export const useEventAction = () => {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) {
const applicationOptions = {
defaultPublic: options?.defaultPublic ?? defaultPublic,
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
lastEventId: options?.lastEventId,
after: options?.after,
defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
};
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
if (newEvent?.cue === undefined) {
newEvent.cue = getCueCandidate(rundown, options?.after);
}
// hard coding duration value to be as expected for now
// this until timeOptions gets implemented
if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) {
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
}
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
if (previousEvent !== undefined && previousEvent.type === 'event') {
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
newEvent.linkStart = applicationOptions.lastEventId;
} else if (applicationOptions?.lastEventId) {
// 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;
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd;
}
}
if (applicationOptions.defaultPublic) {
newEvent.isPublic = true;
}
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
newEvent.duration = forgivingStringToMillis(defaultDuration);
}
}
// handle adding options that concern all event type
@@ -98,13 +93,12 @@ export const useEventAction = () => {
}
try {
// @ts-expect-error -- we know that the object is well formed now
await _addEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Failed adding event', error);
}
},
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
[_addEventMutation, defaultDuration, defaultPublic, linkPrevious],
);
/**
@@ -119,19 +113,15 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const eventId = newEvent.id;
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
if (previousData) {
if (previousData && eventId) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const index = optimisticRundown.findIndex((event) => event.id === newEvent.id);
if (index > -1) {
// @ts-expect-error -- we expect the event types to match
optimisticRundown[index] = { ...optimisticRundown[index], ...newEvent };
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
const newRundown = { ...previousData.rundown };
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous and new events
@@ -163,6 +153,161 @@ export const useEventAction = () => {
[_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
* @private
@@ -175,20 +320,19 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const index = optimisticRundown.findIndex((event) => event.id === eventId);
if (index > -1) {
optimisticRundown.splice(index, 1);
const newOrder = previousData.order.filter((id) => id !== eventId);
const newRundown = { ...previousData.rundown };
delete newRundown[eventId];
queryClient.setQueryData(RUNDOWN, {
rundown: optimisticRundown,
revision: -1,
});
}
queryClient.setQueryData(RUNDOWN, {
order: newOrder,
rundown: newRundown,
revision: -1,
});
}
// Return a context with the previous and new events
@@ -233,10 +377,10 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
// 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 { previousData };
@@ -304,15 +448,13 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const [reorderedItem] = optimisticRundown.splice(data.from, 1);
optimisticRundown.splice(data.to, 0, reorderedItem);
const newOrder = reorderArray(previousData.order, data.from, data.to);
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
@@ -338,9 +480,9 @@ export const useEventAction = () => {
async (eventId: string, from: number, to: number) => {
try {
const reorderObject: ReorderEntry = {
eventId: eventId,
from: from,
to: to,
eventId,
from,
to,
};
await _reorderEventMutation.mutateAsync(reorderObject);
} catch (error) {
@@ -362,15 +504,22 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const fromEventIndex = previousData.rundown.findIndex((event) => event.id === from);
const toEventIndex = previousData.rundown.findIndex((event) => event.id === to);
const newRundown = { ...previousData.rundown };
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
@@ -394,8 +543,6 @@ export const useEventAction = () => {
*/
const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => {
// TODO: before calling `/swapEvents`,
// we should determine the events are of type `OntimeEvent`
try {
await _swapEvents.mutateAsync({ from, to });
} catch (error) {
@@ -407,11 +554,15 @@ export const useEventAction = () => {
return {
addEvent,
updateEvent,
applyDelay,
batchUpdateEvents,
deleteEvent,
deleteAllEvents,
applyDelay,
linkTimer,
reorderEvent,
swapEvents,
updateEvent,
updateTimer,
updateCustomField,
};
};

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