Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 672267c0b3 | |||
| ca65d31071 | |||
| 305d6b6476 | |||
| 139b667e20 | |||
| 29a9167d61 | |||
| 38654f8981 | |||
| 33b496f98e | |||
| 10673b3ee1 | |||
| 4bceae65ab | |||
| 40d389acc9 | |||
| 0b94fc71a4 | |||
| 8f834fed35 | |||
| db9cb61164 | |||
| 9c4c84905e | |||
| da889a4825 | |||
| 4ca9fb9f72 | |||
| b228020b73 | |||
| f248b78945 | |||
| 11663e5416 | |||
| 6732314f15 | |||
| e9fe0c0a3a | |||
| 770d12888d | |||
| 94a1369d64 | |||
| 5ba27dca1d | |||
| 3df17d172f | |||
| d59c92219f | |||
| 23fce763e5 | |||
| 524aa74a3d | |||
| 32b7908d5b | |||
| e36b19a511 | |||
| ba62276951 | |||
| 4c4b83979d | |||
| 2dcea86b36 | |||
| e54996bd5e | |||
| 41753fc202 | |||
| e77572c0b6 | |||
| 4c268d531e |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[](https://github.com/cpvalente/ontime/actions/workflows/ontime_cy.yml) [](https://github.com/cpvalente/ontime/actions/workflows/build.yml)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0) [](https://cpvalente.gitbook.io/ontime/)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0) [](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.
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 *!/*/}
|
||||
|
||||
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
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 |
@@ -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';
|
||||
|
||||
@@ -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: '',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||