Compare commits

...

21 Commits

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

* chore: update demo

* chore: version bump

* fix: prevent stale keys

* refactor: add new fields to validation

* style: tweaks to modal arrangement

* refactor: prevent parsing http

* ux: click anywhere in event to edit

* refactor: performance improvements to time input

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

* feat: add extra support for languages

german
portuguese
spanish

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

* fix: sentry has no access to error context

* style: clarify event history

* fix: issue with clipboard write in safari

* style: clarify event history

* refactor: improvements to follow logic in rundown

* refactor: improvements in go mode

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

* style: modal wrapper

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

* add color support to minimal timer

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

---------

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

* refactor: end message is part of view settings

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

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

* refactor: remove unused data

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

* chore: update demo db

* style: tweaks on extracted rundown

* chore: prevent console logs in production code

* feat: go mode

* style: remove window size limits

* style: rename delete action

* style: small tweaks on icons

* fix: style override on params

* chore: update test db

* refactor: small code quality improvements

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

* feat: about ontime

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

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

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

* style: rename rundown functions

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

* chore: upgrade deps

* refactor: remove unused functions

* chore: create testing boilerplate

* test: upload showfile
2023-04-18 23:16:19 +02:00
cv f248b78945 refactor: changes in time input 2023-04-18 23:15:15 +02:00
Carlos Valente 11663e5416 refactor: serve react as compressed br (#339) 2023-04-15 13:27:29 +02:00
Carlos Valente 6732314f15 V2 fix delay (#340)
* fix: avoid modifying original objects
2023-04-15 13:26:57 +02:00
cv e9fe0c0a3a fix: resolve to static port in dev mode 2023-04-15 12:46:17 +02:00
190 changed files with 4429 additions and 3729 deletions
+10 -3
View File
@@ -11,9 +11,16 @@
],
"overrides": [
{
"files": ["e2e/**/**.spec.ts", "e2e/**/**.test.ts"],
"extends": ["plugin:playwright/playwright-test"]
"files": [
"e2e/**/**.spec.ts",
"e2e/**/**.test.ts"
],
"extends": [
"plugin:playwright/playwright-test"
]
}
],
"rules": {}
"rules": {
"no-console": "warn"
}
}
+22
View File
@@ -20,6 +20,28 @@ From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev`
## TESTING
Generally we have 2 types of tests.
- Unit tests for functions that contain business logic
- End-to-end tests for core features
### Unit tests
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running turbo `turbo test:pipeline` from the project root.
This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
### E2E tests
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the webserver to test against
These tests also run against a separate version of the DB (test-db)
You can run playwright tests from project root with `pnpm e2e`
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually start the webserver with `pnpm dev:server`
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
Ontime uses Electron to distribute the application.
+6 -6
View File
@@ -1,5 +1,5 @@
[![ontime_test_CI](https://github.com/cpvalente/ontime/actions/workflows/ontime_cy.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/ontime_cy.yml) [![Ontime build](https://github.com/cpvalente/ontime/actions/workflows/build.yml/badge.svg)](https://github.com/cpvalente/ontime/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Documentation in Gitbook](https://badges.aleen42.com/src/gitbook_2.svg)](https://cpvalente.gitbook.io/ontime/)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Documentation in Gitbook](https://badges.aleen42.com/src/gitbook_2.svg)](https://ontime.gitbook.io)
## Download the latest releases here
@@ -15,7 +15,7 @@
Ontime is an application for managing event rundowns and running stage timers.
A single, locally hosted central application distributes your event information over the local network.
This enables the distribution of the data to a series of viewers and allows integration into video and control workflows, including OBS and d3.
This enables the distribution of the data to a series of views and allows integration into video and control workflows, including OBS and d3.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.jpg)
@@ -55,7 +55,7 @@ IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
```
More documentation is available [in our docs](https://cpvalente.gitbook.io/ontime/)
More documentation is available [in our docs](https://ontime.gitbook.io)
## Feature List (in no specific order)
@@ -78,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)
@@ -116,7 +116,7 @@ Writing a new view for the browser can be done with basic knowledge of HTML + CS
<br />
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
how to get you started and read the docs about
the [Websocket API](https://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
@@ -197,7 +197,7 @@ Information about the project setup can be found in the [development documentati
# Help
Help is underway! ... and can be found [here](https://cpvalente.gitbook.io/ontime/)
Help is underway! ... and can be found [here](https://ontime.gitbook.io)
# License
+1
View File
@@ -27,6 +27,7 @@
"prettier"
],
"rules": {
"@typescript-eslint/no-non-null-assertion": "warn",
"prettier/prettier": [
"error",
{
+6 -4
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.0.0-beta2",
"version": "2.0.0-beta",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.5.5",
@@ -21,6 +21,7 @@
"deepmerge": "^4.3.0",
"framer-motion": "^10.10.0",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0",
"react-hook-form": "^7.43.5",
@@ -89,9 +90,10 @@
"stylelint-config-prettier": "^9.0.4",
"stylelint-config-standard-scss": "^6.1.0",
"typescript": "^4.9.4",
"vite": "^4.0.4",
"vite": "^4.3.1",
"vite-plugin-compression2": "^0.9.0",
"vite-plugin-svgr": "^2.4.0",
"vite-tsconfig-paths": "^4.0.3",
"vitest": "^0.29.8"
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.30.1"
}
}
+10 -22
View File
@@ -1,9 +1,8 @@
import { lazy, useEffect } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import useAliases from './common/hooks-query/useAliases';
import withData from './features/viewers/ViewWrapper';
import { useTranslation } from './translation/TranslationProvider';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Table = lazy(() => import('./features/table/ProtectedTable'));
@@ -27,7 +26,7 @@ const SPublic = withData(Public);
const SLowerThird = withData(Lower);
const SStudio = withData(StudioClock);
const FeatureWrapper = lazy(() => import('./features/FeatureWrapper'));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
@@ -37,17 +36,6 @@ export default function AppRouter() {
const { data } = useAliases();
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { setLanguage } = useTranslation();
// Set output language
useEffect(() => {
const langParam = searchParams.get('lang');
if (langParam && langParam.length === 2) {
setLanguage(searchParams.get('lang'));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
// navigate if is alias route
useEffect(() => {
@@ -95,33 +83,33 @@ export default function AppRouter() {
<Route
path='/rundown'
element={
<FeatureWrapper>
<EditorFeatureWrapper>
<RundownPanel />
</FeatureWrapper>
</EditorFeatureWrapper>
}
/>
<Route
path='/timercontrol'
element={
<FeatureWrapper>
<EditorFeatureWrapper>
<TimerControl />
</FeatureWrapper>
</EditorFeatureWrapper>
}
/>
<Route
path='/messagecontrol'
element={
<FeatureWrapper>
<EditorFeatureWrapper>
<MessageControl />
</FeatureWrapper>
</EditorFeatureWrapper>
}
/>
<Route
path='/info'
element={
<FeatureWrapper>
<EditorFeatureWrapper>
<Info />
</FeatureWrapper>
</EditorFeatureWrapper>
}
/>
{/*/!* Send to default if nothing found *!/*/}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

+4 -12
View File
@@ -1,7 +1,7 @@
export const STATIC_PORT = 4001;
// REST stuff
export const EVENTDATA_TABLE = ['eventdata'];
export const EVENT_DATA = ['eventdata'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN_TABLE_KEY = 'rundown';
@@ -12,17 +12,9 @@ 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:${window.location.port}` : window.location.origin);
export const serverURL = calculateServer();
export const websocketUrl = `ws://${window.location.hostname}:${window.location.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}/events`;
+19 -6
View File
@@ -1,9 +1,10 @@
import axios from 'axios';
import { Alias, OSCSettings, Settings, UserFields, ViewSettings } from 'ontime-types';
import { Alias, EventData, OSCSettings, Settings, UserFields, ViewSettings } from 'ontime-types';
import { apiRepoLatest } from '../../externals';
import { InfoType } from '../models/Info';
import { githubURL, ontimeURL } from './apiConstants';
import { ontimeURL } from './apiConstants';
/**
* @description HTTP request to retrieve application settings
@@ -44,7 +45,7 @@ export async function getView(): Promise<ViewSettings> {
* @description HTTP request to mutate view settings
* @return {Promise}
*/
export async function postView(data: ViewSettings) {
export async function postViewSettings(data: ViewSettings) {
return axios.post(`${ontimeURL}/views`, data);
}
@@ -152,11 +153,23 @@ export const uploadData = async (file: string, setProgress: (value: number) => v
.then((response) => response.data.id);
};
export type HasUpdate = {
url: string;
version: string;
};
/**
* @description HTTP request to get the latest version and url from github
* @return {Promise}
*/
export async function getLatestVersion(): Promise<object> {
const res = await axios.get(`${githubURL}`);
return { url: res.data.html_url, version: res.data.tag_name };
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(`${apiRepoLatest}`);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
};
}
export async function postNew(initialData: Partial<EventData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
@@ -18,6 +18,7 @@ import { useEmitLog } from '../../stores/logger';
interface QuitIconBtnProps {
clickHandler: () => void;
size?: Size;
disabled?: boolean;
}
const quitBtnStyle = {
@@ -27,6 +28,10 @@ const quitBtnStyle = {
_hover: {
background: '#D20300', // $red-700
color: 'white',
_disabled: {
color: '#D20300', // $red-700
background: 'none',
},
},
_active: {
background: '#9A0000', // $red-1000
@@ -37,7 +42,7 @@ const quitBtnStyle = {
};
export default function QuitIconBtn(props: QuitIconBtnProps) {
const { clickHandler, size = 'lg', ...rest } = props;
const { clickHandler, size = 'lg', disabled, ...rest } = props;
const [isOpen, setIsOpen] = useState(false);
const { emitInfo } = useEmitLog();
const onClose = () => setIsOpen(false);
@@ -65,6 +70,7 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
size={size}
icon={<FiPower />}
onClick={() => setIsOpen(true)}
isDisabled={disabled}
{...quitBtnStyle}
{...rest}
/>
@@ -75,9 +81,7 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
Ontime Shutdown
</AlertDialogHeader>
<AlertDialogBody>
This will shutdown the program and all running servers. Are you sure?
</AlertDialogBody>
<AlertDialogBody>This will shutdown the program and all running servers. Are you sure?</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
Cancel
@@ -1,7 +1,8 @@
import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: () => void;
clickHandler: (event?: MouseEvent) => void;
tooltip: string;
openDelay?: number;
}
@@ -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}
/>
);
@@ -22,7 +22,7 @@ export default function DelayInput(props: DelayInputProps) {
let ignoreChange = false;
useEffect(() => {
if (typeof duration === undefined) {
if (typeof duration === 'undefined') {
return;
}
setValue(millisToString(duration));
@@ -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,24 +21,36 @@ 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<string>('');
// avoid wrong submit on cancel
let ignoreChange = false;
const ignoreChange = useRef(false);
/**
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
try {
// eslint-disable-next-line -- we use ignore change to stop submit on cancel
ignoreChange = true;
setValue(millisToString(time));
} catch (error) {
emitError(`Unable to parse date: ${error}`);
setValue(millisToString(0));
emitError(`Unable to parse time ${time}: ${error}`);
}
}, [emitError, time]);
@@ -97,11 +109,6 @@ export default function TimeInput(props: TimeInputProps) {
*/
const validateAndSubmit = useCallback(
(newValue: string) => {
if (ignoreChange) {
// eslint-disable-next-line -- we use this to prevent a wrong submit
ignoreChange = false;
return;
}
const success = handleSubmit(newValue);
if (success) {
const ms = forgivingStringToMillis(newValue);
@@ -111,7 +118,7 @@ export default function TimeInput(props: TimeInputProps) {
resetValue();
}
},
[delay, handleSubmit, resetValue],
[delay, handleSubmit, name, resetValue],
);
/**
@@ -127,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();
}
@@ -136,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],
@@ -144,30 +156,24 @@ export default function TimeInput(props: TimeInputProps) {
useEffect(() => {
if (time == null) return;
resetValue();
}, [emitError, resetValue, time]);
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'
@@ -177,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} />;
}
@@ -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');
},
@@ -6,5 +6,4 @@ export const eventDataPlaceholder: EventData = {
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
endMessage: '',
};
@@ -4,7 +4,8 @@ export const ontimePlaceholderSettings: Settings = {
app: 'ontime',
version: 2,
serverPort: 4001,
lock: null,
pinCode: null,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
};
@@ -13,4 +13,5 @@ export type OverridableOptions = {
hideMessagesOverlay?: boolean;
hideEndMessage?: boolean;
language?: string;
showProgressBar?: boolean;
};
@@ -2,4 +2,10 @@ import { ViewSettings } from 'ontime-types';
export const viewsSettingsPlaceholder: ViewSettings = {
overrideStyles: false,
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '',
};
+7 -1
View File
@@ -1,3 +1,9 @@
import { QueryClient } from '@tanstack/react-query';
export const ontimeQueryClient = new QueryClient();
export const ontimeQueryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: 1000 * 60 * 10, // 10 min
},
},
});
@@ -0,0 +1,70 @@
import { create } from 'zustand';
export enum AppMode {
Run = 'run',
Edit = 'edit',
}
const appModeKey = 'ontime-app-mode';
function getModeFromSession() {
return localStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
}
async function persistModeToSession(mode: AppMode) {
localStorage.setItem(appModeKey, mode);
}
type AppModeStore = {
mode: AppMode;
cursor: string | null;
editId: string | null;
setMode: (mode: AppMode) => void;
setCursor: (id: string | null, isEditable?: boolean) => void;
setEditId: (id: string | null) => void;
};
export const useAppMode = create<AppModeStore>()((set) => ({
mode: getModeFromSession(),
cursor: null,
editId: null,
setMode: (mode: AppMode) =>
set((state) => {
persistModeToSession(mode);
return mode === AppMode.Edit
? {
editId: state.cursor,
mode: mode,
}
: {
editId: null,
mode: mode,
};
}),
setCursor: (id: string | null, isEditable?: boolean) =>
set((state) => {
if (isEditable) {
return state.mode === AppMode.Edit
? {
cursor: id,
editId: id,
}
: {
cursor: id,
};
} else {
return { cursor: id, editId: null };
}
}),
setEditId: (id: string | null) =>
set((state) => {
return state.mode === AppMode.Edit
? {
cursor: id,
editId: id,
}
: {
editId: id,
};
}),
}));
@@ -1,24 +0,0 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type CursorStore = {
cursor: number;
isCursorLocked: boolean;
toggleCursorLocked: (newValue?: boolean) => void;
moveCursorTo: (index: number) => void;
};
const cursorLockedKey = 'ontime-cursor-islocked';
export const useCursor = create<CursorStore>()((set) => ({
cursor: 0,
isCursorLocked: booleanFromLocalStorage(cursorLockedKey, false),
toggleCursorLocked: (newValue?: boolean) =>
set((state) => {
const val = typeof newValue === 'undefined' ? !state.isCursorLocked : newValue;
localStorage.setItem(cursorLockedKey, String(val));
return { isCursorLocked: val };
}),
moveCursorTo: (index: number) => set(() => ({ cursor: index })),
}));
@@ -1,13 +0,0 @@
import { create } from 'zustand';
type EventEditorStore = {
openId: string | null;
setOpenEvent: (eventId: string) => void;
removeOpenEvent: () => void;
};
export const useEventEditorStore = create<EventEditorStore>()((set) => ({
openId: null,
setOpenEvent: (eventId: string | null) => set({ openId: eventId }),
removeOpenEvent: () => set({ openId: null }),
}));
@@ -203,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 },
@@ -225,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);
});
}
});
@@ -244,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 },
@@ -272,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 },
@@ -377,11 +461,11 @@ describe('millisToDelayString()', () => {
expect(millisToDelayString(0)).toBeNull();
});
describe('converts values in seconds', () => {
it(`shows a simple string with value in seconds`, () => {
expect(millisToDelayString(10000)).toBe('+10sec');
it('shows a simple string with value in seconds', () => {
expect(millisToDelayString(10000)).toBe('+10 sec');
});
it(`... and its negative counterpart`, () => {
expect(millisToDelayString(-10000)).toBe('-10sec');
it('... and its negative counterpart', () => {
expect(millisToDelayString(-10000)).toBe('-10 sec');
});
const underAMinute = [1, 500, 1000, 6000, 55000, 59999];
@@ -394,16 +478,16 @@ describe('millisToDelayString()', () => {
});
describe('converts values in minutes', () => {
it(`shows a simple string with value in minutes`, () => {
expect(millisToDelayString(720000)).toBe('+12min');
it('shows a simple string with value in minutes', () => {
expect(millisToDelayString(720000)).toBe('+12 min');
});
it(`... and its negative counterpart`, () => {
expect(millisToDelayString(-720000)).toBe('-12min');
it('... and its negative counterpart', () => {
expect(millisToDelayString(-720000)).toBe('-12 min');
});
it(`shows a simple string with value in minutes and seconds`, () => {
it('shows a simple string with value in minutes and seconds', () => {
expect(millisToDelayString(630000)).toBe('+00:10:30');
});
it(`... and its negative counterpart`, () => {
it('... and its negative counterpart', () => {
expect(millisToDelayString(-630000)).toBe('-00:10:30');
});
@@ -416,10 +500,10 @@ describe('millisToDelayString()', () => {
});
describe('converts values with full time string', () => {
it(`positive added time`, () => {
it('positive added time', () => {
expect(millisToDelayString(45015000)).toBe('+12:30:15');
});
it(`negative added time`, () => {
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);
});
+138 -53
View File
@@ -42,7 +42,7 @@ 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 => {
@@ -83,59 +83,144 @@ 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;
};
@@ -148,9 +233,9 @@ export function millisToDelayString(millis: number | null): undefined | string |
const absMillis = Math.abs(millis);
if (absMillis < mtm) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 's')}sec`;
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 's')} sec`;
} else if (absMillis < mth && absMillis % mtm === 0) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'm')}min`;
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'm')} min`;
} else {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
}
+75 -25
View File
@@ -4,74 +4,85 @@ import { formatTime } from './time';
/**
* @description From a list of events, returns only events of type event with calculated delays
* @param {Object[]} events - given events
* @param {Object[]} rundown - given rundown
* @returns {Object[]} Filtered events with calculated delays
*/
export const getEventsWithDelay = (events: OntimeRundownEntry[]): OntimeEvent[] => {
if (events == null) return [];
export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[] => {
if (rundown == null) return [];
const unfilteredEvents = [...events];
const delayedEvents: OntimeEvent[] = [];
// Add running delay
let delay = 0;
for (const event of unfilteredEvents) {
for (const event of rundown) {
if (event.type === SupportedEvent.Block) delay = 0;
else if (event.type === SupportedEvent.Delay) delay = delay + event.duration;
else if (event.type === SupportedEvent.Event && delay > 0) {
event.timeStart += delay;
event.timeEnd += delay;
else if (event.type === SupportedEvent.Delay) {
if (typeof event.duration === 'number') {
delay += event.duration;
}
} else if (event.type === SupportedEvent.Event) {
const delayedEvent = { ...event };
if (delay !== 0) {
delayedEvent.timeStart = Math.max(delayedEvent.timeStart + delay, 0);
delayedEvent.timeEnd = Math.max(delayedEvent.timeEnd + delay, 0);
}
delayedEvents.push(delayedEvent);
}
}
// filter just events
return unfilteredEvents.filter((event) => event.type === SupportedEvent.Event) as OntimeEvent[];
return delayedEvents;
};
/**
* @description Returns trimmed event list array
* @param {Object[]} events - given events
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects
*/
export const trimEventlist = (events: OntimeRundownEntry[], selectedId: string, limit: number) => {
if (events == null) return [];
export const trimRundown = (rundown: OntimeRundownEntry[], selectedId: string, limit: number) => {
if (rundown == null) return [];
const BEFORE = 2;
const trimmedEvents = [...events];
const trimmedRundown = [...rundown];
// limit events length if necessary
if (limit != null) {
while (trimmedEvents.length > limit) {
const idx = trimmedEvents.findIndex((e) => e.id === selectedId);
while (trimmedRundown.length > limit) {
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) {
trimmedEvents.pop();
trimmedRundown.pop();
} else {
trimmedEvents.shift();
trimmedRundown.shift();
}
}
}
return trimmedEvents;
return trimmedRundown;
};
type FormatEventListOptionsProp = {
showEnd?: boolean;
}
};
/**
* @description Returns list of events formatted to be displayed
* @param {Object[]} events - given events
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event
* @param {object} [options]
* @param {boolean} [options.showEnd] - whether to show the end time
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/
export const formatEventList = (events: OntimeEvent[], selectedId: string, nextId: string, options: FormatEventListOptionsProp) => {
if (events == null) return [];
export const formatEventList = (
rundown: OntimeEvent[],
selectedId: string,
nextId: string,
options: FormatEventListOptionsProp,
) => {
if (rundown == null) return [];
const { showEnd = false } = options;
const givenEvents = [...events];
const givenEvents = [...rundown];
// format list
const formattedEvents = [];
@@ -113,3 +124,42 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
after: after,
};
};
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeEvent | null}
*/
export function getFirstEvent(rundown: OntimeRundownEntry[]) {
return rundown.length ? rundown[0] : null;
}
/**
* Gets next event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index + 1 < rundown.length) {
return rundown[index + 1];
} else {
return null;
}
}
/**
* Gets previous event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index - 1 >= 0) {
return rundown[index - 1];
} else {
return null;
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ 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 '...';
}
@@ -0,0 +1,6 @@
export function isStringBoolean(text: string | null) {
if (text === null) {
return false;
}
return text?.toLowerCase() === 'true' || text === '1';
}
+4
View File
@@ -0,0 +1,4 @@
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const gitbookUrl = 'https://ontime.gitbook.io';
@@ -1,16 +1,12 @@
import { ReactNode } from 'react';
import { PropsWithChildren } from 'react';
import ProtectRoute from '../common/components/protect-route/ProtectRoute';
import style from './FeatureWrapper.module.scss';
interface FeatureWrapperProps {
children: ReactNode;
}
export default function FeatureWrapper({ children }: FeatureWrapperProps) {
export default function EditorFeatureWrapper({ children }: PropsWithChildren) {
return (
<ProtectRoute>
<ProtectRoute permission='editor'>
<div className={style.wrapper}>{children}</div>
</ProtectRoute>
);
@@ -1,19 +0,0 @@
import { Playback } from 'ontime-types';
import PlaybackDisplay from './PlaybackDisplay';
import Transport from './Transport';
interface PlaybackButtonsProps {
playback: Playback;
noEvents: boolean;
}
export default function PlaybackButtons(props: PlaybackButtonsProps) {
const { playback, noEvents } = props;
return (
<>
<PlaybackDisplay playback={playback} noEvents={noEvents} />
<Transport playback={playback} noEvents={noEvents} />
</>
);
};
@@ -1,130 +1,4 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
@use '../../../theme/mixins' as *;
.mainContainer {
width: 100%;
display: grid;
margin: 0 auto;
gap: $element-inner-spacing;
}
.timeContainer {
display: grid;
grid-template-areas:
'ind clk clk btn'
'... sta fin btn';
grid-template-rows: 1fr auto;
grid-template-columns: 1.5em 1fr 1fr 5em;
gap: $element-inner-spacing;
justify-items: start;
}
.timer {
grid-area: clk;
white-space: nowrap;
max-width: 300px;
}
.indicators {
grid-area: ind;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
.indRoll,
.indDelay,
.indNegative {
background-color: $gray-1350;
}
.indRoll,
.indRollActive,
.indDelay,
.indDelayActive {
margin: 0 auto;
border-radius: 6px;
width: 12px;
height: 12px;
}
.indRollActive {
background-color: $ontime-roll;
}
.indNegative,
.indNegativeActive {
margin: 0 auto;
width: 90%;
height: 4px;
}
.indNegativeActive {
background-color: $playback-negative;
}
.indDelayActive {
background-color: $ontime-delay;
}
.btn {
grid-area: btn;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 100%;
gap: $element-inner-spacing;
}
.minus {
grid-area: min;
display: flex;
flex-direction: column;
}
.start,
.finish,
.roll {
height: 24px;
}
.start {
grid-area: sta;
}
.finish {
grid-area: fin;
}
.roll {
grid-area: 2 / 2 / 2 / 4 ;
}
.time {
color: $section-white;
font-size: $text-body-size;
}
.tag {
color: $label-gray;
font-size: 13px;
}
.rolltag {
color: $ontime-roll;
font-size: $text-body-size;
}
.playbackContainer {
display: flex;
justify-content: space-evenly;
padding-top: 0.5em;
gap: $element-spacing;
}
.invertX {
transform: rotateY(180deg);
}
@@ -2,8 +2,8 @@ import { Playback } from 'ontime-types';
import { usePlaybackControl } from '../../../common/hooks/useSocket';
import PlaybackButtons from './PlaybackButtons';
import PlaybackTimer from './PlaybackTimer';
import PlaybackButtons from './playback-buttons/PlaybackButtons';
import PlaybackTimer from './playback-timer/PlaybackTimer';
import style from './PlaybackControl.module.scss';
@@ -13,7 +13,11 @@ export default function PlaybackControl() {
return (
<div className={style.mainContainer}>
<PlaybackTimer playback={data.playback as Playback} />
<PlaybackButtons playback={data.playback} noEvents={data.numEvents < 1} />
<PlaybackButtons
playback={data.playback}
numEvents={data.numEvents}
selectedEventIndex={data.selectedEventIndex}
/>
</div>
);
}
@@ -1,55 +0,0 @@
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
import { Playback } from 'ontime-types';
import { setPlayback } from '../../../common/hooks/useSocket';
import TapButton from './TapButton';
import style from './PlaybackControl.module.scss';
interface PlaybackProps {
playback: Playback;
noEvents: boolean;
}
export default function PlaybackDisplay(props: PlaybackProps) {
const { playback, noEvents } = props;
const isRolling = playback === Playback.Roll;
const isPlaying = playback === Playback.Play;
const isPaused = playback === Playback.Pause;
const isArmed = playback === Playback.Armed;
const isStopped = playback === Playback.Stop;
return (
<div className={style.playbackContainer}>
<TapButton
onClick={() => setPlayback.start()}
disabled={isStopped || isRolling}
theme={Playback.Play}
active={isPlaying}
>
<IoPlay />
</TapButton>
<TapButton
onClick={() => setPlayback.pause()}
disabled={isStopped || isRolling || isArmed}
theme={Playback.Pause}
active={isPaused}
>
<IoPause />
</TapButton>
<TapButton
onClick={() => setPlayback.roll()}
disabled={!isStopped || noEvents}
theme={Playback.Roll}
active={isRolling}
>
<IoTimeOutline />
</TapButton>
</div>
);
}
@@ -1,30 +0,0 @@
import { ForwardedRef, forwardRef, PropsWithChildren } from 'react';
import { Playback } from 'ontime-types';
import style from './TapButton.module.scss';
interface TapButtonProps {
disabled?: boolean;
square?: boolean;
onClick: () => void;
theme?: Playback | 'neutral';
active?: boolean;
}
const TapButton = forwardRef((props: PropsWithChildren<TapButtonProps>, ref: ForwardedRef<HTMLButtonElement> ) => {
const { children, disabled, onClick, theme = 'neutral', square, active } = props;
return (
<button
className={`${style.tapButton} ${style[theme]} ${square ? style.square : ''} ${active ? style.active : ''}`}
disabled={disabled}
type='button'
onClick={onClick}
ref={ref}
>
{children}
</button>
);
});
TapButton.displayName = "TabButton";
export default TapButton;
@@ -1,49 +0,0 @@
import { Tooltip } from '@chakra-ui/react';
import { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback } from 'ontime-types';
import { setPlayback } from '../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../ontimeConfig';
import TapButton from './TapButton';
import style from './PlaybackControl.module.scss';
interface TransportProps {
playback: Playback;
noEvents: boolean;
}
export default function Transport(props: TransportProps) {
const { playback, noEvents } = props;
const isRolling = playback === Playback.Roll;
const isStopped = playback === Playback.Stop;
return (
<div className={style.playbackContainer}>
<Tooltip label='Previous event' openDelay={tooltipDelayMid}>
<TapButton onClick={() => setPlayback.previous()} disabled={isRolling || noEvents}>
<IoPlaySkipBack />
</TapButton>
</Tooltip>
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
<TapButton onClick={() => setPlayback.next()} disabled={isRolling || noEvents}>
<IoPlaySkipForward />
</TapButton>
</Tooltip>
<Tooltip label='Reload event' openDelay={tooltipDelayMid}>
<TapButton onClick={() => setPlayback.reload()} disabled={isStopped || isRolling}>
<IoReload className={style.invertX} />
</TapButton>
</Tooltip>
<Tooltip label='Unload Event' openDelay={tooltipDelayMid}>
<TapButton onClick={() => setPlayback.stop()} disabled={isStopped && !isRolling} theme={Playback.Stop}>
<IoStop />
</TapButton>
</Tooltip>
</div>
);
}
@@ -0,0 +1,43 @@
@use '../../../../theme/v2Styles' as *;
.buttonContainer {
padding-top: $element-spacing;
display: grid;
grid-template-areas:
"go playback"
"go transport"
"extra extra";
grid-template-rows: repeat(3, 32px);
gap: $element-spacing;
}
.go {
grid-area: go;
font-size: 5em;
}
@mixin spaced-flex {
display: flex;
justify-content: space-evenly;
gap: $element-spacing;
}
.playbackContainer {
grid-area: playback;
@include spaced-flex;
}
.transportContainer {
grid-area: transport;
@include spaced-flex;
}
.extra {
grid-area: extra;
@include spaced-flex;
}
.invertX {
transform: rotateY(180deg);
}
@@ -0,0 +1,107 @@
import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
import { Playback } from 'ontime-types';
import { setPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton';
import styles from './PlaybackButtons.module.scss';
import style from './PlaybackButtons.module.scss';
interface PlaybackButtonsProps {
playback: Playback;
numEvents: number;
selectedEventIndex: number | null;
}
export default function PlaybackButtons(props: PlaybackButtonsProps) {
const { playback, numEvents, selectedEventIndex } = props;
const isRolling = playback === Playback.Roll;
const isPlaying = playback === Playback.Play;
const isPaused = playback === Playback.Pause;
const isArmed = playback === Playback.Armed;
const isStopped = playback === Playback.Stop;
const isFirst = selectedEventIndex === 0;
const isLast = selectedEventIndex === numEvents - 1;
const noEvents = numEvents === 0;
const disableGo = isRolling || noEvents || (isLast && !isArmed);
const disablePrev = isRolling || noEvents || isFirst;
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next';
const goModeAction = () => {
if (isArmed) {
setPlayback.start();
} else {
setPlayback.startNext();
}
};
return (
<div className={styles.buttonContainer}>
<TapButton disabled={disableGo} onClick={goModeAction} aspect='fill' className={styles.go}>
{goModeText}
</TapButton>
<div className={style.playbackContainer}>
<TapButton
onClick={setPlayback.start}
disabled={isStopped || isRolling}
theme={Playback.Play}
active={isPlaying}
>
<IoPlay />
</TapButton>
<TapButton
onClick={setPlayback.pause}
disabled={isStopped || isRolling || isArmed}
theme={Playback.Pause}
active={isPaused}
>
<IoPause />
</TapButton>
</div>
<div className={style.transportContainer}>
<Tooltip label='Previous event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.previous} disabled={disablePrev}>
<IoPlaySkipBack />
</TapButton>
</Tooltip>
<Tooltip label='Next event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.next} disabled={disableGo}>
<IoPlaySkipForward />
</TapButton>
</Tooltip>
</div>
<div className={styles.extra}>
<TapButton
onClick={setPlayback.roll}
disabled={!isStopped || noEvents}
theme={Playback.Roll}
active={isRolling}
>
<IoTimeOutline />
</TapButton>
<Tooltip label='Reload event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.reload} disabled={isStopped || isRolling}>
<IoReload className={style.invertX} />
</TapButton>
</Tooltip>
<Tooltip label='Unload Event' openDelay={tooltipDelayMid}>
<TapButton onClick={setPlayback.stop} disabled={isStopped && !isRolling} theme={Playback.Stop}>
<IoStop />
</TapButton>
</Tooltip>
</div>
</div>
);
}
@@ -0,0 +1,111 @@
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
.timeContainer {
display: grid;
grid-template-areas:
'ind clk clk btn'
'... sta fin btn';
grid-template-rows: 1fr auto;
grid-template-columns: 1.5em 1fr 1fr 5em;
gap: $element-inner-spacing;
justify-items: start;
}
.timer {
grid-area: clk;
white-space: nowrap;
max-width: 300px;
}
.indicators {
grid-area: ind;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
.indRoll,
.indDelay,
.indNegative {
background-color: $black-10;
}
.indRoll,
.indRollActive,
.indDelay,
.indDelayActive {
margin: 0 auto;
border-radius: 6px;
width: 12px;
height: 12px;
}
.indRollActive {
background-color: $ontime-roll;
}
.indNegative,
.indNegativeActive {
margin: 0 auto;
width: 90%;
height: 4px;
}
.indNegativeActive {
background-color: $playback-negative;
}
.indDelayActive {
background-color: $ontime-delay;
}
.btn {
grid-area: btn;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 100%;
gap: $element-inner-spacing;
}
.minus {
grid-area: min;
display: flex;
flex-direction: column;
}
.start,
.finish,
.roll {
height: 24px;
}
.start {
grid-area: sta;
}
.finish {
grid-area: fin;
}
.roll {
grid-area: 2 / 2 / 2 / 4 ;
}
.time {
color: $section-white;
font-size: $text-body-size;
}
.tag {
color: $label-gray;
font-size: 13px;
}
.rolltag {
color: $ontime-roll;
font-size: $text-body-size;
}
@@ -2,14 +2,13 @@ import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import TimerDisplay from '../../../common/components/timer-display/TimerDisplay';
import { setPlayback, useTimer } from '../../../common/hooks/useSocket';
import { millisToMinutes, millisToSeconds } from '../../../common/utils/dateConfig';
import { tooltipDelayMid } from '../../../ontimeConfig';
import TimerDisplay from '../../../../common/components/timer-display/TimerDisplay';
import { setPlayback, useTimer } from '../../../../common/hooks/useSocket';
import { millisToMinutes, millisToSeconds } from '../../../../common/utils/dateConfig';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton';
import TapButton from './TapButton';
import style from './PlaybackControl.module.scss';
import style from './PlaybackTimer.module.scss';
interface PlaybackTimerProps {
playback: Playback;
@@ -36,7 +35,7 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
if (ms < 6000) {
return `${millisToSeconds(ms)} seconds`;
} else if (ms < 12000) {
return `1 minute`;
return '1 minute';
} else {
return `${millisToMinutes(ms)} minutes`;
}
@@ -87,22 +86,22 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
)}
<div className={style.btn}>
<Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-1)} disabled={disableButtons} square>
<TapButton onClick={() => setPlayback.delay(-1)} disabled={disableButtons} aspect='square'>
-1
</TapButton>
</Tooltip>
<Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(1)} disabled={disableButtons} square>
<TapButton onClick={() => setPlayback.delay(1)} disabled={disableButtons} aspect='square'>
+1
</TapButton>
</Tooltip>
<Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-5)} disabled={disableButtons} square>
<TapButton onClick={() => setPlayback.delay(-5)} disabled={disableButtons} aspect='square'>
-5
</TapButton>
</Tooltip>
<Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(+5)} disabled={disableButtons} square>
<TapButton onClick={() => setPlayback.delay(+5)} disabled={disableButtons} aspect='square'>
+5
</TapButton>
</Tooltip>
@@ -1,15 +1,14 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
$button-bg-gray: $gray-1050;
$button-color-white: $gray-50;
@mixin tap-factory($theme-color) {
font-family: $ontime-font-family;
font-size: 22px;
font-size: 18px;
border-radius: $component-border-radius-md;
width: 100%;
aspect-ratio: 3/1;
transition-property: color, background-color;
transition-duration: $transition-time-feedback;
display: grid;
@@ -78,7 +77,17 @@ $button-color-white: $gray-50;
@include tap-factory($ontime-stop);
}
.tapButton.normal {
aspect-ratio: 3/1;
}
.tapButton.square {
aspect-ratio: 1;
font-size: 14px;
}
.tapButton.fill {
aspect-ratio: unset;
height: 100%;
font-size: 1.5em;
}
@@ -0,0 +1,31 @@
import { ForwardedRef, forwardRef, PropsWithChildren } from 'react';
import { Playback } from 'ontime-types';
import { cx } from '../../../../common/utils/styleUtils';
import style from './TapButton.module.scss';
interface TapButtonProps {
disabled?: boolean;
aspect?: 'normal' | 'square' | 'fill';
square?: boolean;
free?: boolean;
onClick: () => void;
theme?: Playback | 'neutral';
active?: boolean;
className?: string;
}
const TapButton = forwardRef((props: PropsWithChildren<TapButtonProps>, ref: ForwardedRef<HTMLButtonElement>) => {
const { children, disabled, onClick, theme = 'neutral', aspect = 'normal', active, className } = props;
const classes = cx([style.tapButton, className, style[theme], style[aspect], active ? style.active : null]);
return (
<button className={classes} disabled={disabled} type='button' onClick={onClick} ref={ref}>
{children}
</button>
);
});
TapButton.displayName = 'TabButton';
export default TapButton;
@@ -37,11 +37,11 @@ $playback-width: 450px;
grid-template-rows: auto 1fr;
grid-template-columns: $menu-width $rundown-width $playback-width auto;
grid-template-areas:
'sett even play info'
'sett even mess info';
'sett rundown play info'
'sett rundown mess info';
gap: 8px;
.editor,
.rundown,
.playback,
.messages,
.info,
@@ -98,7 +98,7 @@ $playback-width: 450px;
visibility: visible;
}
.editor,
.rundown,
.info,
.settings {
visibility: hidden;
@@ -117,7 +117,7 @@ $playback-width: 450px;
visibility: visible;
}
.editor,
.rundown,
.messages,
.info,
.settings {
@@ -128,7 +128,7 @@ $playback-width: 450px;
.mainContainer {
.settings,
.editor,
.rundown,
.messages,
.playback,
.info {
@@ -171,8 +171,9 @@ $playback-width: 450px;
}
}
.editor {
grid-area: even;
.rundown {
grid-area: rundown;
height: 100%;
.content {
height: calc(100% - 24px);
@@ -183,6 +184,7 @@ $playback-width: 450px;
.info {
grid-area: info;
min-width: 17em;
max-width: 800px;
.content {
display: flex;
+14 -4
View File
@@ -4,8 +4,10 @@ import { Box, useDisclosure } from '@chakra-ui/react';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import UploadModal from '../../common/components/upload-modal/UploadModal';
import MenuBar from '../menu/MenuBar';
import AboutModal from '../modals/about-modal/AboutModal';
import IntegrationModal from '../modals/integration-modal/IntegrationModal';
import ModalManager from '../modals/ModalManager';
import QuickStart from '../modals/quick-start/QuickStart';
import SettingsModal from '../modals/settings-modal/SettingsModal';
import styles from './Editor.module.scss';
@@ -23,6 +25,8 @@ export default function Editor() {
onOpen: onIntegrationModalOpen,
onClose: onIntegrationModalClose,
} = useDisclosure();
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
// Set window title
useEffect(() => {
@@ -31,10 +35,12 @@ export default function Editor() {
return (
<>
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
<ErrorBoundary>
<ModalManager isOpen={isSettingsOpen} onClose={onSettingsClose} />
<QuickStart onClose={onQuickStartClose} isOpen={isQuickStartOpen} />
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
</ErrorBoundary>
<div className={styles.mainContainer} data-testid='event-editor'>
<Box id='settings' className={styles.settings}>
@@ -47,6 +53,10 @@ export default function Editor() {
onUploadOpen={onUploadModalOpen}
isIntegrationOpen={isIntegrationModalOpen}
onIntegrationOpen={onIntegrationModalOpen}
isAboutOpen={isAboutModalOpen}
onAboutOpen={onAboutModalOpen}
isQuickStartOpen={isQuickStartOpen}
onQuickStartOpen={onQuickStartOpen}
/>
</ErrorBoundary>
</Box>
@@ -4,7 +4,7 @@ import Editor from './Editor';
export default function ProtectedEditor() {
return (
<ProtectRoute>
<ProtectRoute permission='editor'>
<Editor />
</ProtectRoute>
);
@@ -3,7 +3,7 @@ import { OntimeEvent } from 'ontime-types';
import CopyTag from '../../common/components/copy-tag/CopyTag';
import useRundown from '../../common/hooks-query/useRundown';
import { useEventEditorStore } from '../../common/stores/eventEditor';
import { useAppMode } from '../../common/stores/appModeStore';
import getDelayTo from '../../common/utils/getDelayTo';
import EventEditorTimes from './composite/EventEditorTimes';
@@ -14,7 +14,7 @@ import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent;
export default function EventEditor() {
const { openId } = useEventEditorStore();
const openId = useAppMode((state) => state.editId);
const { data } = useRundown();
const [event, setEvent] = useState<OntimeEvent | null>(null);
const [delay, setDelay] = useState(0);
@@ -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>
@@ -131,7 +131,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
onChange={(event) => handleSubmit('endAction', event.target.value)}
variant='ontime'
>
<option value={EndAction.Continue}>Continue</option>
<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>
+67 -62
View File
@@ -1,18 +1,20 @@
import { useCallback, useEffect } from 'react';
import { memo, useCallback, useEffect } from 'react';
import { VStack } from '@chakra-ui/react';
import { FiHelpCircle } from '@react-icons/all-files/fi/FiHelpCircle';
import { FiMinimize } from '@react-icons/all-files/fi/FiMinimize';
import { FiSave } from '@react-icons/all-files/fi/FiSave';
import { FiUpload } from '@react-icons/all-files/fi/FiUpload';
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
import { IoScan } from '@react-icons/all-files/io5/IoScan';
import { IoHelp } from '@react-icons/all-files/io5/IoHelp';
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline';
import { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { downloadRundown } from '../../common/api/ontimeApi';
import 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,27 +102,20 @@ 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={<FiUpload />}
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'
@@ -140,11 +123,30 @@ export default function MenuBar(props: MenuBarProps) {
/>
<TooltipActionBtn
{...buttonStyle}
icon={<FiSave />}
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}
@@ -165,11 +167,14 @@ export default function MenuBar(props: MenuBarProps) {
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
icon={<FiHelpCircle />}
clickHandler={() => actionHandler('help')}
tooltip='Help'
aria-label='Help'
className={isAboutOpen ? style.open : ''}
icon={<IoHelp />}
clickHandler={onAboutOpen}
tooltip='About'
aria-label='About'
/>
</VStack>
);
}
};
export default memo(MenuBar);
@@ -1,15 +1,4 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeColours' as *;
.headerButtons {
align-content: center;
justify-content: space-between;
text-align: right;
padding-top: 24px;
}
.labelledSwitch {
display: flex;
align-items: center;
gap: $element-spacing;
color: $gray-100;
}
+9 -18
View File
@@ -1,5 +1,5 @@
import { memo, useCallback } from 'react';
import { Button, HStack, Menu, MenuButton, MenuDivider, MenuItem, MenuList, Switch } from '@chakra-ui/react';
import { Button, Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { FiMinusCircle } from '@react-icons/all-files/fi/FiMinusCircle';
import { FiTrash2 } from '@react-icons/all-files/fi/FiTrash2';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -7,15 +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 { useEventEditorStore } from '../../common/stores/eventEditor';
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 removeOpenEvent = useEventEditorStore((state) => state.removeOpenEvent);
const setEditId = useAppMode((state) => state.setEditId);
const setCursor = useAppMode((state) => state.setCursor);
const { addEvent, deleteAllEvents } = useEventAction();
@@ -33,19 +31,12 @@ const RundownMenu = () => {
const deleteAll = useCallback(() => {
deleteAllEvents();
removeOpenEvent();
}, [deleteAllEvents, removeOpenEvent]);
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...
@@ -66,7 +57,7 @@ const RundownMenu = () => {
</MenuItem>
</MenuList>
</Menu>
</HStack>
</div>
);
};
@@ -1,298 +0,0 @@
/* eslint-disable jsx-a11y/anchor-has-content */
import { useCallback, useEffect, useState } from 'react';
import { Button, IconButton, Input, ModalBody, Tooltip } from '@chakra-ui/react';
import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
import { useEmitLog } from '@/common/stores/logger';
import { viewerLocations } from '../../appConstants';
import { postAliases } from '../../common/api/ontimeApi';
import useAliases from '../../common/hooks-query/useAliases';
import { validateAlias } from '../../common/utils/aliases';
import { handleLinks, host } from '../../common/utils/linkUtils';
import { tooltipDelayFast } from '../../ontimeConfig';
import SubmitContainer from './SubmitContainer';
import style from './Modals.module.scss';
export default function AliasesModal() {
const { data, status, refetch } = useAliases();
const { emitError } = useEmitLog();
const [changed, setChanged] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [aliases, setAliases] = useState([]);
/**
* Set formdata from server state
*/
useEffect(() => {
if (data == null) return;
if (changed) return;
setAliases([...data]);
}, [changed, data]);
/**
* Validate and submit data
*/
const submitHandler = useCallback(
async (event) => {
event.preventDefault();
setSubmitting(true);
const validatedAliases = [...aliases];
let errors = false;
for (const alias of validatedAliases) {
// validate url
const isURLValid = validateAlias(alias.pathAndParams);
if (!isURLValid.status) {
alias.urlError = isURLValid.message;
errors = true;
} else {
alias.urlError = undefined;
}
// validate alias
const isAliasValid = validateAlias(alias.alias);
if (!isAliasValid.status) {
alias.aliasError = isAliasValid.message;
errors = true;
} else {
alias.aliasError = undefined;
}
}
setAliases(validatedAliases);
if (!errors) {
try {
await postAliases(aliases);
} catch (error) {
emitError(`Error saving settings: ${error}`);
} finally {
await refetch();
setChanged(false);
}
}
setSubmitting(false);
},
[aliases, emitError, refetch],
);
/**
* Creates a new alias in state with a temporary id
*/
const addNew = useCallback(() => {
if (aliases.length > 20) {
emitError('Maximum amount of aliases reacted (20)');
return;
}
const emptyAlias = {
id: Math.floor(Math.random() * 1000),
enabled: false,
alias: '',
pathAndParams: '',
};
setAliases((prevState) => [...prevState, emptyAlias]);
setChanged(true);
}, [aliases.length, emitError]);
/**
* Deletes an alias by a given id
* @param {string} id - id of alias to delete
*/
const deleteAlias = useCallback((id) => {
setAliases((prevState) => [...prevState.filter((a) => a.id !== id)]);
setChanged(true);
}, []);
/**
* Sets enabled flag to true / false
* @param {string} id - object id
* @param {boolean} isEnabled - whether to enable / disable flag
*/
const setEnabled = useCallback(
(id, isEnabled) => {
const aliasesState = [...aliases];
for (const a of aliasesState) {
if (a.id === id) {
if (isEnabled) {
if (a.alias === '' || a.pathAndParams === '') {
emitError('Alias incomplete');
break;
}
const isRepeated = aliases.some((r) => a.alias === r.alias && r.enabled);
if (isRepeated) {
emitError('There is already an alias with this name');
break;
}
}
a.enabled = isEnabled;
break;
}
}
setChanged(true);
setAliases(aliasesState);
},
[aliases, emitError],
);
/**
* Reverts local state equals to server state
*/
const revert = async () => {
setChanged(false);
await refetch();
};
/**
* Handles change of input field in local state
* @param {number} index - index of item in array
* @param {string} field - object parameter to update
* @param {string} value - new object parameter value
*/
const handleChange = useCallback(
(index, field, value) => {
const temp = [...aliases];
temp[index][field] = value;
setAliases(temp);
setChanged(true);
},
[aliases],
);
return (
<ModalBody className={style.modalBody}>
<p className={style.notes}>
Configure easy to use URL Aliases
<br />
🔥 Changes take effect on save 🔥
</p>
<form onSubmit={submitHandler}>
<div className={style.modalFields}>
<div className={style.hSeparator}>Default URLs</div>
<div className={style.blockNotes}>
{viewerLocations.map((l) => (
<a
href={l.link}
target='_blank'
rel='noreferrer'
className={style.flexNote}
key={l.link}
onClick={(e) => handleLinks(e, l.link)}
>
{`${l.label} - http://${host}/${l.link}`}
</a>
))}
</div>
<div className={style.hSeparator}>Custom Aliases</div>
<div className={style.blockNotes}>
<span className={style.inlineFlex}>
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
URL aliases are useful in two main scenarios
</span>
<span className={style.labelNote}>Complicated URLs</span>
<br />
eg. a lower third url with some custom parameters
<table>
<tbody>
<tr>
<td className={style.labelNote} style={{ width: '30%' }}>
Alias
</td>
<td className={style.labelNote}>Page URL</td>
</tr>
<tr>
<td>mylower</td>
<td>lower?bg=ff2&text=f00&size=0.6&transition=5</td>
</tr>
</tbody>
</table>
<br />
<span className={style.labelNote}>URLs to be changed dynamically</span>
<br />
eg. an unattended screen that you would need to change route from the app
<table>
<tbody>
<tr>
<td className={style.labelNote} style={{ width: '30%' }}>
Alias
</td>
<td className={style.labelNote}>Page URL</td>
</tr>
<tr>
<td>thirdfloor</td>
<td>public</td>
</tr>
</tbody>
</table>
</div>
<div className={style.inlineAliasPlaceholder}>
<span className={style.labelNote}>Alias</span>
<span className={style.labelNote}>Page URL</span>
</div>
{aliases.map((alias, index) => (
<div key={alias.id}>
<div className={style.inlineAlias}>
<Input
size='sm'
variant='flushed'
name='Alias'
placeholder='URL Alias'
autoComplete='off'
value={alias.alias}
isInvalid={alias.aliasError}
onChange={(event) => handleChange(index, 'alias', event.target.value)}
/>
<Input
size='sm'
fontSize='0.75em'
variant='flushed'
name='URL'
placeholder='URL (portion after ontime Port)'
autoComplete='off'
value={alias.pathAndParams}
isInvalid={alias.urlError}
onChange={(event) => handleChange(index, 'pathAndParams', event.target.value)}
/>
<Tooltip label={`Test /${alias.pathAndParams}`} openDelay={tooltipDelayFast}>
<a href='#!' target='_blank' rel='noreferrer' onClick={(e) => handleLinks(e, alias.pathAndParams)} />
</Tooltip>
<Tooltip label='Enable alias' openDelay={tooltipDelayFast}>
<IconButton
aria-label='Enable alias'
size='xs'
icon={<IoSunny />}
colorScheme='blue'
variant={alias.enabled ? null : 'outline'}
onClick={() => setEnabled(alias.id, !alias.enabled)}
/>
</Tooltip>
<Tooltip label='Delete alias' openDelay={tooltipDelayFast}>
<IconButton
aria-label='Delete alias'
size='xs'
icon={<IoRemove />}
colorScheme='red'
onClick={() => deleteAlias(alias.id)}
/>
</Tooltip>
</div>
{alias.aliasError ? <div className={style.error}>{`Alias error: ${alias.aliasError}`}</div> : null}
{alias.urlError ? <div className={style.error}>{`URL error: ${alias.urlError}`}</div> : null}
</div>
))}
<div className={style.inlineAliasPlaceholder}>
<Button size='xs' colorScheme='blue' variant='outline' onClick={() => addNew()}>
Add new
</Button>
</div>
</div>
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
</form>
</ModalBody>
);
}
@@ -1,297 +0,0 @@
import { useEffect, useState } from 'react';
import isEqual from 'react-fast-compare';
import {
Button,
Checkbox,
FormControl,
FormLabel,
IconButton,
Input,
ModalBody,
PinInput,
PinInputField,
Select,
} from '@chakra-ui/react';
import { FiEye } from '@react-icons/all-files/fi/FiEye';
import { FiX } from '@react-icons/all-files/fi/FiX';
import { useEmitLog } from '@/common/stores/logger';
import { version } from '../../../package.json';
import { getLatestVersion, postSettings } from '../../common/api/ontimeApi';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import useSettings from '../../common/hooks-query/useSettings';
import { ontimePlaceholderSettings } from '../../common/models/OntimeSettings';
import { useLocalEvent } from '../../common/stores/localEvent';
import { inputProps } from './modalHelper';
import SubmitContainer from './SubmitContainer';
import style from './Modals.module.scss';
export default function AppSettingsModal() {
const { data, status, refetch } = useSettings();
const { emitError, emitWarning } = useEmitLog();
const [formData, setFormData] = useState(ontimePlaceholderSettings);
const [changed, setChanged] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [hidePin, setHidePin] = useState(true);
const eventSettings = useLocalEvent((state) => state.eventSettings);
const setLocalEventSettings = useLocalEvent((state) => state.setLocalEventSettings);
const [formSettings, setFormSettings] = useState(eventSettings);
const [updateMessage, setUpdateMessage] = useState(<a>Using ontime version: {version}</a>);
const [isFetching, setIsFetching] = useState(false);
/**
* Set formdata from server state
*/
useEffect(() => {
if (data == null) return;
if (changed) return;
setFormData({
pinCode: data.pinCode,
timeFormat: data.timeFormat,
});
// getLatestVersion().then((data) => {
// setVersionData(data);
// });
}, [changed, data]);
/**
* Validate and submit data
*/
const submitHandler = async (event) => {
event.preventDefault();
setSubmitting(true);
const validation = { isValid: false, message: '' };
const hasChanged = !isEqual(formSettings, eventSettings);
if (hasChanged) {
setLocalEventSettings(formSettings);
validation.isValid = true;
}
// we might not have changed this
if (formData.pinCode !== data.pinCode) {
// Validate fields
if (formData.pinCode === '' || formData.pinCode == null) {
validation.isValid = true;
validation.message += 'App pin code removed';
} else {
validation.isValid = true;
validation.message += 'App pin code added';
}
}
if (formData.timeFormat !== data.timeFormat) {
if (formData.timeFormat === '12' || formData.timeFormat === '24') {
validation.isValid = true;
} else {
validation.isValue = false;
}
}
let resetChange = hasChanged;
// set fields with error
if (!validation.isValid) {
emitError(`Invalid Input: ${validation.message}`);
} else {
try {
await postSettings(formData);
} catch (error) {
emitError(`Error saving settings: ${error}`);
} finally {
await refetch();
resetChange = true;
}
validation?.message && emitWarning(validation.message);
}
if (resetChange) {
setChanged(false);
}
setSubmitting(false);
};
/**
* Reverts local state equals to server state
*/
const revert = async () => {
setChanged(false);
// set from context
setFormSettings(eventSettings);
await refetch();
};
/**
* Handles change of input field in local state
* @param {string} field - object parameter to update
* @param {string} value - new object parameter value
*/
const handleChange = (field, value) => {
const temp = { ...formData };
temp[field] = value;
setFormData(temp);
setChanged(true);
};
/**
* Handles version comparison and returns component with message
*/
const versionCheck = async () => {
let message = <a>Using latest version</a>;
setIsFetching(true);
getLatestVersion()
.then((data) => {
const remoteVersion = data;
if (!remoteVersion.version.includes(version)) {
message = (
<a href={remoteVersion.url} target='_blank' rel='noreferrer'>
Update to version {remoteVersion.version} available
</a>
);
}
})
.catch(function () {
message = <a>Error reaching server</a>;
})
.finally(function () {
setUpdateMessage(message);
setIsFetching(false);
});
};
const disableModal = status !== 'success';
return (
<ModalBody className={style.modalBody}>
<p className={style.notes}>
Options related to the application
<br />
🔥 Changes take effect on save 🔥
</p>
<form onSubmit={submitHandler}>
<div className={style.modalFields}>
<div className={style.hSeparator}>General App Settings</div>
<div className={style.modalInline}>
<FormControl id='serverPort'>
<FormLabel htmlFor='serverPort'>
Viewer Port
<span className={style.labelNote}>
<br />
Ontime is available at port
</span>
</FormLabel>
<Input {...inputProps} name='title' value={4001} disabled style={{ width: '6em', textAlign: 'center' }} />
</FormControl>
<FormControl id='editorPin'>
<FormLabel htmlFor='editorPin'>
Editor Pincode
<span className={style.labelNote}>
<br />
Protect the editor with a Pincode
</span>
</FormLabel>
<div className={style.pin}>
<PinInput
{...inputProps}
type='alphanumeric'
name='pinCode'
defaultValue=''
value={formData.pinCode}
mask={hidePin}
isDisabled={disableModal}
onChange={(value) => handleChange('pinCode', value)}
>
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
</PinInput>
<IconButton
size='sm'
colorScheme='blue'
variant='ghost'
icon={<FiEye />}
aria-label='Editor pin code'
onMouseDown={() => setHidePin(false)}
onMouseUp={() => setHidePin(true)}
isDisabled={disableModal}
/>
<TooltipActionBtn
tooltip='Clear pincode'
size='sm'
colorScheme='red'
variant='ghost'
icon={<FiX />}
clickHandler={() => handleChange('pinCode', '')}
isDisabled={disableModal}
/>
</div>
</FormControl>
</div>
<div className={style.modalColumn}>
<FormControl id='timeFormat'>
<FormLabel htmlFor='timeFormat'>
Time format
<span className={style.labelNote}>
<br />
12 / 24 hour format (viewers only for now)
</span>
</FormLabel>
<Select
size='sm'
name='timeFormat'
value={formData.timeFormat}
isDisabled={disableModal}
onChange={(event) => handleChange('timeFormat', event.target.value)}
>
<option value='12'>12 hours eg. 11:00:10 PM</option>
<option value='24'>24 hours eg. 23:00:10</option>
</Select>
</FormControl>
</div>
<div className={style.hSeparator}>Create Event Default Settings</div>
<div className={style.modalColumn}>
<Checkbox
isChecked={formSettings.showQuickEntry}
onChange={(e) => {
setFormSettings((prev) => ({ ...prev, showQuickEntry: e.target.checked }));
setChanged(true);
}}
>
Show quick entry on cursor
</Checkbox>
<Checkbox
isChecked={formSettings.startTimeIsLastEnd}
onChange={(e) => {
setFormSettings((prev) => ({ ...prev, startTimeIsLastEnd: e.target.checked }));
setChanged(true);
}}
>
Start time is last end
</Checkbox>
<Checkbox
isChecked={formSettings.defaultPublic}
onChange={(e) => {
setFormSettings((prev) => ({ ...prev, defaultPublic: e.target.checked }));
setChanged(true);
}}
>
Event default public
</Checkbox>
</div>
<div className={style.notes}>
{updateMessage}
<Button onClick={versionCheck} isLoading={isFetching}>
Check for updates
</Button>
</div>
</div>
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
</form>
</ModalBody>
);
}
@@ -1,193 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { FormLabel, Input, ModalBody, Textarea } from '@chakra-ui/react';
import { useEmitLog } from '@/common/stores/logger';
import { postEventData } from '../../common/api/eventDataApi';
import useEventData from '../../common/hooks-query/useEventData';
import { eventDataPlaceholder } from '../../common/models/EventData';
import { inputProps } from './modalHelper';
import SubmitContainer from './SubmitContainer';
import style from './Modals.module.scss';
export default function SettingsModal() {
const { data, status, refetch } = useEventData();
const { emitError } = useEmitLog();
const [formData, setFormData] = useState(eventDataPlaceholder);
const [changed, setChanged] = useState(false);
const [submitting, setSubmitting] = useState(false);
/**
* Set formdata from server state
*/
useEffect(() => {
if (data == null) return;
if (changed) return;
setFormData({
title: data.title,
publicUrl: data.publicUrl,
publicInfo: data.publicInfo,
backstageUrl: data.backstageUrl,
backstageInfo: data.backstageInfo,
endMessage: data.endMessage,
});
}, [changed, data]);
/**
* Validate and submit data
*/
const submitHandler = useCallback(
async (event) => {
event.preventDefault();
setSubmitting(true);
try {
await postEventData(formData);
} catch (error) {
emitError(`Error saving event settings: ${error}`);
} finally {
await refetch();
setChanged(false);
}
setSubmitting(false);
},
[emitError, formData, refetch],
);
/**
* Reverts local state equals to server state
*/
const revert = useCallback(async () => {
setChanged(false);
await refetch();
}, [refetch]);
/**
* Handles change of input field in local state
* @param {string} field - object parameter to update
* @param {string} value - new object parameter value
*/
const handleChange = useCallback(
(field, value) => {
const temp = { ...formData };
temp[field] = value;
setFormData(temp);
setChanged(true);
},
[formData],
);
return (
<ModalBody className={style.modalBody}>
<p className={style.notes}>
Options related to the running event
<br />
Affects rendered views
</p>
<form onSubmit={submitHandler}>
<div className={style.modalFields}>
<div className={style.hSeparator}>Event Data</div>
<div className={style.spacedEntry}>
<FormLabel htmlFor='title'>Event Title</FormLabel>
<Input
{...inputProps}
maxLength={35}
name='title'
placeholder='Event Title'
value={formData.title}
onChange={(event) => handleChange('title', event.target.value)}
/>
</div>
<div className={style.hSeparator}>Additional Screen Info</div>
<div className={style.spacedEntry}>
<FormLabel htmlFor='publicUrl'>
Public URL
<span className={style.labelNote}>
<br />
QR code to be shown on public screens
</span>
</FormLabel>
<Input
{...inputProps}
name='publicUrl'
placeholder='www.getontime.no'
value={formData.publicUrl}
onChange={(event) => handleChange('publicUrl', event.target.value)}
/>
</div>
<div className={style.spacedEntry}>
<FormLabel htmlFor='pubInfo'>
Public Info
<span className={style.labelNote}>
<br />
Information to be shown on public screens
</span>
</FormLabel>
<Textarea
{...inputProps}
name='pubInfo'
placeholder='Information to be shown on public screens'
value={formData.publicInfo}
onChange={(event) => handleChange('publicInfo', event.target.value)}
/>
</div>
<div className={style.spacedEntry}>
<FormLabel htmlFor='backstageUrl'>
Backstage URL
<span className={style.labelNote}>
<br />
QR to be shown on backstage screens
</span>
</FormLabel>
<Input
{...inputProps}
name='backstageUrl'
placeholder='www.getontime.no'
value={formData.backstageUrl}
onChange={(event) => handleChange('backstageUrl', event.target.value)}
/>
</div>
<div className={style.spacedEntry}>
<FormLabel htmlFor='backstageInfo'>
Backstage Info
<span className={style.labelNote}>
<br />
Information to be shown on backstage screens
</span>
</FormLabel>
<Textarea
{...inputProps}
name='backstageInfo'
placeholder='Information to be shown on backstage screens'
resize={false}
value={formData.backstageInfo}
onChange={(event) => handleChange('backstageInfo', event.target.value)}
/>
</div>
<div className={style.spacedEntry}>
<FormLabel htmlFor='endMessage'>
End Message
<span className={style.labelNote}>
<br />
Shown on presenter view when time is finished
</span>
</FormLabel>
<Input
{...inputProps}
maxLength={30}
name='endMessage'
placeholder='Empty message shows elapsed time'
value={formData.endMessage}
onChange={(event) => handleChange('endMessage', event.target.value)}
/>
</div>
</div>
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
</form>
</ModalBody>
);
}
@@ -1,32 +1,53 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeColours' as *;
$el-padding-with-compensation: 24px; // 16 + 8
@mixin modal-link {
color: $blue-500;
transition-property: color;
transition-duration: $transition-time-action;
&:hover {
color: $ontime-color;
}
}
.headerNotes {
font-size: $text-body-size;
width: 100%;
padding: 0 $section-spacing;
padding: 0 $el-padding-with-compensation;
color: $modal-note-color;
margin-bottom: $section-spacing;
a {
display: block;
color: $blue-500;
transition-property: color;
transition-duration: $transition-time-action;
&:hover {
color: $ontime-color;
}
@include modal-link
}
}
.footerNotes {
font-size: $inner-section-text-size;
padding: 0 $el-padding-with-compensation;
color: $modal-note-color;
}
.divider {
margin: $element-spacing 0;
border: 0;
border-top: 1px solid $gray-100;
}
.title {
font-size: $inner-section-text-size;
color: $gray-500;
padding-left: 8px;
margin: 8px 0;
text-transform: uppercase;
}
.sectionContainer {
padding: 8px 16px;
display: flex;
flex-direction: column;
height: 100%
@@ -39,12 +60,20 @@
align-items: center;
&:hover {
background-color: $blue-50;
background-color: $gray-50;
}
}
.columnSection {
@include sectionSpacing;
flex-direction: column;
align-items: flex-start;
gap: $element-inner-spacing;
}
.splitSection {
@include sectionSpacing;
gap: $section-spacing;
justify-content: space-between;
}
@@ -56,6 +85,7 @@
.sectionTitle {
font-size: $inner-section-text-size;
display: block;
width: 100%;
&.main {
font-weight: 600;
@@ -79,9 +109,6 @@
.buttonSection {
margin-top: $section-spacing;
padding-top: $section-spacing;
padding-left: -24px;
border-top: 1px solid $gray-100;
display: flex;
gap: $section-spacing;
}
@@ -92,9 +119,47 @@
.shiftRight {
align-self: flex-end;
margin-right: 8px;
}
.showPointer {
cursor: pointer;
}
.column {
display: flex;
flex-direction: column;
}
.twoColumn {
display: grid;
grid-template-columns: auto 1fr;
}
.padBottom {
padding-bottom: $element-spacing;
}
.logo {
width: 48px;
height: 48px;
display: inline-block;
vertical-align: text-bottom;
margin-right: $section-spacing;
}
.updateSection {
padding-top: $el-padding-with-compensation;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: $element-inner-spacing;
.error {
font-size: $error-red;
}
}
.overflowContainer {
overflow-y: auto;
max-height: 40%;
}
@@ -0,0 +1,29 @@
import { PropsWithChildren } from 'react';
import { FormControl } from '@chakra-ui/react';
import style from './settings-modal/SettingsModal.module.scss';
interface ModalInputProps {
field: string;
title: string;
description: string;
error?: string;
}
export default function ModalInput(props: PropsWithChildren<ModalInputProps>) {
const { field, title, description, error, children } = props;
return (
<FormControl isInvalid={!!error} className={style.columnSection}>
<label htmlFor={field}>
<span className={style.sectionTitle}>{title}</span>
{error ? (
<span className={style.error}>{error}</span>
) : (
<span className={style.sectionSubtitle}>{description}</span>
)}
</label>
{children}
</FormControl>
);
}
@@ -0,0 +1,20 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeColours' as *;
.link {
display: flex;
align-items: center;
gap: 4px;
color: $blue-500;
transition-property: color;
transition-duration: $transition-time-action;
&.inline {
display: inline-flex;
}
&:hover {
color: $ontime-color;
}
}
@@ -0,0 +1,29 @@
import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
import style from './ModalLink.module.scss';
interface ModalLinkProps {
href: string;
children: ReactNode;
inline?: boolean;
}
export default function ModalLink(props: ModalLinkProps) {
const { href, inline, children } = props;
const classes = cx([style.link, inline ? style.inline : null]);
const handleClick = (event: MouseEvent) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
{children} <IoOpenOutline />
</a>
);
}
@@ -1,70 +0,0 @@
import {
Modal,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
} from '@chakra-ui/react';
import AliasesModal from './AliasesModal';
import AppSettingsModal from './AppSettingsModal';
import EventSettingsModal from './EventSettingsModal';
import TableOptionsModal from './TableOptionsModal';
import ViewsSettingsModal from './ViewsSettingsModal';
interface ModalManagerProps {
isOpen: boolean;
onClose: () => void;
}
export default function ModalManager(props: ModalManagerProps) {
const { isOpen, onClose } = props;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
size='xl'
scrollBehavior='inside'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Ontime Settings</ModalHeader>
<ModalCloseButton />
<Tabs size='sm' isLazy>
<TabList>
<Tab>App Settings</Tab>
<Tab>Viewers</Tab>
<Tab>Event Data</Tab>
<Tab>URL Aliases</Tab>
<Tab>Cuesheet</Tab>
</TabList>
<TabPanels>
<TabPanel>
<AppSettingsModal />
</TabPanel>
<TabPanel>
<ViewsSettingsModal />
</TabPanel>
<TabPanel>
<EventSettingsModal />
</TabPanel>
<TabPanel>
<AliasesModal />
</TabPanel>
<TabPanel>
<TableOptionsModal />
</TabPanel>
</TabPanels>
</Tabs>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,29 @@
import { PropsWithChildren } from 'react';
import { FormControl } from '@chakra-ui/react';
import style from './settings-modal/SettingsModal.module.scss';
interface ModalSplitInputProps {
field: string;
title: string;
description: string;
error?: string;
}
export default function ModalSplitInput(props: PropsWithChildren<ModalSplitInputProps>) {
const { field, title, description, error, children } = props;
return (
<FormControl isInvalid={!!error} className={style.splitSection}>
<label htmlFor={field}>
<span className={style.sectionTitle}>{title}</span>
{error ? (
<span className={style.error}>{error}</span>
) : (
<span className={style.sectionSubtitle}>{description}</span>
)}
</label>
{children}
</FormControl>
);
}
@@ -5,10 +5,11 @@ interface ModalWrapperProps {
isOpen: boolean;
onClose: () => void;
title: string;
size?: string;
}
export default function ModalWrapper(props: PropsWithChildren<ModalWrapperProps>) {
const { isOpen, onClose, title, children } = props;
const { isOpen, onClose, title, size = 'xl', children } = props;
return (
<Modal
@@ -16,7 +17,7 @@ export default function ModalWrapper(props: PropsWithChildren<ModalWrapperProps>
isOpen={isOpen}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
size='xl'
size={size}
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime'
@@ -1,197 +0,0 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/v2Styles' as *;
//////////////////////////////////// main
.modalBody {
font-weight: 400;
.notes {
font-weight: 400;
color: $action-blue;
display: grid;
place-items: center;
height: 4em;
}
.modalFields {
max-height: 45vh;
overflow-y: auto;
scrollbar-color: rgba($action-blue, 0.35) rgba($action-blue, 0.15);
padding-right: 6px;
label {
//font-weight: 400;
font-size: 0.8em;
}
.inlineAlias,
.inlineAliasPlaceholder {
display: grid;
grid-template-columns: 20% 1fr 1em 1.5em 1.5em;
gap: 8px;
align-items: center;
padding: 0.5em 0;
}
.error {
font-size: 0.8em;
color: $error-red;
}
.inlineAliasPlaceholder {
grid-template-columns: 20% 1fr 4em;
.placeholder {
background: black;
width: 100%;
height: 24px;
}
}
}
/* Track */
::-webkit-scrollbar-track {
background: rgba($gray-50, 0.15);
border-radius: 3px;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: rgba($gray-100, 0.35);
border-radius: 3px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: rgba($gray-200, 0.45);
}
.modalInline {
display: flex;
gap: 2em;
align-items: center;
padding: 0 0.5em 0.5em 0.5em;
}
.modalColumn {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0 0.5em 0.5em 0.5em;
}
.spacedEntry {
padding: 0 0.5em 0.5em 0.5em;
}
.pin {
display: flex;
gap: 0.5em;
border-radius: 50%;
input {
border-radius: 50%;
}
}
.submitContainer {
margin-top: auto;
padding-top: 2em;
display: flex;
justify-content: flex-end;
gap: 16px;
}
}
.modalBody > * {
margin-top: 0.5em;
}
//////////////////////////////////// notes
ul.featureList {
li {
display: flex;
align-items: center;
svg {
color: black;
margin-right: 4px;
}
}
}
p {
&.notes {
text-align: center;
border-color: black;
border-width: 0 2px;
font-size: 0.9em;
margin-bottom: 1em;
}
}
span {
&.notes {
font-size: 0.9em;
padding-left: 0.4em;
}
}
.blockNotes {
background-color: #fff;
margin: 1em 0;
padding: 0.5em;
font-size: 0.8em;
border-radius: 2px;
table {
background-color: #fff;
border-left: 4px solid lighten($ontime-color, 5%);
width: 100%;
margin: 0.5em 0;
border-radius: 2px;
:first-child {
padding-left: 1em;
}
td {
user-select: text;
}
}
.noteItem {
user-select: text;
font-weight: 600;
padding-right: 2em;
}
.flexNote {
user-select: text;
padding-bottom: 0.3em;
display: block;
}
.emNote {
user-select: text;
display: block;
background-color: #fffc;
}
}
.labelNote {
color: $action-blue;
padding-right: 1em;
}
.labelNoteInline {
color: $action-blue;
}
.inlineFlex {
display: flex;
gap: 1em;
align-items: center;
margin-bottom: 1em;
}
@@ -1,6 +1,6 @@
import { Button, ModalFooter } from '@chakra-ui/react';
import styles from '../Modal.module.scss';
import styles from './Modal.module.scss';
interface OntimeModalFooterProps {
formId: string;
@@ -17,7 +17,7 @@ export default function OntimeModalFooter(props: OntimeModalFooterProps) {
const disableSubmit = isSubmitting || !isDirty || !isValid;
return (
<ModalFooter className={styles.buttonSection} paddingInlineStart={0} paddingInlineEnd={0} paddingBottom={0}>
<ModalFooter className={styles.buttonSection}>
<Button isDisabled={disableRevert} variant='ontime-ghost-on-light' size='sm' onClick={handleRevert}>
Revert to saved
</Button>
@@ -1,35 +0,0 @@
import { Button } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import style from './Modals.module.scss';
export default function SubmitContainer(props) {
const { submitting, changed, revert, status } = props;
return (
<div className={style.submitContainer}>
<Button
isDisabled={submitting || !changed}
variant='ghosted'
onClick={revert}
>
Revert
</Button>
<Button
colorScheme='blue'
type='submit'
isLoading={submitting}
disabled={!changed || status !== 'success'}
>
Save
</Button>
</div>
);
}
SubmitContainer.propTypes = {
submitting: PropTypes.bool,
changed: PropTypes.bool,
status: PropTypes.string,
revert: PropTypes.func.isRequired,
};
@@ -1,139 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Input, ModalBody } from '@chakra-ui/react';
import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline';
import { useEmitLog } from '@/common/stores/logger';
import { postUserFields } from '../../common/api/ontimeApi';
import useUserFields from '../../common/hooks-query/useUserFields';
import { userFieldsPlaceholder } from '../../common/models/UserFields';
import { handleLinks, host } from '../../common/utils/linkUtils';
import SubmitContainer from './SubmitContainer';
import style from './Modals.module.scss';
export default function TableOptionsModal() {
const { data, status, refetch } = useUserFields();
const { emitError } = useEmitLog();
const [changed, setChanged] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [userFields, setUserFields] = useState(userFieldsPlaceholder);
/**
* Set formdata from server state
*/
useEffect(() => {
if (data == null) return;
if (changed) return;
// Todo: we need some validation on API replies
setUserFields(data);
}, [changed, data]);
/**
* Validate and submit data
*/
const submitHandler = useCallback(async (event) => {
event.preventDefault();
setSubmitting(true);
// validation step makes clean string
const validatedFields = { ...userFields };
const errors = false;
for (const field in validatedFields) {
validatedFields[field] = validatedFields[field].trim();
}
if (!errors) {
try {
await postUserFields(validatedFields);
} catch (error) {
emitError(`Error saving table options: ${error}`)
}
await refetch();
setChanged(false);
}
setSubmitting(false);
},[emitError, refetch, userFields]);
/**
* Reverts local state equals to server state
*/
const revert = useCallback(async () => {
setChanged(false);
await refetch();
},[refetch]);
/**
* Handles change of input field in local state
* @param {string} field - object parameter to update
* @param {string} value - new object parameter value
*/
const handleChange = useCallback((field, value) => {
if (value.length < 30) {
const temp = { ...userFields };
temp[field] = value;
setUserFields(temp);
setChanged(true);
}
},[userFields]);
return (
<ModalBody className={style.modalBody}>
<p className={style.notes}>
Options related to cuesheets
<br />
🔥 Changes take effect on save 🔥
</p>
<form onSubmit={submitHandler}>
<div className={style.modalFields}>
<div className={style.hSeparator}>User Fields</div>
<div className={style.blockNotes}>
<span className={style.inlineFlex}>
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
User Fields
</span>
<span>
Userfields facilitate adding custom fields to an event (eg: light, sound, camera).{' '}
<br />
These are available for excel imports and shown in the{' '}
<a
target='_blank'
rel='noreferrer'
href={`http://${host}cuesheet`}
onClick={(e) => handleLinks(e, 'cuesheet')}
>
cuesheet
</a>
</span>
</div>
<div className={style.inlineAliasPlaceholder} style={{ padding: '0.5em 0' }}>
<span className={style.labelNote}>User Field</span>
<span className={style.labelNote}>Display Name</span>
</div>
{Object.keys(userFields).map((field) => (
<div className={style.inlineAlias} key={field}>
<span>{field}</span>
<Input
size='sm'
variant='flushed'
name='Alias'
placeholder={field}
autoComplete='off'
value={userFields[field]}
onChange={(event) => handleChange(field, event.target.value)}
/>
</div>
))}
</div>
<SubmitContainer
revert={revert}
submitting={submitting}
changed={changed}
status={status}
/>
</form>
</ModalBody>
);
}
@@ -1,148 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { FormControl, FormLabel, ModalBody } from '@chakra-ui/react';
import { IoCheckmarkSharp } from '@react-icons/all-files/io5/IoCheckmarkSharp';
import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline';
import { useEmitLog } from '@/common/stores/logger';
import { postView } from '../../common/api/ontimeApi';
import EnableBtn from '../../common/components/buttons/EnableBtn';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { viewsSettingsPlaceholder } from '../../common/models/ViewSettings.type';
import { openLink } from '../../common/utils/linkUtils';
import SubmitContainer from './SubmitContainer';
import style from './Modals.module.scss';
export default function ViewsSettingsModal() {
const { data, status, refetch } = useViewSettings();
const { emitError } = useEmitLog();
const [formData, setFormData] = useState(viewsSettingsPlaceholder);
const [changed, setChanged] = useState(false);
const [submitting, setSubmitting] = useState(false);
/**
* Set formdata from server state
*/
useEffect(() => {
if (data == null) return;
if (changed) return;
setFormData({ ...data });
}, [changed, data]);
/**
* Validate and submit data
*/
const submitHandler = useCallback(
async (event) => {
event.preventDefault();
setSubmitting(true);
try {
await postView(formData);
} catch (error) {
emitError(`Error view settings: ${error}`);
} finally {
await refetch();
setChanged(false);
}
setSubmitting(false);
},
[emitError, formData, refetch],
);
/**
* Reverts local state equals to server state
*/
const revert = useCallback(async () => {
setChanged(false);
await refetch();
}, [refetch]);
/**
* Handles change of input field in local state
* @param {string} field - object parameter to update
* @param {(string | number | boolean)} value - new object parameter value
*/
const handleChange = useCallback(
(field, value) => {
const temp = { ...formData };
temp[field] = value;
setFormData(temp);
setChanged(true);
},
[formData],
);
return (
<ModalBody className={style.modalBody}>
<p className={style.notes}>
Options related to the viewers
<br />
🔥 Changes take effect immediately 🔥
</p>
<form onSubmit={submitHandler}>
<div className={style.hSeparator}>Style Options</div>
<div className={style.blockNotes}>
<span className={style.inlineFlex}>
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
CSS Style Overrides
</span>
This feature allows user defined CSS to override the application stylesheets as a way to customise viewers
appearance.
<br />
Currently the feature affects the following views
<br />
<ul className={style.featureList}>
<li>
<IoCheckmarkSharp /> Stage timer
</li>
<li>
<IoCheckmarkSharp /> Clock
</li>
<li>
<IoCheckmarkSharp /> Minimal timer
</li>
<li>
<IoCheckmarkSharp /> Backstage screen
</li>
<li>
<IoCheckmarkSharp /> Public screen
</li>
<li>
<IoCheckmarkSharp /> Countdown
</li>
</ul>
Read more about it in the documentation{' '}
<a
href='#!'
onClick={() => openLink('https://cpvalente.gitbook.io/ontime/features/custom-styling')}
className={style.if}
>
over at Gitbook
</a>
</div>
<div className={style.modalFields}>
<div className={style.modalInline}>
<FormControl>
<FormLabel htmlFor='overrideStyles'>
Override CSS Styles
<span className={style.labelNote}>
<br />
Enable / Disable override
</span>
</FormLabel>
<EnableBtn
active={formData.overrideStyles}
text={formData.overrideStyles ? 'Style Override Enabled' : 'Style Override Disabled'}
actionHandler={() => handleChange('overrideStyles', !formData.overrideStyles)}
/>
</FormControl>
</div>
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
</div>
</form>
</ModalBody>
);
}

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