Compare commits

..

37 Commits

Author SHA1 Message Date
Carlos Valente 672267c0b3 v2 beta6 (#379)
* feat: operator key

* chore: update demo

* chore: version bump

* fix: prevent stale keys

* refactor: add new fields to validation

* style: tweaks to modal arrangement

* refactor: prevent parsing http

* ux: click anywhere in event to edit

* refactor: performance improvements to time input

* refactor: performance improvements menu
2023-05-13 23:36:19 +02:00
Carlos Valente ca65d31071 Ux language (#378)
* ux: language from menu

* feat: add extra support for languages

german
portuguese
spanish

* refactor: type improvements
2023-05-12 23:16:20 +02:00
Carlos Valente 305d6b6476 v2 beta 5 (#375)
* chore: update documentation links

* fix: sentry has no access to error context

* style: clarify event history

* fix: issue with clipboard write in safari

* style: clarify event history

* refactor: improvements to follow logic in rundown

* refactor: improvements in go mode

* several style tweaks and small improvements
2023-05-12 22:31:26 +02:00
Carlos Valente 139b667e20 v2 beta5 modals (#377)
* refactor: folder structure

* style: modal wrapper

* feat: add nordic translations
2023-05-12 22:16:33 +02:00
Fabian Posenau 29a9167d61 Feat/timer/stylechange (#341)
* feat: multi color progress bar

* add color support to minimal timer

Co-Authored-By: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
2023-05-03 20:16:35 +02:00
Carlos Valente 38654f8981 Feature: quick start (#369)
* feat: quick start modal

* refactor: end message is part of view settings

* refactor: small tweaks and type improvements
2023-04-29 21:20:25 +02:00
Carlos Valente 33b496f98e v2 beta 4 bump (#367)
* chore: update links to documentation

* chore: version bump
2023-04-28 20:48:06 +02:00
Carlos Valente 10673b3ee1 ux: content aware playback (#364) 2023-04-26 22:07:26 +02:00
Carlos Valente 4bceae65ab fix v2 roll timer (#363)
* fix: roll update skips elapsed time

* refactor: remove unused data

* style: fix style in roll state
2023-04-26 22:02:04 +02:00
Carlos Valente 40d389acc9 v2 beta 4 (#361)
* ux: rename end action

* chore: update demo db

* style: tweaks on extracted rundown

* chore: prevent console logs in production code

* feat: go mode

* style: remove window size limits

* style: rename delete action

* style: small tweaks on icons

* fix: style override on params

* chore: update test db

* refactor: small code quality improvements

* refactor: prevent circular imports
2023-04-25 22:35:14 +02:00
Carlos Valente 0b94fc71a4 v2 about (#362)
* chore: remove unused assets

* feat: about ontime

* refactor: cleanup unused
2023-04-25 22:31:56 +02:00
Carlos Valente 8f834fed35 style: cuesheet block (#355)
* style: cuesheet show block title

* style: cuesheet show full delay string
2023-04-23 10:18:19 +02:00
Carlos Valente db9cb61164 fix: osc modal (#352)
* refactor: parse time input

* fix: form dirty flag on dynamic elements
2023-04-21 20:06:40 +02:00
Carlos Valente 9c4c84905e feat: edit mode (#344)
* style: add mode selection to menu

* style: rename rundown functions

* feat: edit mode
2023-04-21 20:06:31 +02:00
Carlos Valente da889a4825 chore: vite upgrade (#354)
* chore: upgrade vite
2023-04-21 20:06:12 +02:00
Carlos Valente 4ca9fb9f72 refactor: parse time input (#351) 2023-04-21 09:20:34 +02:00
Carlos Valente b228020b73 V2 e2e playwright (#343)
* test: create utility for uploading test-db

* chore: upgrade deps

* refactor: remove unused functions

* chore: create testing boilerplate

* test: upload showfile
2023-04-18 23:16:19 +02:00
cv f248b78945 refactor: changes in time input 2023-04-18 23:15:15 +02:00
Carlos Valente 11663e5416 refactor: serve react as compressed br (#339) 2023-04-15 13:27:29 +02:00
Carlos Valente 6732314f15 V2 fix delay (#340)
* fix: avoid modifying original objects
2023-04-15 13:26:57 +02:00
cv e9fe0c0a3a fix: resolve to static port in dev mode 2023-04-15 12:46:17 +02:00
Carlos Valente 770d12888d V2 beta3 (#338)
* chore: upgrade local build documentation

* style: keep scrolling event in screen

* feat: delay is time entry

* refactor: remove unused

* refactor: remove unused

* refactor: batch store updates

* refactor: virtually remove cap on events

* refactor: style and behaviour tweaks to event block

* style: tweaks on schedules

* chore: remove sentry from server

* style: reorder menu

* chore: update docs
2023-04-14 10:13:46 +02:00
Fabian Posenau 94a1369d64 fix docker on different port (#337)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-04-14 10:08:42 +02:00
Fabian Posenau 5ba27dca1d add onClick handler (#335)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-04-10 20:00:09 +02:00
cv 3df17d172f fix: sentry hangs node process 2023-04-09 19:54:19 +02:00
Fabian Posenau d59c92219f Fix/events enpoint (#329) 2023-04-09 13:53:51 +02:00
cv 23fce763e5 style: ensure events are always visible 2023-04-06 23:39:08 +02:00
cv 524aa74a3d chore: upgrade deps 2023-04-06 20:56:01 +02:00
cv 32b7908d5b chore: upgrade deps 2023-04-06 20:50:56 +02:00
cv e36b19a511 style: small tweaks on blocks 2023-04-06 20:46:24 +02:00
cv ba62276951 refactor: improve performance on EventBlock 2023-04-06 14:42:34 +02:00
cv 4c4b83979d fix: close editor on event delete 2023-04-06 14:10:23 +02:00
cv 2dcea86b36 style: small style tweaks 2023-04-06 14:07:19 +02:00
cv e54996bd5e refactor: prevent iterating on undefined 2023-04-06 11:58:47 +02:00
cv 41753fc202 chore: upgrade deps 2023-04-06 11:47:30 +02:00
cv e77572c0b6 refactor: performance tweaks to event editor 2023-04-06 11:00:28 +02:00
cv 4c268d531e style: fix info scroll 2023-04-06 09:49:57 +02:00
230 changed files with 5904 additions and 5411 deletions
+10 -3
View File
@@ -11,9 +11,16 @@
],
"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": {}
"rules": {
"no-console": "warn"
}
}
+23 -1
View File
@@ -20,6 +20,28 @@ From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev`
## TESTING
Generally we have 2 types of tests.
- Unit tests for functions that contain business logic
- End-to-end tests for core features
### Unit tests
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running turbo `turbo test:pipeline` from the project root.
This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
### E2E tests
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the webserver to test against
These tests also run against a separate version of the DB (test-db)
You can run playwright tests from project root with `pnpm e2e`
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually start the webserver with `pnpm dev:server`
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
Ontime uses Electron to distribute the application.
@@ -27,7 +49,7 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo build`
- __Build the UI and server__ by running `turbo build:local`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
The build distribution assets will be at `.apps/electron/dist`
+10 -15
View File
@@ -1,5 +1,5 @@
[![ontime_test_CI](https://github.com/cpvalente/ontime/actions/workflows/ontime_cy.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/ontime_cy.yml) [![Ontime build](https://github.com/cpvalente/ontime/actions/workflows/build.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/build.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://cpvalente.gitbook.io/ontime/)
[![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)
## Download the latest releases here
@@ -15,7 +15,7 @@
Ontime is an application for managing event rundowns and running stage timers.
A single, locally hosted central application distributes your event information over the local network.
This enables the distribution of the data to a series of viewers and allows integration into video and control workflows, including OBS and d3.
This enables the distribution of the data to a series of views and allows integration into video and control workflows, including OBS and d3.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.jpg)
@@ -55,7 +55,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://cpvalente.gitbook.io/ontime/)
More documentation is available [in our docs](https://ontime.gitbook.io)
## Feature List (in no specific order)
@@ -65,6 +65,7 @@ More documentation is available [in our docs](https://cpvalente.gitbook.io/ontim
- Backstage Info
- Public Info
- Studio Clock
- Countdown
- [Make your own?](#make-your-own-viewer)
- [x] Configurable Lower Thirds
- [x] Cuesheets with user definable fields
@@ -77,7 +78,7 @@ More documentation is available [in our docs](https://cpvalente.gitbook.io/ontim
- WebSockets
- [x] Roll mode: run independently 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://cpvalente.gitbook.io/ontime/views/countdown): have
- [x] [Countdown to anything!](https://ontime.gitbook.io/v2/views/countdown): have
a countdown to any scheduled event
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
@@ -109,13 +110,13 @@ Taking advantage of the integrations, we currently use Ontime with:
### Make your own viewer
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside of the application.
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language that can run in the browser).
<br />
See [this repository](https://github.com/cpvalente/ontime-viewer-template) with a small template on
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://app.gitbook.com/s/-Mc0giSOToAhq0ROd0CR/control-and-feedback/websocket-api)
the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/websocket-api)
### Headless run
@@ -132,13 +133,7 @@ in [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime
docker pull getontime/ontime
```
```bash
# Port 4001 - ontime server port
# Port 8888 - OSC input, bound to localhost IP Address
docker run -p 4001:4001 -p 127.0.0.1:8888:8888/udp --mount type=bind,source="$(pwd)/ontime-db",target=/server/preloaded-db getontime/ontime
```
or if running from the docker compose
and use the included docker compose to get started
```bash
docker-compose up
@@ -202,7 +197,7 @@ Information about the project setup can be found in the [development documentati
# Help
Help is underway! ... and can be found [here](https://cpvalente.gitbook.io/ontime/)
Help is underway! ... and can be found [here](https://ontime.gitbook.io)
# License
+1
View File
@@ -27,6 +27,7 @@
"prettier"
],
"rules": {
"@typescript-eslint/no-non-null-assertion": "warn",
"prettier/prettier": [
"error",
{
+15 -14
View File
@@ -1,27 +1,27 @@
{
"name": "ontime-ui",
"version": "2.0.0-beta2",
"version": "2.0.0-beta",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.5.1",
"@chakra-ui/react": "^2.5.5",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.28.1",
"@sentry/tracing": "^7.24.1",
"@tanstack/react-query": "^4.26.1",
"@tanstack/react-query-devtools": "^4.26.1",
"@sentry/react": "^7.46.0",
"@sentry/tracing": "^7.46.0",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.29.0",
"autosize": "^5.0.2",
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.2.3",
"deepmerge": "^4.3.0",
"framer-motion": "^8.0.2",
"luxon": "^3.3.0",
"framer-motion": "^10.10.0",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0",
"react-hook-form": "^7.43.5",
@@ -58,7 +58,7 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "^0.3.0",
"@sentry/vite-plugin": "^0.4.0",
"@tanstack/eslint-plugin-query": "^4.26.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
@@ -90,9 +90,10 @@
"stylelint-config-prettier": "^9.0.4",
"stylelint-config-standard-scss": "^6.1.0",
"typescript": "^4.9.4",
"vite": "^4.0.4",
"vite": "^4.3.1",
"vite-plugin-compression2": "^0.9.0",
"vite-plugin-svgr": "^2.4.0",
"vite-tsconfig-paths": "^4.0.3",
"vitest": "^0.29.8"
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.30.1"
}
}
+10 -22
View File
@@ -1,9 +1,8 @@
import { lazy, useEffect } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import useAliases from './common/hooks-query/useAliases';
import withData from './features/viewers/ViewWrapper';
import { useTranslation } from './translation/TranslationProvider';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Table = lazy(() => import('./features/table/ProtectedTable'));
@@ -27,7 +26,7 @@ const SPublic = withData(Public);
const SLowerThird = withData(Lower);
const SStudio = withData(StudioClock);
const FeatureWrapper = lazy(() => import('./features/FeatureWrapper'));
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'));
@@ -37,17 +36,6 @@ export default function AppRouter() {
const { data } = useAliases();
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { setLanguage } = useTranslation();
// Set output language
useEffect(() => {
const langParam = searchParams.get('lang');
if (langParam && langParam.length === 2) {
setLanguage(searchParams.get('lang'));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
// navigate if is alias route
useEffect(() => {
@@ -95,33 +83,33 @@ export default function AppRouter() {
<Route
path='/rundown'
element={
<FeatureWrapper>
<EditorFeatureWrapper>
<RundownPanel />
</FeatureWrapper>
</EditorFeatureWrapper>
}
/>
<Route
path='/timercontrol'
element={
<FeatureWrapper>
<EditorFeatureWrapper>
<TimerControl />
</FeatureWrapper>
</EditorFeatureWrapper>
}
/>
<Route
path='/messagecontrol'
element={
<FeatureWrapper>
<EditorFeatureWrapper>
<MessageControl />
</FeatureWrapper>
</EditorFeatureWrapper>
}
/>
<Route
path='/info'
element={
<FeatureWrapper>
<EditorFeatureWrapper>
<Info />
</FeatureWrapper>
</EditorFeatureWrapper>
}
/>
{/*/!* Send to default if nothing found *!/*/}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,21 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" rx="24" fill="#222222"/>
<path d="M40.5 131.781V128.193C40.5 114.635 42.4369 102.159 46.3106 90.7659C50.1843 79.2587 55.8239 69.2896 63.2295 60.8586C70.6351 52.4277 79.7497 45.8765 90.5733 41.2053C101.397 36.4202 113.815 34.0276 127.829 34.0276C141.843 34.0276 154.318 36.4202 165.256 41.2053C176.193 45.8765 185.365 52.4277 192.771 60.8586C200.29 69.2896 205.987 79.2587 209.86 90.7659C213.734 102.159 215.671 114.635 215.671 128.193V131.781C215.671 145.226 213.734 157.701 209.86 169.208C205.987 180.601 200.29 190.571 192.771 199.115C185.365 207.546 176.25 214.098 165.427 218.769C154.603 223.44 142.185 225.776 128.171 225.776C114.157 225.776 101.682 223.44 90.7442 218.769C79.9206 214.098 70.7491 207.546 63.2295 199.115C55.8239 190.571 50.1843 180.601 46.3106 169.208C42.4369 157.701 40.5 145.226 40.5 131.781ZM89.7188 128.193V131.781C89.7188 139.529 90.4024 146.764 91.7696 153.486C93.1368 160.208 95.3015 166.132 98.2637 171.259C101.34 176.272 105.328 180.203 110.227 183.051C115.126 185.899 121.107 187.323 128.171 187.323C135.007 187.323 140.874 185.899 145.773 183.051C150.673 180.203 154.603 176.272 157.565 171.259C160.528 166.132 162.692 160.208 164.06 153.486C165.541 146.764 166.281 139.529 166.281 131.781V128.193C166.281 120.673 165.541 113.609 164.06 107.001C162.692 100.279 160.471 94.3547 157.395 89.2278C154.432 83.9869 150.502 79.8853 145.603 76.9231C140.703 73.9609 134.779 72.4797 127.829 72.4797C120.879 72.4797 114.955 73.9609 110.056 76.9231C105.271 79.8853 101.34 83.9869 98.2637 89.2278C95.3015 94.3547 93.1368 100.279 91.7696 107.001C90.4024 113.609 89.7188 120.673 89.7188 128.193Z" fill="#FFFFFA"/>
<mask id="mask0_30_31" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="40" y="34" width="176" height="192">
<path d="M40.5 131.781V128.193C40.5 114.635 42.4369 102.159 46.3106 90.7659C50.1843 79.2587 55.8239 69.2896 63.2295 60.8586C70.6351 52.4277 79.7497 45.8765 90.5733 41.2053C101.397 36.4202 113.815 34.0276 127.829 34.0276C141.843 34.0276 154.318 36.4202 165.256 41.2053C176.193 45.8765 185.365 52.4277 192.771 60.8586C200.29 69.2896 205.987 79.2587 209.86 90.7659C213.734 102.159 215.671 114.635 215.671 128.193V131.781C215.671 145.226 213.734 157.701 209.86 169.208C205.987 180.601 200.29 190.571 192.771 199.115C185.365 207.546 176.25 214.098 165.427 218.769C154.603 223.44 142.185 225.776 128.171 225.776C114.157 225.776 101.682 223.44 90.7442 218.769C79.9206 214.098 70.7491 207.546 63.2295 199.115C55.8239 190.571 50.1843 180.601 46.3106 169.208C42.4369 157.701 40.5 145.226 40.5 131.781ZM89.7188 128.193V131.781C89.7188 139.529 90.4024 146.764 91.7696 153.486C93.1368 160.208 95.3015 166.132 98.2637 171.259C101.34 176.272 105.328 180.203 110.227 183.051C115.126 185.899 121.107 187.323 128.171 187.323C135.007 187.323 140.874 185.899 145.773 183.051C150.673 180.203 154.603 176.272 157.565 171.259C160.528 166.132 162.692 160.208 164.06 153.486C165.541 146.764 166.281 139.529 166.281 131.781V128.193C166.281 120.673 165.541 113.609 164.06 107.001C162.692 100.279 160.471 94.3547 157.395 89.2278C154.432 83.9869 150.502 79.8853 145.603 76.9231C140.703 73.9609 134.779 72.4797 127.829 72.4797C120.879 72.4797 114.955 73.9609 110.056 76.9231C105.271 79.8853 101.34 83.9869 98.2637 89.2278C95.3015 94.3547 93.1368 100.279 91.7696 107.001C90.4024 113.609 89.7188 120.673 89.7188 128.193Z" fill="white"/>
</mask>
<g mask="url(#mask0_30_31)">
<path d="M17.7519 90.6278C32.2336 84.5548 31.0535 58.0568 38.3066 62.5986C47.3731 68.2758 38.3066 41.1095 50.9198 47.1825C71.1339 56.9152 40.7009 64.5389 62.3666 76.9778C84.0323 89.4167 171.756 99.642 202.839 160.802C214.766 184.27 220.843 191.199 223.166 192.958C224.57 192.837 224.823 194.214 223.166 192.958C222.812 192.989 222.385 193.115 221.898 193.401C216.763 196.422 122.406 273.336 83.6205 225.168C44.8346 177 2.09417 132.401 13.321 120.526C22.3025 111.025 14.6184 97.1566 17.7519 90.6278Z" fill="url(#paint0_linear_30_31)"/>
<path d="M39.2409 66.8029C31.9877 62.2612 38.8572 79.7789 16.8175 87.8247C13.684 94.3536 21.1274 106.353 12.1459 115.854C0.919109 127.73 47.1703 180.27 85.9563 228.438C124.742 276.606 200.88 205.765 206.015 202.745C211.149 199.724 219.133 212.629 196.599 168.158C165.523 106.828 78.8569 98.1563 57.3454 76.3867C38.7095 57.5272 88.9205 36.7794 67.2701 41.1095C39.2409 46.7153 48.3073 72.4802 39.2409 66.8029Z" fill="url(#paint1_linear_30_31)"/>
</g>
<defs>
<linearGradient id="paint0_linear_30_31" x1="87.4684" y1="-29.7758" x2="62.9476" y2="328.852" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF0000" stop-opacity="0.74"/>
<stop offset="1" stop-color="#FF0000" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_30_31" x1="82.3228" y1="-32.8735" x2="57.802" y2="325.754" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF005C" stop-opacity="0.74"/>
<stop offset="1" stop-color="#FF005C" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+5 -13
View File
@@ -1,7 +1,7 @@
export const STATIC_PORT = 4001;
// REST stuff
export const EVENTDATA_TABLE = ['eventdata'];
export const EVENT_DATA = ['eventdata'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN_TABLE_KEY = 'rundown';
@@ -12,20 +12,12 @@ export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings'];
export const RUNTIME = ['runtimeStore'];
// external stuff
export const githubURL = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
/**
* @description finds server path given the current location, it
* @return {*}
*/
export const calculateServer = () => (import.meta.env.DEV ? `http://localhost:${STATIC_PORT}` : window.location.origin);
export const serverURL = calculateServer();
export const websocketUrl = `ws://${window.location.hostname}:${STATIC_PORT}/ws`;
export const serverPort = import.meta.env.DEV ? STATIC_PORT : window.location.port;
export const serverURL = import.meta.env.DEV ? `http://localhost:${serverPort}` : window.location.origin;
export const websocketUrl = `ws://${window.location.hostname}:${serverPort}/ws`;
export const eventURL = `${serverURL}/eventdata`;
export const rundownURL = `${serverURL}/eventlist`;
export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
export const stylesPath = 'external/styles/override.css';
+19 -6
View File
@@ -1,9 +1,10 @@
import axios from 'axios';
import { Alias, OSCSettings, Settings, UserFields, ViewSettings } from 'ontime-types';
import { Alias, EventData, OSCSettings, Settings, UserFields, ViewSettings } from 'ontime-types';
import { apiRepoLatest } from '../../externals';
import { InfoType } from '../models/Info';
import { githubURL, ontimeURL } from './apiConstants';
import { ontimeURL } from './apiConstants';
/**
* @description HTTP request to retrieve application settings
@@ -44,7 +45,7 @@ export async function getView(): Promise<ViewSettings> {
* @description HTTP request to mutate view settings
* @return {Promise}
*/
export async function postView(data: ViewSettings) {
export async function postViewSettings(data: ViewSettings) {
return axios.post(`${ontimeURL}/views`, data);
}
@@ -152,11 +153,23 @@ export const uploadData = async (file: string, setProgress: (value: number) => v
.then((response) => response.data.id);
};
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<object> {
const res = await axios.get(`${githubURL}`);
return { url: res.data.html_url, version: res.data.tag_name };
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<EventData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
@@ -18,6 +18,7 @@ import { useEmitLog } from '../../stores/logger';
interface QuitIconBtnProps {
clickHandler: () => void;
size?: Size;
disabled?: boolean;
}
const quitBtnStyle = {
@@ -27,6 +28,10 @@ const quitBtnStyle = {
_hover: {
background: '#D20300', // $red-700
color: 'white',
_disabled: {
color: '#D20300', // $red-700
background: 'none',
},
},
_active: {
background: '#9A0000', // $red-1000
@@ -37,7 +42,7 @@ const quitBtnStyle = {
};
export default function QuitIconBtn(props: QuitIconBtnProps) {
const { clickHandler, size = 'lg', ...rest } = props;
const { clickHandler, size = 'lg', disabled, ...rest } = props;
const [isOpen, setIsOpen] = useState(false);
const { emitInfo } = useEmitLog();
const onClose = () => setIsOpen(false);
@@ -65,6 +70,7 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
size={size}
icon={<FiPower />}
onClick={() => setIsOpen(true)}
isDisabled={disabled}
{...quitBtnStyle}
{...rest}
/>
@@ -75,9 +81,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 program and all running servers. Are you sure?</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
Cancel
@@ -1,7 +1,8 @@
import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: () => void;
clickHandler: (event?: MouseEvent) => void;
tooltip: string;
openDelay?: number;
}
@@ -10,13 +11,7 @@ export default function TooltipActionBtn(props: TooltipActionBtnProps) {
const { clickHandler, icon, size = 'xs', tooltip, openDelay = 0, className, ...rest } = props;
return (
<Tooltip label={tooltip} openDelay={openDelay}>
<IconButton
{...rest}
size={size}
icon={icon}
onClick={clickHandler}
className={className}
/>
<IconButton {...rest} size={size} icon={icon} onClick={clickHandler} className={className} />
</Tooltip>
);
}
@@ -6,6 +6,7 @@
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;
@@ -14,21 +14,18 @@ interface CopyTagProps {
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
const { label, className, size = 'xs', children } = props;
const handleClick = () => {
// we need to this as a promise because safari
setTimeout(async () => await navigator.clipboard.writeText(children as string));
};
return (
<Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup
size={size}
isAttached
className={className}
>
<Button variant='ontime-subtle' tabIndex={-1}>{children}</Button>
<IconButton
aria-label={label}
icon={<IoCopy />}
variant='ontime-filled'
tabIndex={-1}
onClick={() => navigator.clipboard.writeText(children as string)}
/>
<ButtonGroup size={size} isAttached className={className}>
<Button variant='ontime-subtle' tabIndex={-1}>
{children}
</Button>
<IconButton aria-label={label} icon={<IoCopy />} variant='ontime-filled' tabIndex={-1} onClick={handleClick} />
</ButtonGroup>
</Tooltip>
);
@@ -28,12 +28,6 @@ class ErrorBoundary extends React.Component {
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
});
try {
this.context.emitError(error.toString());
} catch (e) {
Sentry.captureMessage(`Unable to emit error ${error} ${e}`);
}
this.reportContent = `${error} ${info.componentStack}`;
}
@@ -19,7 +19,6 @@ export const AutoTextArea = (props: AutoTextAreaProps) => {
};
}, []);
return (
<Textarea
overflow='hidden'
@@ -27,7 +26,7 @@ export const AutoTextArea = (props: AutoTextAreaProps) => {
resize='none'
ref={ref}
transition='height none'
variant={isDark ? 'ontime-filled' : 'ontime-filled-onlight'}
variant={isDark ? 'ontime-filled' : 'ontime-filled-on-light'}
{...rest}
/>
);
@@ -17,7 +17,7 @@ export default function Swatch(props: SwatchProps) {
if (!color) {
return (
<div className={`${classes} ${style.center}`}>
<div className={`${classes} ${style.center}`} onClick={() => onClick('')}>
<IoBan />
</div>
);
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorTitles';
import Swatch from './Swatch';
@@ -8,8 +8,8 @@ import style from './SwatchSelect.module.scss';
interface ColourInputProps {
value: string;
name: EventEditorSubmitActions;
handleChange: (newValue: EventEditorSubmitActions, name: string) => void;
name: TitleActions;
handleChange: (newValue: TitleActions, name: string) => void;
}
const colours = [
@@ -1,13 +1,24 @@
@use '../../../../theme/v2Styles' as *;
$input-font-size: 15px;
.delayInput {
display: flex;
gap: $element-spacing;
align-items: center;
color: $ontime-delay-text;
font-size: $text-body-size;
.inputField {
font-size: $input-font-size;
letter-spacing: 1px;
max-width: 7em;
padding-left: 16px;
color: $ontime-delay-text
}
}
.inputField {
text-align: center;
}
.delayOptions {
display: flex;
flex-direction: column;
}
@@ -1,88 +1,134 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { clamp } from '../../../utils/math';
import { useEventAction } from '../../../hooks/useEventAction';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import style from './DelayInput.module.scss';
const inputStyleProps = {
width: 20,
placeholder: '-',
size: 'sm',
color: '#E69056',
variant: 'ontime-filled',
fontSize: '15px',
letterSpacing: '0.3px',
};
interface DelayInputProps {
submitHandler: (value: number) => void;
value?: number;
eventId: string;
duration: number;
}
export default function DelayInput(props: DelayInputProps) {
const { submitHandler, value = 0 } = props;
const [_value, setValue] = useState(value);
const { eventId, duration } = props;
const { updateEvent } = useEventAction();
const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null);
// avoid wrong submit on cancel
let ignoreChange = false;
useEffect(() => {
if (!value) {
if (typeof duration === 'undefined') {
return;
}
setValue(value);
}, [value]);
setValue(millisToString(duration));
}, [duration]);
/**
* @description Prepare delay value for update
* @param {string} value string to be parsed
* @param {string} newValue string to be parsed
*/
const validate = useCallback(
(newValue?: string) => {
if (newValue === '') setValue(0);
const delayValue = clamp(Number(newValue), -60, 60);
if (delayValue === value) return;
setValue(delayValue);
const validateAndSubmit = (newValue: string) => {
if (ignoreChange) {
ignoreChange = false;
return;
}
submitHandler(delayValue);
},
[submitHandler, value],
);
const isNegative = newValue.startsWith('-');
let newMillis = forgivingStringToMillis(newValue);
if (isNegative) {
newMillis = newMillis * -1;
}
if (newMillis === duration) {
return;
}
submitChange(newMillis);
setValue(millisToString(newMillis));
};
const submitChange = (value: number) => {
updateEvent({
id: eventId,
duration: value,
});
};
/**
* @description Selects input text on focus
*/
const handleFocus = () => inputRef.current?.select();
/**
* @description Handles common keys for submit and cancel
* @param {KeyboardEvent} event
*/
const onKeyDownHandler = useCallback(
(key: string) => {
if (key === 'Enter') {
inputRef.current?.blur();
validate(inputRef.current?.value);
} else if (key === 'Escape') {
inputRef.current?.blur();
setValue(value);
}
},
[validate, value],
);
const onKeyDownHandler = (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);
} else if (event.key === 'Escape') {
ignoreChange = true;
setValue(millisToString(duration));
inputRef.current?.blur();
}
};
const labelText = `${Math.abs(value) !== 1 ? 'minutes' : 'minute'} ${
value !== undefined && value >= 0 ? 'delayed' : 'ahead'
}`;
/**
* @description handles direction change to delay
* @param newDirection
*/
const handleSlipChange = (newDirection: 'add' | 'subtract') => {
if (newDirection === 'add') {
// add time
if (duration < 0) {
submitChange(duration * -1);
}
} else if (newDirection === 'subtract') {
// subtract time
if (duration > 0) {
submitChange(duration * -1);
}
}
};
const checkedOption = value.startsWith('-') ? 'subtract' : 'add';
return (
<label className={style.delayInput}>
<div className={style.delayInput}>
<Input
{...inputStyleProps}
size='sm'
ref={inputRef}
data-testid='delay-input'
className={style.inputField}
value={_value}
onChange={(event) => setValue(Number(event.target.value))}
onBlur={(event) => validate(event.target.value)}
onKeyDown={(event) => onKeyDownHandler(event.key)}
type='number'
type='text'
placeholder='-'
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => validateAndSubmit(event.target.value)}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={9}
/>
{labelText}
</label>
<RadioGroup
className={style.delayOptions}
onChange={handleSlipChange}
value={checkedOption}
variant='ontime-block'
size='sm'
>
<Radio value='add'>Add time</Radio>
<Radio value='subtract'>Subtract time</Radio>
</RadioGroup>
</div>
);
}
@@ -0,0 +1,19 @@
@use "../../../../theme/_ontimeColours" as *;
@use "../../../../theme/_v2Styles" as *;
.swatch {
width: 24px;
height: 24px;
border-radius: 12px;
border: 1px solid white;
box-shadow: 0 0 0 1px $gray-300;
cursor: pointer;
transition-property: box-shadow;
transition-duration: $transition-time-action;
}
.swatch:hover {
box-shadow: 0 0 0 1px $blue-300;
}
@@ -0,0 +1,35 @@
import { HexAlphaColorPicker } from 'react-colorful';
import { useController, UseControllerProps } from 'react-hook-form';
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import style from './PopoverPicker.module.scss';
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
return <PopoverPicker color={value as string} onChange={onChange} />;
}
interface PopoverPickerProps {
color: string;
onChange: (color: string) => void;
}
export default function PopoverPicker(props: PopoverPickerProps) {
const { color, onChange } = props;
return (
<Popover>
<PopoverTrigger>
<div className={style.swatch} style={{ backgroundColor: color }} />
</PopoverTrigger>
<PopoverContent style={{ width: 'auto' }}>
<HexAlphaColorPicker color={color} onChange={onChange} />
</PopoverContent>
</Popover>
);
}
@@ -1,4 +1,4 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
@@ -21,22 +21,38 @@ interface TimeInputProps {
warning?: 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, warning?: string) {
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`;
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`;
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`;
return '';
}
export default function TimeInput(props: TimeInputProps) {
const { name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState('');
const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
/**
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
try {
setValue(millisToString(time + delay));
setValue(millisToString(time));
} catch (error) {
emitError(`Unable to parse date: ${error}`);
setValue(millisToString(0));
emitError(`Unable to parse time ${time}: ${error}`);
}
}, [delay, emitError, time]);
}, [emitError, time]);
/**
* @description Selects input text on focus
@@ -73,11 +89,8 @@ export default function TimeInput(props: TimeInputProps) {
newValMillis = forgivingStringToMillis(newValue);
}
// Time now and time submittedVal
const originalMillis = time + delay;
// check if time is different from before
if (newValMillis === originalMillis) return false;
if (newValMillis === time) return false;
// validate with parent
if (!validationHandler(name, newValMillis)) return false;
@@ -87,7 +100,7 @@ export default function TimeInput(props: TimeInputProps) {
return true;
},
[delay, name, previousEnd, submitHandler, time, validationHandler],
[name, previousEnd, submitHandler, time, validationHandler],
);
/**
@@ -99,12 +112,13 @@ export default function TimeInput(props: TimeInputProps) {
const success = handleSubmit(newValue);
if (success) {
const ms = forgivingStringToMillis(newValue);
setValue(millisToString(ms + delay));
const delayed = name === 'timeEnd' ? Math.max(0, ms + delay) : Math.max(0, ms + delay);
setValue(millisToString(delayed));
} else {
resetValue();
}
},
[delay, handleSubmit, resetValue],
[delay, handleSubmit, name, resetValue],
);
/**
@@ -120,6 +134,7 @@ export default function TimeInput(props: TimeInputProps) {
validateAndSubmit((event.target as HTMLInputElement).value);
}
if (event.key === 'Escape') {
ignoreChange.current = true;
inputRef.current?.blur();
resetValue();
}
@@ -129,6 +144,10 @@ export default function TimeInput(props: TimeInputProps) {
const onBlurHandler = useCallback(
(event: FocusEvent<HTMLInputElement>) => {
if (ignoreChange.current) {
ignoreChange.current = false;
return;
}
validateAndSubmit((event.target as HTMLInputElement).value);
},
[validateAndSubmit],
@@ -137,31 +156,24 @@ export default function TimeInput(props: TimeInputProps) {
useEffect(() => {
if (time == null) return;
resetValue();
}, [emitError, resetValue, time]);
const isDelayed = delay != null && delay !== 0;
const ButtonInitial = () => {
if (name === 'timeStart') return 'S';
if (name === 'timeEnd') return 'E';
if (name === 'durationOverride') return 'D';
return '';
};
const ButtonTooltip = () => {
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`;
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`;
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`;
return '';
};
}, [resetValue, time]);
const isDelayed = delay !== 0;
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null, warning ? style.warn : null]);
const TooltipLabel = useMemo(() => {
return ButtonTooltip(name, warning);
}, [name, warning]);
const ButtonText = useMemo(() => {
return ButtonInitial(name);
}, [name]);
return (
<InputGroup size='sm' className={inputClasses}>
<InputLeftElement className={style.inputLeft}>
<Tooltip label={ButtonTooltip()} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<Tooltip label={TooltipLabel} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<Button
size='sm'
variant='ontime-subtle-white'
@@ -171,7 +183,7 @@ export default function TimeInput(props: TimeInputProps) {
borderRight='1px solid transparent'
borderRadius='2px 0 0 2px'
>
{ButtonInitial()}
{ButtonText}
</Button>
</Tooltip>
</InputLeftElement>
@@ -0,0 +1,50 @@
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 12px;
$progress-bar-br: 3px;
.progress-bar {
position: relative;
width: 100%;
height: $progress-bar-size;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
display: flex;
&--hidden {
display: none;
transition: display 0.5s;
}
}
.progress-bar__indicator {
position: absolute;
height: inherit;
background-color: black;
opacity: 0.8;
transition: 1s linear;
transition-property: width;
right: 0;
}
.progress-bar__bg-normal {
position: absolute;
height: inherit;
right: 0;
border-radius: $progress-bar-br;
width: 100%;
}
.progress-bar__bg-warning {
position: absolute;
height: inherit;
right: 0;
border-radius: 0 $progress-bar-br $progress-bar-br 0;
}
.progress-bar__bg-danger {
position: absolute;
height: inherit;
right: 0;
border-radius: 0 $progress-bar-br $progress-bar-br 0;
}
@@ -0,0 +1,33 @@
import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: number;
complete: number;
normalColor: string;
warning: number;
warningColor: string;
danger: number;
dangerColor: string;
hidden?: boolean;
className?: string;
}
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) * 100) / complete, 0, 100);
const dangerWidth = clamp((danger / complete) * 100, 0, 100);
const warningWidth = clamp((warning / complete) * 100, 0, 100);
return (
<div className={`progress-bar ${hidden ? 'progress-bar--hidden' : ''} ${className}`}>
<div className='progress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div className='progress-bar__bg-warning' style={{ width: `${warningWidth}%`, backgroundColor: warningColor }} />
<div className='progress-bar__bg-danger' style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }} />
<div className='progress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</div>
);
}
@@ -0,0 +1,64 @@
import { useCallback, useEffect, useState } from 'react';
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import style from './ProtectRoute.module.scss';
interface PinPageProps {
permission: 'editor' | 'operator';
handleValidation: (pin: string) => boolean;
}
export default function PinPage(props: PinPageProps) {
const { permission, handleValidation } = props;
const [pin, setPin] = useState('');
const [failed, setFailed] = useState(false);
const validate = useCallback(() => {
const isValid = handleValidation(pin);
if (!isValid) {
setFailed(true);
setPin('');
}
}, [handleValidation, pin]);
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
if (event.repeat) return;
if (event.key === 'Enter') {
validate();
}
};
document.addEventListener('keydown', handleKeyPress);
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [validate]);
return (
<div className={style.container}>
{`Ontime ${permission || ''}`}
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
<PinInput
type='alphanumeric'
size='lg'
mask
autoFocus
value={pin}
onChange={(value) => {
setFailed(false);
setPin(value);
}}
>
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
</PinInput>
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
</HStack>
</div>
);
}
@@ -1,91 +0,0 @@
import { useCallback, useContext, useEffect, useState } from 'react';
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { FiCheck } from '@react-icons/all-files/fi/FiCheck';
import PropTypes from 'prop-types';
import { AppContext } from '../../context/AppContext';
import style from './ProtectRoute.module.scss';
export default function ProtectRoute({ children }) {
const isLocal =
window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const [pin, setPin] = useState('');
const [failed, setFailed] = useState(false);
const { auth, validate } = useContext(AppContext);
const handleValidation = useCallback(() => {
const r = validate(pin);
if (!r) {
setFailed(true);
setPin('');
}
}, [pin, validate]);
// Set window title
useEffect(() => {
document.title = 'ontime';
}, []);
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
(e) => {
// handle held key
if (e.repeat) return;
// Space bar
if (e.keyCode === 13) {
handleValidation();
}
},
[handleValidation]
);
useEffect(() => {
// attach the event listener
document.addEventListener('keydown', handleKeyPress);
// remove the event listener
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [handleKeyPress]);
if (isLocal || auth) {
return children;
}
return (
<div className={style.container}>
ontime
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
<PinInput
type='alphanumeric'
size='lg'
mask
autoFocus
value={pin}
onChange={(value) => {
setFailed(false);
setPin(value);
}}
>
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
</PinInput>
<IconButton
aria-label='Enter'
size='lg'
isRound
icon={<FiCheck />}
onClick={() => handleValidation()}
/>
</HStack>
</div>
);
}
ProtectRoute.propTypes = {
children: PropTypes.node.isRequired,
};
@@ -0,0 +1,38 @@
import { PropsWithChildren, useCallback, useContext } from 'react';
import { AppContext } from '../../context/AppContext';
import PinPage from './PinPage';
interface ProtectRouteProps {
permission: 'editor' | 'operator';
}
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const { editorAuth, operatorAuth, validate } = useContext(AppContext);
const handleValidation = useCallback(
(pin: string) => {
return validate(pin, permission);
},
[permission, validate],
);
const hasRelevantAuth = () => {
if (permission === 'editor') {
return editorAuth;
}
if (permission === 'operator') {
return operatorAuth;
}
return false;
};
if (isLocal || hasRelevantAuth()) {
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
return <>{children}</>;
}
return <PinPage permission={permission} handleValidation={handleValidation} />;
}
@@ -32,7 +32,7 @@
}
&:not(:last-child) {
padding-bottom: clamp(16px, 1.5vw, 24px);
padding-bottom: 8px;
}
&--past {
@@ -6,7 +6,7 @@ import { useInterval } from '../../hooks/useInterval';
interface ScheduleContextState {
events: OntimeEvent[];
paginatedEvents: OntimeEvent[];
selectedEventId: string;
selectedEventId: string | null;
numPages: number;
visiblePage: number;
isBackstage: boolean;
@@ -16,22 +16,20 @@ const ScheduleContext = createContext<ScheduleContextState | undefined>(undefine
interface ScheduleProviderProps {
events: OntimeEvent[];
selectedEventId: string;
selectedEventId: string | null;
isBackstage?: boolean;
eventsPerPage?: number;
time?: number;
}
export const ScheduleProvider = (
{
children,
events,
selectedEventId,
isBackstage = false,
eventsPerPage = 4,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
export const ScheduleProvider = ({
children,
events,
selectedEventId,
isBackstage = false,
eventsPerPage = 8,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
const numPages = Math.ceil(events.length / eventsPerPage);
@@ -4,9 +4,9 @@ import './TitleCard.scss';
interface TitleCardProps {
label: 'now' | 'next';
title: string;
subtitle: string;
presenter: string;
title: string | null;
subtitle: string | null;
presenter: string | null;
}
export default function TitleCard(props: TitleCardProps) {
@@ -1,54 +0,0 @@
import { createContext, useCallback, useEffect, useState } from 'react';
import useSettings from '../hooks-query/useSettings';
export const AppContext = createContext({
auth: false,
data: {
pinCode: null,
},
});
export const AppContextProvider = ({ children }) => {
const [auth, setAuth] = useState(true);
const { data } = useSettings();
useEffect(() => {
if (data == null) return;
const previousEntry = sessionStorage.getItem('ontime-entry');
if (previousEntry) {
if (previousEntry === data?.pinCode) {
setAuth(true);
} else {
sessionStorage.removeItem('ontime-entry');
}
} else if (data?.pinCode == null || data?.pinCode === '') {
setAuth(true);
} else {
setAuth(false);
}
}, [data]);
/**
* Validates a pincode
* @return boolean - whether the pin is valid
*/
const validate = useCallback(
(pin) => {
let correct;
if (data?.pinCode == null || data?.pinCode === '') {
correct = true;
} else {
correct = pin === data?.pinCode;
}
if (correct) {
sessionStorage.setItem('ontime-entry', pin);
}
setAuth(correct);
return correct;
},
[data],
);
return <AppContext.Provider value={{ auth, validate }}>{children}</AppContext.Provider>;
};
@@ -0,0 +1,81 @@
import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react';
import useSettings from '../hooks-query/useSettings';
interface AppContextType {
editorAuth: boolean;
operatorAuth: boolean;
validate: (pin: string, permission: 'editor' | 'operator') => boolean;
}
export const AppContext = createContext<AppContextType>({
editorAuth: false,
operatorAuth: false,
validate: () => false,
});
const storageKeys = {
editor: 'ontime-editor-entry',
operator: 'ontime-operator-entry',
};
export const AppContextProvider = ({ children }: PropsWithChildren) => {
const { status, data } = useSettings();
const [editorAuth, setEditorAuth] = useState(true);
const [operatorAuth, setOperatorAuth] = useState(true);
useEffect(() => {
if (status === 'loading') return;
if (!data) return;
const previousEditor = sessionStorage.getItem(storageKeys.editor);
if (previousEditor && previousEditor === data.editorKey) {
setEditorAuth(true);
} else {
setEditorAuth(data.editorKey == null || data.editorKey === '');
}
const previousOperator = sessionStorage.getItem(storageKeys.operator);
if (previousOperator && previousOperator === data.operatorKey) {
setOperatorAuth(true);
} else {
setOperatorAuth(data.operatorKey == null || data.operatorKey === '');
}
}, [data, status]);
/**
* Validates a pincode
* @return boolean - whether the pin is valid
*/
const validate = useCallback(
(pin: string, permission: 'editor' | 'operator'): boolean => {
function isValid(pin: string, savedPin?: string | null): boolean {
return savedPin == null || savedPin === '' || pin === savedPin;
}
if (!data) {
return false;
}
if (permission === 'editor') {
const correct = isValid(pin, data.editorKey);
if (correct) {
sessionStorage.setItem(storageKeys.editor, pin);
}
setEditorAuth(correct);
return correct;
} else if (permission === 'operator') {
const correct = isValid(pin, data.operatorKey);
if (correct) {
sessionStorage.setItem(storageKeys.operator, pin);
}
setOperatorAuth(correct);
return correct;
}
return false;
},
[data],
);
return <AppContext.Provider value={{ editorAuth, operatorAuth, validate }}>{children}</AppContext.Provider>;
};
@@ -1,13 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { EVENTDATA_TABLE } from '../api/apiConstants';
import { EVENT_DATA } from '../api/apiConstants';
import { fetchEventData } from '../api/eventDataApi';
import { eventDataPlaceholder } from '../models/EventData';
export default function useEventData() {
const { data, status, isError, refetch } = useQuery({
queryKey: EVENTDATA_TABLE,
queryKey: EVENT_DATA,
queryFn: fetchEventData,
placeholderData: eventDataPlaceholder,
retry: 5,
@@ -37,6 +37,7 @@ export const setMessage = {
export const usePlaybackControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
});
@@ -47,6 +48,7 @@ export const setPlayback = {
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
roll: () => socketSendJson('roll'),
startNext: () => socketSendJson('start-next'),
previous: () => {
socketSendJson('previous');
},
@@ -90,6 +92,7 @@ export const useCuesheet = () => {
export const setEventPlayback = {
loadEvent: (eventId: string) => socketSendJson('loadid', eventId),
startEvent: (eventId: string) => socketSendJson('startid', eventId),
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
};
@@ -6,5 +6,4 @@ export const eventDataPlaceholder: EventData = {
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
endMessage: '',
};
@@ -4,7 +4,8 @@ export const ontimePlaceholderSettings: Settings = {
app: 'ontime',
version: 2,
serverPort: 4001,
lock: null,
pinCode: null,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
};
@@ -13,4 +13,5 @@ export type OverridableOptions = {
hideMessagesOverlay?: boolean;
hideEndMessage?: boolean;
language?: string;
showProgressBar?: boolean;
};
@@ -2,4 +2,10 @@ import { ViewSettings } from 'ontime-types';
export const viewsSettingsPlaceholder: ViewSettings = {
overrideStyles: false,
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '',
};
+7 -1
View File
@@ -1,3 +1,9 @@
import { QueryClient } from '@tanstack/react-query';
export const ontimeQueryClient = new QueryClient();
export const ontimeQueryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: 1000 * 60 * 10, // 10 min
},
},
});
@@ -0,0 +1,70 @@
import { create } from 'zustand';
export enum AppMode {
Run = 'run',
Edit = 'edit',
}
const appModeKey = 'ontime-app-mode';
function getModeFromSession() {
return localStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
}
async function persistModeToSession(mode: AppMode) {
localStorage.setItem(appModeKey, mode);
}
type AppModeStore = {
mode: AppMode;
cursor: string | null;
editId: string | null;
setMode: (mode: AppMode) => void;
setCursor: (id: string | null, isEditable?: boolean) => void;
setEditId: (id: string | null) => void;
};
export const useAppMode = create<AppModeStore>()((set) => ({
mode: getModeFromSession(),
cursor: null,
editId: null,
setMode: (mode: AppMode) =>
set((state) => {
persistModeToSession(mode);
return mode === AppMode.Edit
? {
editId: state.cursor,
mode: mode,
}
: {
editId: null,
mode: mode,
};
}),
setCursor: (id: string | null, isEditable?: boolean) =>
set((state) => {
if (isEditable) {
return state.mode === AppMode.Edit
? {
cursor: id,
editId: id,
}
: {
cursor: id,
};
} else {
return { cursor: id, editId: null };
}
}),
setEditId: (id: string | null) =>
set((state) => {
return state.mode === AppMode.Edit
? {
cursor: id,
editId: id,
}
: {
editId: id,
};
}),
}));
@@ -1,24 +0,0 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type CursorStore = {
cursor: number;
isCursorLocked: boolean;
toggleCursorLocked: (newValue?: boolean) => void;
moveCursorTo: (index: number) => void;
};
const cursorLockedKey = 'ontime-cursor-islocked';
export const useCursor = create<CursorStore>()((set) => ({
cursor: 0,
isCursorLocked: booleanFromLocalStorage(cursorLockedKey, false),
toggleCursorLocked: (newValue?: boolean) =>
set((state) => {
const val = typeof newValue === 'undefined' ? !state.isCursorLocked : newValue;
localStorage.setItem(cursorLockedKey, String(val));
return { isCursorLocked: val };
}),
moveCursorTo: (index: number) => set(() => ({ cursor: index })),
}));
@@ -1,13 +0,0 @@
import { create } from 'zustand';
type EventEditorStore = {
openId: string | null;
setOpenEvent: (eventId: string) => void;
removeOpenEvent: () => void;
};
export const useEventEditorStore = create<EventEditorStore>()((set) => ({
openId: null,
setOpenEvent: (eventId: string | null) => set({ openId: eventId }),
removeOpenEvent: () => set({ openId: null }),
}));
@@ -2,9 +2,9 @@ import {
forgivingStringToMillis,
formatDisplay,
isTimeString,
millisToDelayString,
millisToMinutes,
millisToSeconds,
timeStringToMillis,
} from '../dateConfig';
describe('test string from formatDisplay function', () => {
@@ -170,88 +170,6 @@ describe('test millisToMinutes function', () => {
});
});
describe('test timeStringToMillis function', () => {
it('test with null', () => {
const t = { val: null, result: 0 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 00:00:00', () => {
const t = { val: '00:00:00', result: 0 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with -00:00:00', () => {
const t = { val: '-00:00:00', result: 0 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 00:00:01', () => {
const t = { val: '00:00:01', result: 1000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with -00:00:01', () => {
const t = { val: '-00:00:01', result: 1000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 01:00:01', () => {
const t = { val: '01:00:01', result: 3601000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 24:00:01', () => {
const t = { val: '24:00:01', result: 86401000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 00:00:5', () => {
const t = { val: '00:00:5', result: 5000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 00:1:00', () => {
const t = { val: '00:1:00', result: 60000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 1:00:00', () => {
const t = { val: '1:00:00', result: 3600000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 1', () => {
const t = { val: '1', result: 1000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 120', () => {
const t = { val: '120', result: 120000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 56', () => {
const t = { val: '56', result: 56000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 2:3', () => {
const t = { val: '2:3', result: 123000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 02:3', () => {
const t = { val: '02:3', result: 123000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
it('test with 2:03', () => {
const t = { val: '2:03', result: 123000 };
expect(timeStringToMillis(t.val)).toBe(t.result);
});
});
describe('test isTimeString() function', () => {
it('it validates time strings', () => {
const ts = ['2', '2:10', '2:10:22'];
@@ -285,12 +203,6 @@ describe('test forgivingStringToMillis()', () => {
{ value: '-0', expect: 0 },
{ value: '1', expect: 60 * 1000 },
{ value: '-1', expect: 60 * 1000 },
{ value: '000000', expect: 0 },
{ value: '000001', expect: 1000 },
{ value: '000100', expect: 1000 * 60 },
{ value: '010000', expect: 1000 * 60 * 60 },
{ value: '230000', expect: 1000 * 60 * 60 * 23 },
{ value: '121212', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
{ value: '0h0m0s', expect: 0 },
{ value: '0h0m1s', expect: 1000 },
{ value: '0h1m0s', expect: 1000 * 60 },
@@ -307,10 +219,6 @@ describe('test forgivingStringToMillis()', () => {
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
it(`handles ${s.value} to right`, () => {
expect(typeof forgivingStringToMillis(s.value, false)).toBe('number');
expect(forgivingStringToMillis(s.value, false)).toBe(s.expect);
});
}
});
@@ -326,16 +234,158 @@ describe('test forgivingStringToMillis()', () => {
it(`handles ${s.value} to the left`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
it(`handles ${s.value} to the right`, () => {
}
});
describe('parses time strings', () => {
const ts = [
{ value: '1h2m3s', expect: 60 * 60 * 1000 + 2 * 60 * 1000 + 3 * 1000 },
{ value: '1h3s', expect: 60 * 60 * 1000 + 3 * 1000 },
{ value: '1h2m', expect: 60 * 60 * 1000 + 2 * 60 * 1000 },
{ value: '10h', expect: 10 * 60 * 60 * 1000 },
{ value: '10m', expect: 10 * 60 * 1000 },
{ value: '10s', expect: 10 * 1000 },
{ value: '120h', expect: 120 * 60 * 60 * 1000 },
{ value: '120m', expect: 120 * 60 * 1000 },
{ value: '120s', expect: 120 * 1000 },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('handles am/pm', () => {
const ampm = [
{ value: '9:10:11am', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
{ value: '9:10:11a', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
{ value: '9:10:11pm', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
{ value: '9:10:11p', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
{ value: '9:10am', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 },
{ value: '9:10a', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 },
{ value: '9:10pm', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 },
{ value: '9:10p', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 },
{ value: '9am', expect: 9 * 60 * 60 * 1000 },
{ value: '9a', expect: 9 * 60 * 60 * 1000 },
{ value: '9pm', expect: (12 + 9) * 60 * 60 * 1000 },
{ value: '9p', expect: (12 + 9) * 60 * 60 * 1000 },
{ value: '12am', expect: 0 },
{ value: '12pm', expect: 12 * 60 * 60 * 1000 },
];
for (const s of ampm) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('it infers separators when non existent', () => {
const testCases = [
{ value: '1', expect: 1000 * 60 }, // 00:01:00
{ value: '12', expect: 1000 * 60 * 12 }, // 00:12:00
{ value: '123', expect: 1000 * 60 * 23 + 1000 * 60 * 60 }, // 01:23:00
{ value: '1234', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 }, // 12:34:00
{ value: '12345', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 + 5 * 1000 }, // 12:34:05
{ value: '123456', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 + 56 * 1000 }, // 12:34:56
];
for (const s of testCases) {
it(`handles basic strings digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const sixDigits = [
{ value: '000000', expect: 0 },
{ value: '000001', expect: 1000 },
{ value: '000100', expect: 1000 * 60 },
{ value: '010000', expect: 1000 * 60 * 60 },
{ value: '230000', expect: 1000 * 60 * 60 * 23 },
{ value: '121212', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
];
for (const s of sixDigits) {
it(`handles string with 6 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const fiveDigits = [
{ value: '00000', expect: 0 },
{ value: '00001', expect: 1000 }, // 00:00:01
{ value: '00010', expect: 1000 * 60 }, // 00:01:00
{ value: '00100', expect: 1000 * 60 * 10 }, // 00:10:00
{ value: '01000', expect: 1000 * 60 * 60 }, // 01:00:00
{ value: '10000', expect: 1000 * 60 * 60 * 10 }, // 10:00:00
{ value: '23000', expect: 1000 * 60 * 60 * 23 }, // 23:00:00
{ value: '12121', expect: 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, // 12:12:01
];
for (const s of fiveDigits) {
it(`handles string with 5 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const fourDigits = [
{ value: '0000', expect: 0 },
{ value: '0001', expect: 1000 * 60 }, // 00:01:00
{ value: '0010', expect: 1000 * 60 * 10 }, // 00:10:00
{ value: '0100', expect: 1000 * 60 * 60 }, // 01:00:00
{ value: '1000', expect: 1000 * 60 * 60 * 10 }, // 10:00:00
{ value: '2300', expect: 1000 * 60 * 60 * 23 }, // 23:00:00
{ value: '1212', expect: 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, // 12:12:00
];
for (const s of fourDigits) {
it(`handles string with 4 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const threeDigits = [
{ value: '000', expect: 0 },
{ value: '001', expect: 1000 * 60 }, // 00:01:00
{ value: '010', expect: 1000 * 60 * 10 }, // 00:10:00
{ value: '100', expect: 1000 * 60 * 60 }, // 01:00:00
{ value: '230', expect: 2 * 1000 * 60 * 60 + 30 * 1000 * 60 }, // 02:30:00
{ value: '121', expect: 21 * 60 * 1000 + 1000 * 60 * 60 }, // 01:21:00
];
for (const s of threeDigits) {
it(`handles string with 3 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const twoDigits = [
{ value: '00', expect: 0 },
{ value: '01', expect: 1000 * 60 }, // 00:01:00
{ value: '10', expect: 1000 * 60 * 10 }, // 00:10:00
{ value: '23', expect: 1000 * 60 * 23 }, // 00:23:00
];
for (const s of twoDigits) {
it(`handles string with 2 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const singleDigit = [...Array(10).keys()];
for (const s of singleDigit) {
it(`handles string with a single digits ${s}`, () => {
expect(forgivingStringToMillis(`${s}`)).toBe(s * 1000 * 60);
});
}
});
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '120', expect: 1000 * 60 * 120 },
{ value: '2.0.0', expect: 1000 * 60 * 120 },
{ value: '99', expect: 1000 * 60 * 99 },
{ value: '1.39.0', expect: 1000 * 60 * 99 },
@@ -354,58 +404,10 @@ describe('test forgivingStringToMillis()', () => {
it(`handles ${s.value} to the left`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
it(`handles ${s.value} to the right`, () => {
expect(forgivingStringToMillis(s.value, false)).toBe(s.expect);
});
}
});
describe('test with fillRight (legacy)', () => {
describe('function handles separators', () => {
const testData = [
{ value: '1:2:3:10', expect: 3723000 },
{ value: '2,10', expect: 130000 },
{ value: '2.10', expect: 130000 },
{ value: '2 10', expect: 130000 },
];
for (const s of testData) {
it(`handles ${s.value}`, () => {
expect(typeof forgivingStringToMillis(s.value, false)).toBe('number');
expect(forgivingStringToMillis(s.value, false)).toBe(s.expect);
});
}
});
describe('parses strings correctly', () => {
const ts = [
{ value: '1.2', expect: 60 * 1000 + 2 * 1000 },
{ value: '1.70', expect: 60 * 1000 + 70 * 1000 },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value, false)).toBe(s.expect);
});
}
});
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '0.120', expect: 120 * 1000 },
{ value: '0.99', expect: 99 * 1000 },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value, false)).toBe(s.expect);
});
}
});
});
describe('test with fillLeft', () => {
describe('test fillLeft', () => {
describe('function handles separators', () => {
const testData = [
{ value: '1:2:3:10', expect: 3723000 },
@@ -450,3 +452,59 @@ describe('test forgivingStringToMillis()', () => {
});
});
});
describe('millisToDelayString()', () => {
it('returns null for null values', () => {
expect(millisToDelayString(null)).toBeNull();
});
it('returns null 0', () => {
expect(millisToDelayString(0)).toBeNull();
});
describe('converts values in seconds', () => {
it('shows a simple string with value in seconds', () => {
expect(millisToDelayString(10000)).toBe('+10 sec');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-10000)).toBe('-10 sec');
});
const underAMinute = [1, 500, 1000, 6000, 55000, 59999];
underAMinute.forEach((value) => {
it(`handles ${value}`, () => {
expect(millisToDelayString(value)?.endsWith('sec')).toBe(true);
});
});
expect(millisToDelayString(null)).toBeNull();
});
describe('converts values in minutes', () => {
it('shows a simple string with value in minutes', () => {
expect(millisToDelayString(720000)).toBe('+12 min');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-720000)).toBe('-12 min');
});
it('shows a simple string with value in minutes and seconds', () => {
expect(millisToDelayString(630000)).toBe('+00:10:30');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-630000)).toBe('-00:10:30');
});
const underAnHour = [60000, 360000, 720000];
underAnHour.forEach((value) => {
it(`handles ${value}`, () => {
expect(millisToDelayString(value)?.endsWith('min')).toBe(true);
});
});
});
describe('converts values with full time string', () => {
it('positive added time', () => {
expect(millisToDelayString(45015000)).toBe('+12:30:15');
});
it('negative added time', () => {
expect(millisToDelayString(-45015000)).toBe('-12:30:15');
});
});
});
@@ -1,127 +1,232 @@
import { formatEventList, getEventsWithDelay, trimEventlist } from '../eventsManager';
import { formatEventList, getEventsWithDelay, trimRundown } from '../eventsManager';
test('getEventsWithDelay function', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
duration: 60000,
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000,
timeEnd: 720000,
colour: '',
type: 'event',
id: '8222',
},
{
duration: 900000,
type: 'delay',
revision: 0,
id: 'a386',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000,
timeEnd: 38520000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000,
timeEnd: 45120000,
colour: '',
type: 'event',
id: '2651',
},
{
type: 'block',
id: 'e6a1',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
describe('getEventsWithDelay function', () => {
test('with positive delays', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
duration: 60000,
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000,
timeEnd: 720000,
colour: '',
type: 'event',
id: '8222',
},
{
duration: 900000,
type: 'delay',
revision: 0,
id: 'a386',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000,
timeEnd: 38520000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000,
timeEnd: 45120000,
colour: '',
type: 'event',
id: '2651',
},
{
type: 'block',
id: 'e6a1',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000 + 60000,
timeEnd: 720000 + 60000,
colour: '',
type: 'event',
id: '8222',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000 + 60000 + 900000,
timeEnd: 38520000 + 60000 + 900000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000 + 60000 + 900000,
timeEnd: 45120000 + 60000 + 900000,
colour: '',
type: 'event',
id: '2651',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000 + 60000,
timeEnd: 720000 + 60000,
colour: '',
type: 'event',
id: '8222',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000 + 60000 + 900000,
timeEnd: 38520000 + 60000 + 900000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000 + 60000 + 900000,
timeEnd: 45120000 + 60000 + 900000,
colour: '',
type: 'event',
id: '2651',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
test('with negative delays', () => {
const testData = [
{
duration: -20,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 100,
timeEnd: 200,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 80,
timeEnd: 180,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
});
describe('getEventsWithDelay edge cases', () => {
it('ensures time start cannot be below 0', () => {
const testData = [
{
duration: -200,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 10,
timeEnd: 20,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 0,
timeEnd: 0,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
it('does not modify original array', () => {
const testData = [
{
duration: 10,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 10,
timeEnd: 20,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 20,
timeEnd: 30,
colour: '',
type: 'event',
id: '5946',
},
];
const expectedSafe = [
{
title: 'Welcome to Ontime',
timeStart: 20,
timeEnd: 30,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
expect(getEventsWithDelay(expectedSafe)).toStrictEqual(expected);
});
it('given an empty array', () => {
const emptyArray = {
test: [],
@@ -260,7 +365,7 @@ describe('test trimEventlist function', () => {
{ id: '8' },
];
const l = trimEventlist(testData, selectedId, limit);
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
@@ -278,7 +383,7 @@ describe('test trimEventlist function', () => {
{ id: '8' },
];
const l = trimEventlist(testData, selectedId, limit);
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
@@ -296,7 +401,7 @@ describe('test trimEventlist function', () => {
{ id: '9' },
];
const l = trimEventlist(testData, selectedId, limit);
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
@@ -314,7 +419,7 @@ describe('test trimEventlist function', () => {
{ id: '8' },
];
const l = trimEventlist(testData, selectedId, limit);
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
+155 -65
View File
@@ -1,3 +1,5 @@
import { formatFromMillis } from 'ontime-utils';
import { mth, mtm, mts } from './timeConstants';
export const timeFormat = 'HH:mm';
@@ -40,27 +42,13 @@ export const millisToSeconds = (millis: number | null): number => {
/**
* @description Converts milliseconds to seconds
* @param {number} millis - time in seconds
* @param {number} millis - time in milliseconds
* @returns {number} Amount in seconds
*/
export const millisToMinutes = (millis: number): number => {
return millis < 0 ? Math.ceil(millis / mtm) : Math.floor(millis / mtm);
};
/**
* @description Converts timestring to milliseconds
* @param {string} string - time string "23:00:12"
* @returns {number} Amount in milliseconds
*/
export const timeStringToMillis = (string: string): number => {
if (typeof string !== 'string') return 0;
const time = string.split(':');
if (time.length === 1) return Math.abs(time[0] * mts);
if (time.length === 2) return Math.abs(time[0]) * mtm + time[1] * mts;
if (time.length === 3) return Math.abs(time[0]) * mth + time[1] * mtm + time[2] * mts;
return 0;
};
/**
* @description Validates a time string
* @param {string} string - time string "23:00:12"
@@ -95,58 +83,160 @@ const parse = (valueAsString: string): number => {
};
/**
* @description Parses a time string to millis
* @param {string} value - time string
* @param {boolean} fillLeft - autofill left = hours / right = seconds
* @returns {number} - time string in millis
* @description Utility function to check if a string contain am/pm indicators
* @param {string} value
*/
export const forgivingStringToMillis = (value: string, fillLeft = true): number => {
let millis = 0;
const hours = parseInt(value.match(/(\d+)h/)?.[0] ?? 0, 10);
const minutes = parseInt(value.match(/(\d+)m/)?.[0] ?? 0, 10);
const seconds = parseInt(value.match(/(\d+)s/)?.[0] ?? 0, 10);
if (hours > 0 || minutes > 0 || seconds > 0) {
millis = hours * mth + minutes * mtm + seconds * mts;
} else {
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = value.split(separatorRegex);
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * mth;
millis += parse(second) * mtm;
millis += parse(third) * mts;
} else if (first != null && second == null && third == null) {
// if string has one section,
// could be a complete string like 121010 - 12:10:10
if (first.length === 6) {
const hours = first.substring(0, 2);
const minutes = first.substring(2, 4);
const seconds = first.substring(4);
millis = parse(hours) * mth;
millis += parse(minutes) * mtm;
millis += parse(seconds) * mts;
} else {
// otherwise lets treat as [minutes]
millis = parse(first) * mtm;
}
}
if (first != null && second != null && third == null) {
// if string has two sections
if (fillLeft) {
// treat as [hours] [minutes]
millis = parse(first) * mth;
millis += parse(second) * mtm;
} else {
// treat as [minutes] [seconds]
millis = parse(first) * mtm;
millis += parse(second) * mts;
}
}
function checkAmPm(value: string) {
let isPM = false;
let isAM = false;
if (value.toLowerCase().includes('pm')) {
isPM = true;
value = value.replace(/pm/i, '');
} else if (value.toLowerCase().includes('p')) {
isPM = true;
value = value.replace(/p/i, '');
}
// we need to remove am, but it doesn't actually change anything
if (value.toLowerCase().includes('am')) {
isAM = true;
value = value.replace(/am/i, '');
} else if (value.toLowerCase().includes('a')) {
isAM = true;
value = value.replace(/a/i, '');
}
return { isAM, isPM, value };
}
/**
* @description Utility function to check if a string contain h / m / s indicators
* @param {string} value
*/
function checkMatchers(value: string) {
const hoursMatch = /(\d+)h/.exec(value);
const hoursMatchValue = hoursMatch ? parse(hoursMatch[1]) : 0;
const minutesMatch = /(\d+)m/.exec(value);
const minutesMatchValue = minutesMatch ? parse(minutesMatch[1]) : 0;
const secondsMatch = /(\d+)s/.exec(value);
const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0;
if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) {
return hoursMatchValue * mth + minutesMatchValue * mtm + secondsMatchValue * mts;
}
return { hoursMatchValue };
}
/**
* @description Utility function to infer separators from a whole string
* @param {string} value
* @param {boolean} isAM
* @param {boolean} isPM
*/
function inferSeparators(value: string, isAM: boolean, isPM: boolean) {
const length = value.length;
let inferredMillis = 0;
let addAM = 0;
if (length === 1) {
if (isPM || isAM) {
inferredMillis = parse(value) * mth;
if (isAM) {
// this ensures we dont add 12 hours in the end
addAM = inferredMillis;
}
} else {
inferredMillis = parse(value) * mtm;
}
} else if (length === 2) {
if (isPM || isAM) {
inferredMillis = parse(value) * inferredMillis;
if (isAM) {
// this ensures we dont add 12 hours in the end
addAM = 12;
}
} else {
inferredMillis = parse(value) * mtm;
}
} else if (length === 3) {
inferredMillis = parse(value[0]) * mth + parse(value.substring(1)) * mtm;
} else if (length === 4) {
inferredMillis = parse(value.substring(0, 2)) * mth + parse(value.substring(2)) * mtm;
} else if (length === 5) {
const hours = parse(value.substring(0, 2));
const minutes = parse(value.substring(2, 4));
const seconds = parse(value.substring(4));
inferredMillis = hours * mth + minutes * mtm + seconds * mts;
} else if (length >= 6) {
const hours = parse(value.substring(0, 2));
const minutes = parse(value.substring(2, 4));
const seconds = parse(value.substring(4));
inferredMillis = hours * mth + minutes * mtm + seconds * mts;
}
return { inferredMillis, addAM };
}
/**
* @description Parses a time string to millis, auto-filling to the left
* @param {string} value - time string
* @returns {number} - time string in millis
*/
export const forgivingStringToMillis = (value: string): number => {
if (value === '12am') {
return 0;
}
const { isAM, isPM, value: parsingValue } = checkAmPm(value);
const maybeMillisFromMatchers = checkMatchers(parsingValue);
if (typeof maybeMillisFromMatchers === 'number') {
return maybeMillisFromMatchers;
}
let { hoursMatchValue } = maybeMillisFromMatchers;
let millis = 0;
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = parsingValue.split(separatorRegex);
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * mth;
millis += parse(second) * mtm;
millis += parse(third) * mts;
} else if (first != null && second == null && third == null) {
// we only have one section, infer separators
const { inferredMillis, addAM } = inferSeparators(first, isAM, isPM);
millis = inferredMillis;
hoursMatchValue = addAM;
}
if (first != null && second != null && third == null) {
millis = parse(first) * mth;
millis += parse(second) * mtm;
}
// Add 12 hours if it is PM
if (isPM && hoursMatchValue < 12) {
millis += 12 * mth;
}
return millis;
};
export function millisToDelayString(millis: number | null): undefined | string | null {
if (millis == null || millis === 0) {
return null;
}
const isNegative = millis < 0;
const absMillis = Math.abs(millis);
if (absMillis < mtm) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 's')} sec`;
} else if (absMillis < mth && absMillis % mtm === 0) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'm')} min`;
} else {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
}
}
+75 -25
View File
@@ -4,74 +4,85 @@ import { formatTime } from './time';
/**
* @description From a list of events, returns only events of type event with calculated delays
* @param {Object[]} events - given events
* @param {Object[]} rundown - given rundown
* @returns {Object[]} Filtered events with calculated delays
*/
export const getEventsWithDelay = (events: OntimeRundownEntry[]): OntimeEvent[] => {
if (events == null) return [];
export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[] => {
if (rundown == null) return [];
const unfilteredEvents = [...events];
const delayedEvents: OntimeEvent[] = [];
// Add running delay
let delay = 0;
for (const event of unfilteredEvents) {
for (const event of rundown) {
if (event.type === SupportedEvent.Block) delay = 0;
else if (event.type === SupportedEvent.Delay) delay = delay + event.duration;
else if (event.type === SupportedEvent.Event && delay > 0) {
event.timeStart += delay;
event.timeEnd += delay;
else if (event.type === SupportedEvent.Delay) {
if (typeof event.duration === 'number') {
delay += event.duration;
}
} else if (event.type === SupportedEvent.Event) {
const delayedEvent = { ...event };
if (delay !== 0) {
delayedEvent.timeStart = Math.max(delayedEvent.timeStart + delay, 0);
delayedEvent.timeEnd = Math.max(delayedEvent.timeEnd + delay, 0);
}
delayedEvents.push(delayedEvent);
}
}
// filter just events
return unfilteredEvents.filter((event) => event.type === SupportedEvent.Event) as OntimeEvent[];
return delayedEvents;
};
/**
* @description Returns trimmed event list array
* @param {Object[]} events - given events
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects
*/
export const trimEventlist = (events: OntimeRundownEntry[], selectedId: string, limit: number) => {
if (events == null) return [];
export const trimRundown = (rundown: OntimeRundownEntry[], selectedId: string, limit: number) => {
if (rundown == null) return [];
const BEFORE = 2;
const trimmedEvents = [...events];
const trimmedRundown = [...rundown];
// limit events length if necessary
if (limit != null) {
while (trimmedEvents.length > limit) {
const idx = trimmedEvents.findIndex((e) => e.id === selectedId);
while (trimmedRundown.length > limit) {
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) {
trimmedEvents.pop();
trimmedRundown.pop();
} else {
trimmedEvents.shift();
trimmedRundown.shift();
}
}
}
return trimmedEvents;
return trimmedRundown;
};
type FormatEventListOptionsProp = {
showEnd?: boolean;
}
};
/**
* @description Returns list of events formatted to be displayed
* @param {Object[]} events - given events
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event
* @param {object} [options]
* @param {boolean} [options.showEnd] - whether to show the end time
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/
export const formatEventList = (events: OntimeEvent[], selectedId: string, nextId: string, options: FormatEventListOptionsProp) => {
if (events == null) return [];
export const formatEventList = (
rundown: OntimeEvent[],
selectedId: string,
nextId: string,
options: FormatEventListOptionsProp,
) => {
if (rundown == null) return [];
const { showEnd = false } = options;
const givenEvents = [...events];
const givenEvents = [...rundown];
// format list
const formattedEvents = [];
@@ -113,3 +124,42 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
after: after,
};
};
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeEvent | null}
*/
export function getFirstEvent(rundown: OntimeRundownEntry[]) {
return rundown.length ? rundown[0] : null;
}
/**
* Gets next event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index + 1 < rundown.length) {
return rundown[index + 1];
} else {
return null;
}
}
/**
* Gets previous event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index - 1 >= 0) {
return rundown[index - 1];
} else {
return null;
}
}
+3 -7
View File
@@ -1,6 +1,5 @@
import { DateTime } from 'luxon';
import { Settings } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { formatFromMillis, millisToString } from 'ontime-utils';
import { APP_SETTINGS } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
@@ -39,7 +38,6 @@ type FormatOptions = {
};
/**
/**
* @description utility function to format a date in 12 or 24 hour format
* @param {number | null} milliseconds
* @param {object} [options]
@@ -48,13 +46,11 @@ type FormatOptions = {
* @param {function} resolver
* @return {string}
*/
export const formatTime = (milliseconds: number | null, options: FormatOptions, resolver = resolveTimeFormat) => {
export const formatTime = (milliseconds: number | null, options?: FormatOptions, resolver = resolveTimeFormat) => {
if (milliseconds === null) {
return '...';
}
const timeFormat = resolver();
const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {};
return timeFormat === '12'
? DateTime.fromMillis(milliseconds).toUTC().toFormat(formatString)
: millisToString(milliseconds, showSeconds);
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
};
@@ -0,0 +1,6 @@
export function isStringBoolean(text: string | null) {
if (text === null) {
return false;
}
return text?.toLowerCase() === 'true' || text === '1';
}
+4
View File
@@ -0,0 +1,4 @@
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const gitbookUrl = 'https://ontime.gitbook.io';
@@ -1,16 +1,12 @@
import { ReactNode } from 'react';
import { PropsWithChildren } from 'react';
import ProtectRoute from '../common/components/protect-route/ProtectRoute';
import style from './FeatureWrapper.module.scss';
interface FeatureWrapperProps {
children: ReactNode;
}
export default function FeatureWrapper({ children }: FeatureWrapperProps) {
export default function EditorFeatureWrapper({ children }: PropsWithChildren) {
return (
<ProtectRoute>
<ProtectRoute permission='editor'>
<div className={style.wrapper}>{children}</div>
</ProtectRoute>
);
@@ -1,19 +0,0 @@
import { Playback } from 'ontime-types';
import PlaybackDisplay from './PlaybackDisplay';
import Transport from './Transport';
interface PlaybackButtonsProps {
playback: Playback;
noEvents: boolean;
}
export default function PlaybackButtons(props: PlaybackButtonsProps) {
const { playback, noEvents } = props;
return (
<>
<PlaybackDisplay playback={playback} noEvents={noEvents} />
<Transport playback={playback} noEvents={noEvents} />
</>
);
};
@@ -1,130 +1,4 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
@use '../../../theme/mixins' as *;
.mainContainer {
width: 100%;
display: grid;
margin: 0 auto;
gap: $element-inner-spacing;
}
.timeContainer {
display: grid;
grid-template-areas:
'ind clk clk btn'
'... sta fin btn';
grid-template-rows: 1fr auto;
grid-template-columns: 1.5em 1fr 1fr 5em;
gap: $element-inner-spacing;
justify-items: start;
}
.timer {
grid-area: clk;
white-space: nowrap;
max-width: 300px;
}
.indicators {
grid-area: ind;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
.indRoll,
.indDelay,
.indNegative {
background-color: $gray-1350;
}
.indRoll,
.indRollActive,
.indDelay,
.indDelayActive {
margin: 0 auto;
border-radius: 6px;
width: 12px;
height: 12px;
}
.indRollActive {
background-color: $ontime-roll;
}
.indNegative,
.indNegativeActive {
margin: 0 auto;
width: 90%;
height: 4px;
}
.indNegativeActive {
background-color: $playback-negative;
}
.indDelayActive {
background-color: $ontime-delay;
}
.btn {
grid-area: btn;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 100%;
gap: $element-inner-spacing;
}
.minus {
grid-area: min;
display: flex;
flex-direction: column;
}
.start,
.finish,
.roll {
height: 24px;
}
.start {
grid-area: sta;
}
.finish {
grid-area: fin;
}
.roll {
grid-area: 2 / 2 / 2 / 4 ;
}
.time {
color: $section-white;
font-size: $text-body-size;
}
.tag {
color: $label-gray;
font-size: 13px;
}
.rolltag {
color: $ontime-roll;
font-size: $text-body-size;
}
.playbackContainer {
display: flex;
justify-content: space-evenly;
padding-top: 0.5em;
gap: $element-spacing;
}
.invertX {
transform: rotateY(180deg);
}
@@ -2,8 +2,8 @@ import { Playback } from 'ontime-types';
import { usePlaybackControl } from '../../../common/hooks/useSocket';
import PlaybackButtons from './PlaybackButtons';
import PlaybackTimer from './PlaybackTimer';
import PlaybackButtons from './playback-buttons/PlaybackButtons';
import PlaybackTimer from './playback-timer/PlaybackTimer';
import style from './PlaybackControl.module.scss';
@@ -13,7 +13,11 @@ export default function PlaybackControl() {
return (
<div className={style.mainContainer}>
<PlaybackTimer playback={data.playback as Playback} />
<PlaybackButtons playback={data.playback} noEvents={data.numEvents < 1} />
<PlaybackButtons
playback={data.playback}
numEvents={data.numEvents}
selectedEventIndex={data.selectedEventIndex}
/>
</div>
);
}
@@ -1,55 +0,0 @@
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
import { Playback } from 'ontime-types';
import { setPlayback } from '../../../common/hooks/useSocket';
import TapButton from './TapButton';
import style from './PlaybackControl.module.scss';
interface PlaybackProps {
playback: Playback;
noEvents: boolean;
}
export default function PlaybackDisplay(props: PlaybackProps) {
const { playback, noEvents } = props;
const isRolling = playback === Playback.Roll;
const isPlaying = playback === Playback.Play;
const isPaused = playback === Playback.Pause;
const isArmed = playback === Playback.Armed;
const isStopped = playback === Playback.Stop;
return (
<div className={style.playbackContainer}>
<TapButton
onClick={() => setPlayback.start()}
disabled={isStopped || isRolling}
theme={Playback.Play}
active={isPlaying}
>
<IoPlay />
</TapButton>
<TapButton
onClick={() => setPlayback.pause()}
disabled={isStopped || isRolling || isArmed}
theme={Playback.Pause}
active={isPaused}
>
<IoPause />
</TapButton>
<TapButton
onClick={() => setPlayback.roll()}
disabled={!isStopped || noEvents}
theme={Playback.Roll}
active={isRolling}
>
<IoTimeOutline />
</TapButton>
</div>
);
}
@@ -1,30 +0,0 @@
import { ForwardedRef, forwardRef, PropsWithChildren } from 'react';
import { Playback } from 'ontime-types';
import style from './TapButton.module.scss';
interface TapButtonProps {
disabled?: boolean;
square?: boolean;
onClick: () => void;
theme?: Playback | 'neutral';
active?: boolean;
}
const TapButton = forwardRef((props: PropsWithChildren<TapButtonProps>, ref: ForwardedRef<HTMLButtonElement> ) => {
const { children, disabled, onClick, theme = 'neutral', square, active } = props;
return (
<button
className={`${style.tapButton} ${style[theme]} ${square ? style.square : ''} ${active ? style.active : ''}`}
disabled={disabled}
type='button'
onClick={onClick}
ref={ref}
>
{children}
</button>
);
});
TapButton.displayName = "TabButton";
export default TapButton;
@@ -1,49 +0,0 @@
import { Tooltip } from '@chakra-ui/react';
import { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback } from 'ontime-types';
import { setPlayback } from '../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../ontimeConfig';
import TapButton from './TapButton';
import style from './PlaybackControl.module.scss';
interface TransportProps {
playback: Playback;
noEvents: boolean;
}
export default function Transport(props: TransportProps) {
const { playback, noEvents } = props;
const isRolling = playback === Playback.Roll;
const isStopped = playback === Playback.Stop;
return (
<div className={style.playbackContainer}>
<Tooltip label='Previous event' openDelay={tooltipDelayMid}>
<TapButton onClick={() => setPlayback.previous()} disabled={isRolling || noEvents}>
<IoPlaySkipBack />
</TapButton>
</Tooltip>
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
<TapButton onClick={() => setPlayback.next()} disabled={isRolling || noEvents}>
<IoPlaySkipForward />
</TapButton>
</Tooltip>
<Tooltip label='Reload event' openDelay={tooltipDelayMid}>
<TapButton onClick={() => setPlayback.reload()} disabled={isStopped || isRolling}>
<IoReload className={style.invertX} />
</TapButton>
</Tooltip>
<Tooltip label='Unload Event' openDelay={tooltipDelayMid}>
<TapButton onClick={() => setPlayback.stop()} disabled={isStopped && !isRolling} theme={Playback.Stop}>
<IoStop />
</TapButton>
</Tooltip>
</div>
);
}
@@ -0,0 +1,43 @@
@use '../../../../theme/v2Styles' as *;
.buttonContainer {
padding-top: $element-spacing;
display: grid;
grid-template-areas:
"go playback"
"go transport"
"extra extra";
grid-template-rows: repeat(3, 32px);
gap: $element-spacing;
}
.go {
grid-area: go;
font-size: 5em;
}
@mixin spaced-flex {
display: flex;
justify-content: space-evenly;
gap: $element-spacing;
}
.playbackContainer {
grid-area: playback;
@include spaced-flex;
}
.transportContainer {
grid-area: transport;
@include spaced-flex;
}
.extra {
grid-area: extra;
@include spaced-flex;
}
.invertX {
transform: rotateY(180deg);
}
@@ -0,0 +1,107 @@
import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
import { Playback } from 'ontime-types';
import { setPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton';
import styles from './PlaybackButtons.module.scss';
import style from './PlaybackButtons.module.scss';
interface PlaybackButtonsProps {
playback: Playback;
numEvents: number;
selectedEventIndex: number | null;
}
export default function PlaybackButtons(props: PlaybackButtonsProps) {
const { playback, numEvents, selectedEventIndex } = props;
const isRolling = playback === Playback.Roll;
const isPlaying = playback === Playback.Play;
const isPaused = playback === Playback.Pause;
const isArmed = playback === Playback.Armed;
const isStopped = playback === Playback.Stop;
const isFirst = selectedEventIndex === 0;
const isLast = selectedEventIndex === numEvents - 1;
const noEvents = numEvents === 0;
const disableGo = isRolling || noEvents || (isLast && !isArmed);
const disablePrev = isRolling || noEvents || isFirst;
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next';
const goModeAction = () => {
if (isArmed) {
setPlayback.start();
} else {
setPlayback.startNext();
}
};
return (
<div className={styles.buttonContainer}>
<TapButton disabled={disableGo} onClick={goModeAction} aspect='fill' className={styles.go}>
{goModeText}
</TapButton>
<div className={style.playbackContainer}>
<TapButton
onClick={setPlayback.start}
disabled={isStopped || isRolling}
theme={Playback.Play}
active={isPlaying}
>
<IoPlay />
</TapButton>
<TapButton
onClick={setPlayback.pause}
disabled={isStopped || isRolling || isArmed}
theme={Playback.Pause}
active={isPaused}
>
<IoPause />
</TapButton>
</div>
<div className={style.transportContainer}>
<Tooltip label='Previous event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.previous} disabled={disablePrev}>
<IoPlaySkipBack />
</TapButton>
</Tooltip>
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.next} disabled={disableGo}>
<IoPlaySkipForward />
</TapButton>
</Tooltip>
</div>
<div className={styles.extra}>
<TapButton
onClick={setPlayback.roll}
disabled={!isStopped || noEvents}
theme={Playback.Roll}
active={isRolling}
>
<IoTimeOutline />
</TapButton>
<Tooltip label='Reload event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.reload} disabled={isStopped || isRolling}>
<IoReload className={style.invertX} />
</TapButton>
</Tooltip>
<Tooltip label='Unload Event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.stop} disabled={isStopped && !isRolling} theme={Playback.Stop}>
<IoStop />
</TapButton>
</Tooltip>
</div>
</div>
);
}
@@ -0,0 +1,111 @@
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
.timeContainer {
display: grid;
grid-template-areas:
'ind clk clk btn'
'... sta fin btn';
grid-template-rows: 1fr auto;
grid-template-columns: 1.5em 1fr 1fr 5em;
gap: $element-inner-spacing;
justify-items: start;
}
.timer {
grid-area: clk;
white-space: nowrap;
max-width: 300px;
}
.indicators {
grid-area: ind;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
.indRoll,
.indDelay,
.indNegative {
background-color: $black-10;
}
.indRoll,
.indRollActive,
.indDelay,
.indDelayActive {
margin: 0 auto;
border-radius: 6px;
width: 12px;
height: 12px;
}
.indRollActive {
background-color: $ontime-roll;
}
.indNegative,
.indNegativeActive {
margin: 0 auto;
width: 90%;
height: 4px;
}
.indNegativeActive {
background-color: $playback-negative;
}
.indDelayActive {
background-color: $ontime-delay;
}
.btn {
grid-area: btn;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 100%;
gap: $element-inner-spacing;
}
.minus {
grid-area: min;
display: flex;
flex-direction: column;
}
.start,
.finish,
.roll {
height: 24px;
}
.start {
grid-area: sta;
}
.finish {
grid-area: fin;
}
.roll {
grid-area: 2 / 2 / 2 / 4 ;
}
.time {
color: $section-white;
font-size: $text-body-size;
}
.tag {
color: $label-gray;
font-size: 13px;
}
.rolltag {
color: $ontime-roll;
font-size: $text-body-size;
}
@@ -2,14 +2,13 @@ import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import TimerDisplay from '../../../common/components/timer-display/TimerDisplay';
import { setPlayback, useTimer } from '../../../common/hooks/useSocket';
import { millisToMinutes, millisToSeconds } from '../../../common/utils/dateConfig';
import { tooltipDelayMid } from '../../../ontimeConfig';
import TimerDisplay from '../../../../common/components/timer-display/TimerDisplay';
import { setPlayback, useTimer } from '../../../../common/hooks/useSocket';
import { millisToMinutes, millisToSeconds } from '../../../../common/utils/dateConfig';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton';
import TapButton from './TapButton';
import style from './PlaybackControl.module.scss';
import style from './PlaybackTimer.module.scss';
interface PlaybackTimerProps {
playback: Playback;
@@ -36,7 +35,7 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
if (ms < 6000) {
return `${millisToSeconds(ms)} seconds`;
} else if (ms < 12000) {
return `1 minute`;
return '1 minute';
} else {
return `${millisToMinutes(ms)} minutes`;
}
@@ -87,22 +86,22 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
)}
<div className={style.btn}>
<Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-1)} disabled={disableButtons} square>
<TapButton onClick={() => setPlayback.delay(-1)} disabled={disableButtons} aspect='square'>
-1
</TapButton>
</Tooltip>
<Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(1)} disabled={disableButtons} square>
<TapButton onClick={() => setPlayback.delay(1)} disabled={disableButtons} aspect='square'>
+1
</TapButton>
</Tooltip>
<Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-5)} disabled={disableButtons} square>
<TapButton onClick={() => setPlayback.delay(-5)} disabled={disableButtons} aspect='square'>
-5
</TapButton>
</Tooltip>
<Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(+5)} disabled={disableButtons} square>
<TapButton onClick={() => setPlayback.delay(+5)} disabled={disableButtons} aspect='square'>
+5
</TapButton>
</Tooltip>
@@ -1,15 +1,14 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
$button-bg-gray: $gray-1050;
$button-color-white: $gray-50;
@mixin tap-factory($theme-color) {
font-family: $ontime-font-family;
font-size: 22px;
font-size: 18px;
border-radius: $component-border-radius-md;
width: 100%;
aspect-ratio: 3/1;
transition-property: color, background-color;
transition-duration: $transition-time-feedback;
display: grid;
@@ -78,7 +77,17 @@ $button-color-white: $gray-50;
@include tap-factory($ontime-stop);
}
.tapButton.normal {
aspect-ratio: 3/1;
}
.tapButton.square {
aspect-ratio: 1;
font-size: 14px;
}
.tapButton.fill {
aspect-ratio: unset;
height: 100%;
font-size: 1.5em;
}
@@ -0,0 +1,31 @@
import { ForwardedRef, forwardRef, PropsWithChildren } from 'react';
import { Playback } from 'ontime-types';
import { cx } from '../../../../common/utils/styleUtils';
import style from './TapButton.module.scss';
interface TapButtonProps {
disabled?: boolean;
aspect?: 'normal' | 'square' | 'fill';
square?: boolean;
free?: boolean;
onClick: () => void;
theme?: Playback | 'neutral';
active?: boolean;
className?: string;
}
const TapButton = forwardRef((props: PropsWithChildren<TapButtonProps>, ref: ForwardedRef<HTMLButtonElement>) => {
const { children, disabled, onClick, theme = 'neutral', aspect = 'normal', active, className } = props;
const classes = cx([style.tapButton, className, style[theme], style[aspect], active ? style.active : null]);
return (
<button className={classes} disabled={disabled} type='button' onClick={onClick} ref={ref}>
{children}
</button>
);
});
TapButton.displayName = 'TabButton';
export default TapButton;
@@ -37,11 +37,11 @@ $playback-width: 450px;
grid-template-rows: auto 1fr;
grid-template-columns: $menu-width $rundown-width $playback-width auto;
grid-template-areas:
'sett even play info'
'sett even mess info';
'sett rundown play info'
'sett rundown mess info';
gap: 8px;
.editor,
.rundown,
.playback,
.messages,
.info,
@@ -98,7 +98,7 @@ $playback-width: 450px;
visibility: visible;
}
.editor,
.rundown,
.info,
.settings {
visibility: hidden;
@@ -117,7 +117,7 @@ $playback-width: 450px;
visibility: visible;
}
.editor,
.rundown,
.messages,
.info,
.settings {
@@ -128,7 +128,7 @@ $playback-width: 450px;
.mainContainer {
.settings,
.editor,
.rundown,
.messages,
.playback,
.info {
@@ -145,7 +145,7 @@ $playback-width: 450px;
border-radius: 8px 8px 0 0;
background-color: $bg-container-l2;
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
border-top: 1px solid $white-10;
border-top: 1px solid $white-20;
position: absolute;
bottom: 0;
width: 100vw;
@@ -164,15 +164,16 @@ $playback-width: 450px;
}
.header {
background-color: #202020;
background-color: $gray-1200;
padding: 8px;
border-left: 1px solid $white-10;
border-radius: 0 8px 0 0;
}
}
.editor {
grid-area: even;
height: calc(100% - 24px);
.rundown {
grid-area: rundown;
height: 100%;
.content {
height: calc(100% - 24px);
@@ -183,11 +184,11 @@ $playback-width: 450px;
.info {
grid-area: info;
min-width: 17em;
max-width: 800px;
.content {
display: flex;
flex-direction: column;
height: calc(100% - 24px);
overflow: hidden;
}
}
+14 -4
View File
@@ -4,8 +4,10 @@ import { Box, useDisclosure } from '@chakra-ui/react';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import UploadModal from '../../common/components/upload-modal/UploadModal';
import MenuBar from '../menu/MenuBar';
import AboutModal from '../modals/about-modal/AboutModal';
import IntegrationModal from '../modals/integration-modal/IntegrationModal';
import ModalManager from '../modals/ModalManager';
import QuickStart from '../modals/quick-start/QuickStart';
import SettingsModal from '../modals/settings-modal/SettingsModal';
import styles from './Editor.module.scss';
@@ -23,6 +25,8 @@ export default function Editor() {
onOpen: onIntegrationModalOpen,
onClose: onIntegrationModalClose,
} = useDisclosure();
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
// Set window title
useEffect(() => {
@@ -31,10 +35,12 @@ export default function Editor() {
return (
<>
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
<ErrorBoundary>
<ModalManager isOpen={isSettingsOpen} onClose={onSettingsClose} />
<QuickStart onClose={onQuickStartClose} isOpen={isQuickStartOpen} />
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
</ErrorBoundary>
<div className={styles.mainContainer} data-testid='event-editor'>
<Box id='settings' className={styles.settings}>
@@ -47,6 +53,10 @@ export default function Editor() {
onUploadOpen={onUploadModalOpen}
isIntegrationOpen={isIntegrationModalOpen}
onIntegrationOpen={onIntegrationModalOpen}
isAboutOpen={isAboutModalOpen}
onAboutOpen={onAboutModalOpen}
isQuickStartOpen={isQuickStartOpen}
onQuickStartOpen={onQuickStartOpen}
/>
</ErrorBoundary>
</Box>
@@ -4,7 +4,7 @@ import Editor from './Editor';
export default function ProtectedEditor() {
return (
<ProtectRoute>
<ProtectRoute permission='editor'>
<Editor />
</ProtectRoute>
);
@@ -79,7 +79,7 @@
display: block;
@include input-label;
.delayLabel {
&.delayLabel {
color: $ontime-delay-text;
}
@@ -1,35 +1,23 @@
import { useCallback, useEffect, useState } from 'react';
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useEffect, useState } from 'react';
import { OntimeEvent } from 'ontime-types';
import CopyTag from '../../common/components/copy-tag/CopyTag';
import SwatchSelect from '../../common/components/input/colour-input/SwatchSelect';
import TimeInput from '../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../common/hooks/useEventAction';
import useRundown from '../../common/hooks-query/useRundown';
import { useEventEditorStore } from '../../common/stores/eventEditor';
import { useEmitLog } from '../../common/stores/logger';
import { millisToMinutes } from '../../common/utils/dateConfig';
import { useAppMode } from '../../common/stores/appModeStore';
import getDelayTo from '../../common/utils/getDelayTo';
import { calculateDuration, TimeEntryField, validateEntry } from '../../common/utils/timesManager';
import CountedTextArea from './composite/CountedTextArea';
import CountedTextInput from './composite/CountedTextInput';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent | 'durationOverride';
export type EventEditorSubmitActions = keyof OntimeEvent;
// Todo: add previous end to TimeInput fields
export default function EventEditor() {
const { openId } = useEventEditorStore();
const openId = useAppMode((state) => state.editId);
const { data } = useRundown();
const { emitError } = useEmitLog();
const { updateEvent } = useEventAction();
const [event, setEvent] = useState<OntimeEvent | null>(null);
const [delay, setDelay] = useState(0);
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
useEffect(() => {
if (!data || !openId) {
@@ -46,83 +34,10 @@ export default function EventEditor() {
}
}, [data, event, openId]);
const handleSubmit = useCallback(
(field: EventEditorSubmitActions, value: string | number) => {
if (event === null) {
return;
}
const newEventData: Partial<OntimeEvent> = { id: event.id };
switch (field) {
case 'durationOverride': {
// duration defines timeEnd
newEventData.duration = value as number;
newEventData.timeEnd = event.timeStart + (value as number);
break;
}
case 'timeStart': {
newEventData.duration = calculateDuration(value as number, event.timeEnd);
newEventData.timeStart = value as number;
break;
}
case 'timeEnd': {
newEventData.duration = calculateDuration(event.timeStart, value as number);
newEventData.timeEnd = value as number;
break;
}
default: {
if (field in event) {
// create object with new field
newEventData[field] = value;
break;
} else {
emitError(`Unknown field: ${field}`);
return;
}
}
}
updateEvent(newEventData);
},
[emitError, event, updateEvent],
);
const timerValidationHandler = useCallback(
(entry: TimeEntryField, val: number) => {
if (!event?.timeStart) {
return true;
}
const valid = validateEntry(entry, val, event.timeStart, event.timeEnd);
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
return valid.value;
},
[event?.timeStart, event?.timeEnd],
);
const handleChange = useCallback(
(field: string, value: string) => {
updateEvent({ id: event.id, [field]: value });
},
[event, updateEvent],
);
const togglePublic = useCallback(
(currentValue: boolean) => {
if (!event) {
return;
}
updateEvent({ id: event.id, isPublic: !currentValue });
},
[event, updateEvent],
);
if (!event) {
return <span>Loading...</span>;
}
const delayed = delay !== 0;
const addedTime = delayed ? `${delay >= 0 ? '+' : '-'} ${millisToMinutes(Math.abs(delay))} minutes` : null;
const newStart = delayed ? `New start ${millisToString(event.timeStart + delay)}` : null;
const newEnd = delayed ? `New end ${millisToString(event.timeEnd + delay)}` : null;
return (
<div className={style.eventEditor}>
<div className={style.eventInfo}>
@@ -132,103 +47,24 @@ export default function EventEditor() {
<div className={style.eventActions}>
<CopyTag label='OSC trigger'>{`/ontime/gotoid/${event.id}`}</CopyTag>
</div>
<div className={style.timeOptions}>
<div className={style.timers}>
<label className={style.inputLabel}>
Start time {delayed && <span className={style.delayLabel}>{addedTime}</span>}
{delayed && <div className={style.delayLabel}>{newStart}</div>}
</label>
<TimeInput
name='timeStart'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={event.timeStart}
delay={delay}
placeholder='Start'
warning={warning.start}
/>
<label className={style.inputLabel}>
End time {delayed && <span className={style.delayLabel}>{addedTime}</span>}
{delayed && <div className={style.delayLabel}>{newEnd}</div>}
</label>
<TimeInput
name='timeEnd'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={event.timeEnd}
delay={delay}
placeholder='End'
warning={warning.end}
/>
<label className={style.inputLabel}>Duration</label>
<TimeInput
name='durationOverride'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={event.duration}
placeholder='Duration'
warning={warning.duration}
/>
</div>
<div className={style.timeSettings}>
<label className={style.inputLabel}>Timer Type</label>
<Select
size='sm'
name='timerType'
value={event.timerType}
onChange={(event) => handleChange('timerType', event.target.value)}
variant='ontime'
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.Clock}>Clock</option>
</Select>
<label className={style.inputLabel}>End Action</label>
<Select
size='sm'
name='endAction'
value={event.endAction}
onChange={(event) => handleChange('endAction', event.target.value)}
variant='ontime'
>
<option value={EndAction.Continue}>Continue</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load Next</option>
<option value={EndAction.PlayNext}>Play Next</option>
</Select>
<span className={style.spacer} />
<label className={`${style.inputLabel} ${style.publicToggle}`}>
<Switch isChecked={event.isPublic} onChange={() => togglePublic(event.isPublic)} variant='ontime' />
Event is public
</label>
</div>
</div>
<div className={style.titles}>
<div className={style.left}>
<CountedTextInput field='title' label='Title' initialValue={event.title} submitHandler={handleSubmit} />
<CountedTextInput
field='presenter'
label='Presenter'
initialValue={event.presenter}
submitHandler={handleSubmit}
/>
<CountedTextInput
field='subtitle'
label='Subtitle'
initialValue={event.subtitle}
submitHandler={handleSubmit}
/>
</div>
<div className={style.right}>
<div className={style.column}>
<label className={style.inputLabel}>Colour</label>
<div className={style.inline}>
<SwatchSelect name='colour' value={event.colour} handleChange={handleSubmit} />
</div>
</div>
<CountedTextArea field='note' label='Note' initialValue={event.note} submitHandler={handleSubmit} />
</div>
</div>
<EventEditorTimes
eventId={event.id}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
delay={delay}
isPublic={event.isPublic}
endAction={event.endAction}
timerType={event.timerType}
/>
<EventEditorTitles
eventId={event.id}
title={event.title}
presenter={event.presenter}
subtitle={event.subtitle}
note={event.note}
colour={event.colour}
/>
</div>
);
}
@@ -1,9 +1,10 @@
import { memo } from 'react';
import { Box, IconButton } from '@chakra-ui/react';
import { FiX } from '@react-icons/all-files/fi/FiX';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { useEventEditorStore } from '../../common/stores/eventEditor';
import { useAppMode } from '../../common/stores/appModeStore';
import { cx } from '../../common/utils/styleUtils';
import EventEditor from './EventEditor';
@@ -18,15 +19,19 @@ const closeBtnStyle = {
};
const EventEditorExport = () => {
const { openId, removeOpenEvent } = useEventEditorStore();
const editId = useAppMode((state) => state.editId);
const setEditId = useAppMode((state) => state.setEditId);
const editorStyle = cx([style.eventEditor, !editId ? style.noEvent : null]);
const removeOpenEvent = () => setEditId(null);
return (
<Box className={`${style.eventEditor} ${!openId ? style.noEvent : ''}`}>
<Box className={editorStyle}>
<ErrorBoundary>
<div className={style.eventEditorLayout}>
<EventEditor />
<div className={style.header}>
<IconButton aria-label='Close Menu' icon={<FiX />} onClick={removeOpenEvent} {...closeBtnStyle} />
<IconButton aria-label='Close Menu' icon={<IoClose />} onClick={removeOpenEvent} {...closeBtnStyle} />
</div>
</div>
</ErrorBoundary>
@@ -35,4 +40,3 @@ const EventEditorExport = () => {
};
export default memo(EventEditorExport);
@@ -2,15 +2,16 @@ import { useCallback } from 'react';
import { Textarea } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { EventEditorSubmitActions } from '../EventEditor';
import { TitleActions } from './EventEditorTitles';
import style from '../EventEditor.module.scss';
interface CountedTextAreaProps {
field: EventEditorSubmitActions;
field: TitleActions;
label: string;
initialValue: string;
submitHandler: (field: EventEditorSubmitActions, value: string) => void;
submitHandler: (field: TitleActions, value: string) => void;
}
export default function CountedTextArea(props: CountedTextAreaProps) {
@@ -2,15 +2,16 @@ import { useCallback } from 'react';
import { Input } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { EventEditorSubmitActions } from '../EventEditor';
import { TitleActions } from './EventEditorTitles';
import style from '../EventEditor.module.scss';
interface CountedTextInputProps {
field: EventEditorSubmitActions;
field: TitleActions;
label: string;
initialValue: string;
submitHandler: (field: EventEditorSubmitActions, value: string) => void;
submitHandler: (field: TitleActions, value: string) => void;
}
export default function CountedTextInput(props: CountedTextInputProps) {
@@ -0,0 +1,149 @@
import { memo, useState } from 'react';
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import TimeInput from '../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import { cx } from '../../../common/utils/styleUtils';
import { calculateDuration, TimeEntryField, validateEntry } from '../../../common/utils/timesManager';
import style from '../EventEditor.module.scss';
interface EventEditorTimesProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
delay: number;
isPublic: boolean;
endAction: EndAction;
timerType: TimerType;
}
type TimeActions = 'timeStart' | 'timeEnd' | 'durationOverride' | 'timerType' | 'endAction' | 'isPublic';
// Todo: add previous end to TimeInput fields
const EventEditorTimes = (props: EventEditorTimesProps) => {
const { eventId, timeStart, timeEnd, duration, delay, isPublic, endAction, timerType } = props;
const { updateEvent } = useEventAction();
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
const timerValidationHandler = (entry: TimeEntryField, val: number) => {
const valid = validateEntry(entry, val, timeStart, timeEnd);
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
return valid.value;
};
const handleSubmit = (field: TimeActions, value: number | string | boolean) => {
const newEventData: Partial<OntimeEvent> = { id: eventId };
switch (field) {
case 'durationOverride': {
// duration defines timeEnd
newEventData.duration = value as number;
newEventData.timeEnd = timeStart + (value as number);
break;
}
case 'timeStart': {
newEventData.duration = calculateDuration(value as number, timeEnd);
newEventData.timeStart = value as number;
break;
}
case 'timeEnd': {
newEventData.duration = calculateDuration(timeStart, value as number);
newEventData.timeEnd = value as number;
break;
}
case 'isPublic': {
updateEvent({ id: eventId, isPublic: !(value as boolean) });
break;
}
default: {
if (field === 'timerType' || field === 'endAction') {
// @ts-expect-error -- not sure how to typecheck here
newEventData[field as keyof OntimeEvent] = value as string;
} else {
return;
}
}
}
updateEvent(newEventData);
};
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
const startLabel = delayTime ? `New start ${millisToString(timeStart + delay)}` : 'Start time';
const endLabel = delayTime ? `New end ${millisToString(timeEnd + delay)}` : 'End time';
const inputTimeLabels = cx([style.inputLabel, delayTime ? style.delayLabel : null]);
return (
<div className={style.timeOptions}>
<div className={style.timers}>
<label className={inputTimeLabels}>{startLabel}</label>
<TimeInput
name='timeStart'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={timeStart}
delay={delay}
placeholder='Start'
warning={warning.start}
/>
<label className={inputTimeLabels}>{endLabel}</label>
<TimeInput
name='timeEnd'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={timeEnd}
delay={delay}
placeholder='End'
warning={warning.end}
/>
<label className={style.inputLabel}>Duration</label>
<TimeInput
name='durationOverride'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={duration}
placeholder='Duration'
warning={warning.duration}
/>
</div>
<div className={style.timeSettings}>
<label className={style.inputLabel}>Timer Type</label>
<Select
size='sm'
name='timerType'
value={timerType}
onChange={(event) => handleSubmit('timerType', event.target.value)}
variant='ontime'
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.Clock}>Clock</option>
</Select>
<label className={style.inputLabel}>End Action</label>
<Select
size='sm'
name='endAction'
value={endAction}
onChange={(event) => handleSubmit('endAction', event.target.value)}
variant='ontime'
>
<option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load Next</option>
<option value={EndAction.PlayNext}>Play Next</option>
</Select>
<span className={style.spacer} />
<label className={`${style.inputLabel} ${style.publicToggle}`}>
<Switch isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
Event is public
</label>
</div>
</div>
);
};
export default memo(EventEditorTimes);
@@ -0,0 +1,50 @@
import { memo } from 'react';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import { useEventAction } from '../../../common/hooks/useEventAction';
import CountedTextArea from './CountedTextArea';
import CountedTextInput from './CountedTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorTitlesProps {
eventId: string;
title: string;
presenter: string;
subtitle: string;
note: string;
colour: string;
}
export type TitleActions = 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
const EventEditorTitles = (props: EventEditorTitlesProps) => {
const { eventId, title, presenter, subtitle, note, colour } = props;
const { updateEvent } = useEventAction();
const handleSubmit = (field: TitleActions, value: string) => {
updateEvent({ id: eventId, [field]: value });
};
return (
<div className={style.titles}>
<div className={style.left}>
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
</div>
<div className={style.right}>
<div className={style.column}>
<label className={style.inputLabel}>Colour</label>
<div className={style.inline}>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
</div>
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
</div>
</div>
);
};
export default memo(EventEditorTitles);
@@ -2,8 +2,6 @@ import { PropsWithChildren, useState } from 'react';
import CollapseBar from '../../common/components/collapse-bar/CollapseBar';
import style from './Info.module.scss';
interface CollapsableInfoProps {
title: string;
}
@@ -13,9 +11,9 @@ export default function CollapsableInfo(props: PropsWithChildren<CollapsableInfo
const [collapsed, setCollapsed] = useState(false);
return (
<div className={style.container}>
<>
<CollapseBar title={title} isCollapsed={collapsed} onClick={() => setCollapsed((prev) => !prev)} />
{!collapsed && children}
</div>
</>
);
}
@@ -9,10 +9,6 @@
justify-content: space-between;
}
.container {
margin-top: $main-spacing;
}
.labels {
font-size: $inner-section-text-size;
display: flex;
@@ -4,12 +4,8 @@
$info-gray: $secondary-text-gray;
$info-hover: $section-white;
.infoLoggerContainer {
max-height: 80%;
height: 100%
}
.log {
flex: 1;
height: 100%;
overflow-y: scroll;
font-size: 0.8em;
+2 -2
View File
@@ -56,7 +56,7 @@ export default function InfoLogger() {
}, []);
return (
<div className={style.infoLoggerContainer}>
<>
<div className={style.buttonBar}>
<Button
variant={showUser ? 'ontime-filled' : 'ontime-subtle'}
@@ -125,6 +125,6 @@ export default function InfoLogger() {
</li>
))}
</ul>
</div>
</>
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default function InfoNif() {
return (
<div className={style.interfaceList}>
{data?.networkInterfaces.map((nif) => (
{data?.networkInterfaces?.map((nif) => (
<span key={nif.address} onClick={() => handleClick(nif.address)} className={style.interface}>
{`${nif.name} - ${nif.address}`}
<IoArrowUp className={style.linkIcon} />
+87 -82
View File
@@ -1,18 +1,20 @@
import { useCallback, useEffect } from 'react';
import { memo, useCallback, useEffect } from 'react';
import { VStack } from '@chakra-ui/react';
import { FiHelpCircle } from '@react-icons/all-files/fi/FiHelpCircle';
import { FiMinimize } from '@react-icons/all-files/fi/FiMinimize';
import { FiSave } from '@react-icons/all-files/fi/FiSave';
import { FiUpload } from '@react-icons/all-files/fi/FiUpload';
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
import { IoScan } from '@react-icons/all-files/io5/IoScan';
import { IoHelp } from '@react-icons/all-files/io5/IoHelp';
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline';
import { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { downloadRundown } from '../../common/api/ontimeApi';
import { downloadRundown } from '../../common/api/ontimeApi';
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import style from './MenuBar.module.scss';
@@ -24,12 +26,14 @@ interface MenuBarProps {
onUploadOpen: () => void;
isIntegrationOpen: boolean;
onIntegrationOpen: () => void;
isAboutOpen: boolean;
onAboutOpen: () => void;
isQuickStartOpen: boolean;
onQuickStartOpen: () => void;
}
type Actions = 'min' | 'max' | 'shutdown' | 'help';
const buttonStyle = {
fontSize: '1.5em',
fontSize: '1.25em',
size: 'lg',
colorScheme: 'white',
_hover: {
@@ -40,7 +44,7 @@ const buttonStyle = {
},
};
export default function MenuBar(props: MenuBarProps) {
const MenuBar = (props: MenuBarProps) => {
const {
isSettingsOpen,
onSettingsOpen,
@@ -49,37 +53,23 @@ export default function MenuBar(props: MenuBarProps) {
onUploadOpen,
isIntegrationOpen,
onIntegrationOpen,
isAboutOpen,
onAboutOpen,
isQuickStartOpen,
onQuickStartOpen,
} = props;
const { isElectron, sendToElectron } = useElectronEvent();
const actionHandler = useCallback(
(action: Actions) => {
// Stop crashes when testing locally
if (!isElectron) {
if (action === 'help') {
window.open('https://cpvalente.gitbook.io/ontime/');
}
} else {
switch (action) {
case 'min':
sendToElectron('set-window', 'to-tray');
break;
case 'max':
sendToElectron('set-window', 'to-max');
break;
case 'shutdown':
sendToElectron('shutdown', 'now');
break;
case 'help':
sendToElectron('send-to-link', 'help');
break;
default:
break;
}
}
},
[sendToElectron, isElectron],
);
const appMode = useAppMode((state) => state.mode);
const setAppMode = useAppMode((state) => state.setMode);
const setRunMode = () => setAppMode(AppMode.Run);
const setEditMode = () => setAppMode(AppMode.Edit);
const sendShutdown = () => {
if (isElectron) {
sendToElectron('shutdown', 'now');
}
};
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
@@ -112,30 +102,59 @@ export default function MenuBar(props: MenuBarProps) {
return (
<VStack>
<QuitIconBtn clickHandler={() => actionHandler('shutdown')} />
<TooltipActionBtn
{...buttonStyle}
icon={<IoScan />}
clickHandler={() => actionHandler('max')}
tooltip='Show full window'
aria-label='Show full window'
isDisabled={!isElectron}
/>
<TooltipActionBtn
{...buttonStyle}
icon={<FiMinimize />}
clickHandler={() => actionHandler('min')}
tooltip='Minimise to tray'
aria-label='Minimise to tray'
isDisabled={!isElectron}
/>
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} />
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<FiHelpCircle />}
clickHandler={() => actionHandler('help')}
tooltip='Help'
aria-label='Help'
icon={<IoColorWand />}
className={isQuickStartOpen ? style.open : ''}
clickHandler={onQuickStartOpen}
tooltip='Quick start'
aria-label='Quick start'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<IoPushOutline />}
className={isUploadOpen ? style.open : ''}
clickHandler={onUploadOpen}
tooltip='Upload showfile'
aria-label='Upload showfile'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<IoSaveOutline />}
clickHandler={downloadRundown}
tooltip='Export showfile'
aria-label='Export showfile'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<IoPlay />}
className={appMode === AppMode.Run ? style.open : ''}
clickHandler={setRunMode}
tooltip='Run mode'
aria-label='Run mode'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<IoOptions />}
className={appMode === AppMode.Edit ? style.open : ''}
clickHandler={setEditMode}
tooltip='Edit mode'
aria-label='Edit mode'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={isIntegrationOpen ? <IoExtensionPuzzle /> : <IoExtensionPuzzleOutline />}
className={isIntegrationOpen ? style.open : ''}
clickHandler={onIntegrationOpen}
tooltip='Integrations'
aria-label='Integrations'
/>
<TooltipActionBtn
{...buttonStyle}
@@ -148,28 +167,14 @@ export default function MenuBar(props: MenuBarProps) {
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={isIntegrationOpen ? <IoExtensionPuzzle /> : <IoExtensionPuzzleOutline />}
className={isIntegrationOpen ? style.open : ''}
clickHandler={onIntegrationOpen}
tooltip='Integrations'
aria-label='Integrations'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<FiUpload />}
className={isUploadOpen ? style.open : ''}
clickHandler={onUploadOpen}
tooltip='Upload showfile'
aria-label='Upload showfile'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<FiSave />}
clickHandler={downloadRundown}
tooltip='Export showfile'
aria-label='Export showfile'
className={isAboutOpen ? style.open : ''}
icon={<IoHelp />}
clickHandler={onAboutOpen}
tooltip='About'
aria-label='About'
/>
</VStack>
);
}
};
export default memo(MenuBar);
@@ -1,15 +1,4 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeColours' as *;
.headerButtons {
align-content: center;
justify-content: space-between;
text-align: right;
padding-top: 24px;
}
.labelledSwitch {
display: flex;
align-items: center;
gap: $element-spacing;
color: $gray-100;
}
+9 -15
View File
@@ -1,5 +1,5 @@
import { memo, useCallback } from 'react';
import { Button, HStack, Menu, MenuButton, MenuDivider, MenuItem, MenuList, Switch } from '@chakra-ui/react';
import { Button, Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { FiMinusCircle } from '@react-icons/all-files/fi/FiMinusCircle';
import { FiTrash2 } from '@react-icons/all-files/fi/FiTrash2';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -7,13 +7,13 @@ import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
import { SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useCursor } from '../../common/stores/cursorStore';
import { useAppMode } from '../../common/stores/appModeStore';
import style from './RundownMenu.module.scss';
const RundownMenu = () => {
const isCursorLocked = useCursor((state) => state.isCursorLocked);
const toggleCursorLocked = useCursor((state) => state.toggleCursorLocked);
const setEditId = useAppMode((state) => state.setEditId);
const setCursor = useAppMode((state) => state.setCursor);
const { addEvent, deleteAllEvents } = useEventAction();
@@ -31,18 +31,12 @@ const RundownMenu = () => {
const deleteAll = useCallback(() => {
deleteAllEvents();
}, [deleteAllEvents]);
setEditId(null);
setCursor(null);
}, [deleteAllEvents, setCursor, setEditId]);
return (
<HStack className={style.headerButtons}>
<label className={style.labelledSwitch}>
<Switch
defaultChecked={isCursorLocked}
onChange={(event) => toggleCursorLocked(event.target.checked)}
variant='ontime'
/>
Follow loaded event
</label>
<div className={style.headerButtons}>
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton as={Button} leftIcon={<IoAdd />} size='sm' variant='ontime-subtle'>
Event...
@@ -63,7 +57,7 @@ const RundownMenu = () => {
</MenuItem>
</MenuList>
</Menu>
</HStack>
</div>
);
};

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