mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 943dd19c09 | |||
| 138be478ca | |||
| 98bd320cbd | |||
| f57faf1837 | |||
| 44f025b359 | |||
| 0555d0f400 | |||
| 1e90ce4c1d | |||
| 2f09d27813 | |||
| a01046b479 | |||
| 78d5d442cf | |||
| 7c234466f7 | |||
| 29e5c96d44 | |||
| 36b1effaaf | |||
| a84c0654e2 | |||
| b34fe71995 | |||
| 2f74293505 | |||
| e2aabe3646 | |||
| 7545f6f7d0 | |||
| 958c68b6f6 | |||
| a0c5375376 | |||
| 9708f0bfc6 | |||
| 13eca98133 | |||
| 126e31403e | |||
| 53beea2768 | |||
| 0bb09dd039 | |||
| 93fb48ea1c | |||
| 1de3e01216 | |||
| 8d427a6bbe | |||
| 23dcca5527 | |||
| a3bbd74db7 | |||
| 5323e627fb | |||
| cd9dbcdc68 | |||
| af99882919 | |||
| 1d4dc3a26f | |||
| e5fdf27f6c | |||
| 657cc22b44 | |||
| 51c31adaf1 | |||
| a46a0e1631 | |||
| 4f298e6fd9 | |||
| f3610059ef | |||
| 023aac4ead | |||
| 8c9cb908cf | |||
| 839cee18f3 | |||
| b83ebf53a9 | |||
| 1b8392bba4 | |||
| af1241bec7 | |||
| c361ff4b3f | |||
| b579a4c57e | |||
| e49918e8bb | |||
| 60799d3d88 | |||
| 31fc222876 | |||
| bb1eb2838f | |||
| 3603b836f6 | |||
| bee7a8dcb4 | |||
| e3444747bb | |||
| db1155ce1a | |||
| 6ef6b0fdcd | |||
| c73f7e2207 | |||
| 4a0d83d970 | |||
| b89d0787d4 | |||
| 91a2d395cb | |||
| 6c9222bce3 |
@@ -21,6 +21,6 @@
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "warn"
|
||||
"no-console": ["warn", { "allow": ["warn", "error"]}]
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,9 @@ jobs:
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: './apps/electron/dist/ontime-macOS.dmg'
|
||||
files: |
|
||||
./apps/electron/dist/ontime-macOS-x64.dmg
|
||||
./apps/electron/dist/ontime-macOS-arm64.dmg
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
build
|
||||
coverage
|
||||
dist
|
||||
node_modules
|
||||
|
||||
playwright-report
|
||||
|
||||
**/*.toml
|
||||
**/*.yml
|
||||
**/*.json
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
|
||||
+35
-6
@@ -1,32 +1,48 @@
|
||||
# GETTING STARTED
|
||||
|
||||
Ontime consists of 3 distinct parts
|
||||
- __client__: A React app for Ontime's UI and web clients
|
||||
|
||||
- __client__: A React app for Ontime's UI and web clients
|
||||
- __electron__: An electron app which facilitates the cross-platform distribution of Ontime
|
||||
- __server__: A node application which handles the domains services and integrations
|
||||
|
||||
The steps below will assume you have locally installed the necessary dependencies.
|
||||
The steps below will assume you have locally installed the necessary dependencies.
|
||||
Other dependencies will be installed as part of the setup
|
||||
|
||||
- __node__ (>=16.16)
|
||||
- __pnpm__ (>=7)
|
||||
- __docker__ (only necessary to run and build docker images)
|
||||
|
||||
## LOCAL DEVELOPMENT
|
||||
|
||||
The electron app is only necessary to distribute an installable version of the app and is not required for local development.
|
||||
The electron app is only necessary to distribute an installable version of the app and is not required for local
|
||||
development.
|
||||
Locally, we would need to run both the React client and the node.js server in development mode
|
||||
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Run dev mode__ by running `turbo dev`
|
||||
|
||||
### Debugging backend
|
||||
|
||||
To debug backend code in Node.js:
|
||||
|
||||
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
|
||||
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
|
||||
applications.
|
||||
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
|
||||
running `pnpm dev:inspect`.
|
||||
|
||||
## TESTING
|
||||
|
||||
Generally we have 2 types of tests.
|
||||
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.
|
||||
@@ -35,12 +51,22 @@ 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
|
||||
|
||||
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)
|
||||
|
||||
Before running the E2E, you should first build the project with `pnpm build:local`.
|
||||
|
||||
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`
|
||||
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`
|
||||
|
||||
Some other useful commands
|
||||
|
||||
- `pnpm e2e --ui` open playwright UI
|
||||
- `pnpm e2e --headed` run tests with a visible browser window
|
||||
|
||||
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
|
||||
|
||||
@@ -48,6 +74,7 @@ Ontime uses Electron to distribute the application.
|
||||
You can generate a distribution for your OS by running the following steps.
|
||||
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Build the UI and server__ by running `turbo build:local`
|
||||
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
|
||||
@@ -60,10 +87,12 @@ Ontime provides a docker-compose file to aid with building and running docker im
|
||||
While it should allow for a generic setup, it might need to be modified to fit your infrastructure.
|
||||
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
||||
- __Run docker image from compose__ by running `docker-compose up -d`
|
||||
|
||||
Other useful commands
|
||||
|
||||
- __List running processes__ by running `docker ps`
|
||||
- __Kill running process__ by running `docker kill <process-id>`
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
|
||||
Ontime is an application for creating and managing event running order and timers.
|
||||
|
||||
The user inputs a list of events along with scheduling and event information.
|
||||
The user inputs a list of events along with scheduling and event information.
|
||||
This will then populate a series of screens which are available to be rendered by any device in the Network.
|
||||
|
||||
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
|
||||
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video
|
||||
outputs.
|
||||
|
||||

|
||||
|
||||
@@ -28,7 +29,7 @@ This makes for a simple and cheap way to distribute over a venue using a network
|
||||
Once installed and running, Ontime starts a background server that is the heart of all processes.
|
||||
From the app, you can add / edit your running order and control the timer playback.
|
||||
|
||||
Any device with a browser in the same network can choose one of the supported views to render the available data.
|
||||
Any device with a browser in the same network can choose one of the supported views to render the available data.
|
||||
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
|
||||
or `192.168.1.3:4001`
|
||||
<br />
|
||||
@@ -50,8 +51,9 @@ IP.ADDRESS:4001/public > Public / Foyer view
|
||||
IP.ADDRESS:4001/lower > Lower Thirds
|
||||
IP.ADDRESS:4001/studio > Studio Clock
|
||||
```
|
||||
|
||||
```
|
||||
For management views
|
||||
For management views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/editor > the control interface, same as the app
|
||||
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||
@@ -63,14 +65,14 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
|
||||
|
||||
- [x] Distribute data over network and render it in the browser
|
||||
- [x] Different screen types
|
||||
- Stage Timer
|
||||
- Minimal Timer
|
||||
- Clock
|
||||
- Backstage Info
|
||||
- Public Info
|
||||
- Studio Clock
|
||||
- Countdown
|
||||
- [Make your own?](#make-your-own-viewer)
|
||||
- Stage Timer
|
||||
- Minimal Timer
|
||||
- Clock
|
||||
- Backstage Info
|
||||
- Public Info
|
||||
- Studio Clock
|
||||
- Countdown
|
||||
- [Make your own?](#make-your-own-viewer)
|
||||
- [x] Configurable Lower Thirds
|
||||
- [x] Collaborative editing with the cuesheet view
|
||||
- [x] Send live messages to different screen types
|
||||
@@ -83,17 +85,19 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
|
||||
- [x] Roll mode: run standalone using the system clock
|
||||
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
|
||||
- [x] [Countdown to anything!](https://ontime.gitbook.io/v2/views/countdown): have
|
||||
a countdown to any scheduled event
|
||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||
a countdown to any scheduled event
|
||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
|
||||
|
||||
## Unopinionated
|
||||
|
||||
We want Ontime to be unique by targeting freelancers instead of roles.
|
||||
We want Ontime to be unique by targeting freelancers instead of roles.
|
||||
|
||||
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your many environments and workflows.
|
||||
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your
|
||||
many environments and workflows.
|
||||
|
||||
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to work.
|
||||
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to
|
||||
work.
|
||||
|
||||
## Rich APIs for workflow integrations
|
||||
|
||||
@@ -113,7 +117,8 @@ Taking advantage of the integrations, we currently use Ontime with:
|
||||
|
||||
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
|
||||
|
||||
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language that can run in the browser).
|
||||
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language
|
||||
that can run in the browser).
|
||||
<br />
|
||||
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
|
||||
how to get you started and read the docs about
|
||||
@@ -121,26 +126,11 @@ the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-api
|
||||
|
||||
### Headless run️
|
||||
|
||||
You can self-host and run Ontime in a docker image. The run command will:
|
||||
You can self-host and run Ontime in a docker image.
|
||||
|
||||
- expose the necessary ports (listed in the Dockerfile)
|
||||
- mount a local file to persist your data (in the example: ````$(pwd)/local-data````)
|
||||
- the image name __getontime/ontime__
|
||||
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
|
||||
|
||||
The docker image is
|
||||
in [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
|
||||
|
||||
```bash
|
||||
docker pull getontime/ontime
|
||||
```
|
||||
|
||||
and use the included docker compose to get started
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
Related information available [in the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
|
||||
If you want to run this image in a Raspberry Pi, please see [the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -148,32 +138,32 @@ Related information available [in the docs](https://ontime.gitbook.io/v2/additio
|
||||
|
||||
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
|
||||
<br />
|
||||
Have an idea? Reach out via [email](mail@getontime.no) or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
Have an idea? Reach out via [email](mail@getontime.no)
|
||||
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
|
||||
### Issues
|
||||
|
||||
We use Github's issue tracking for bug reporting and feature requests. <br />
|
||||
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
||||
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
||||
|
||||
#### Unsigned App
|
||||
|
||||
When installing the app you would see warning screens from the Operating System like:
|
||||
|
||||
```Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.```
|
||||
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
|
||||
|
||||
or
|
||||
|
||||
```Ontime can't be opened because it is from an unidentified developer```
|
||||
`Ontime can't be opened because it is from an unidentified developer`
|
||||
|
||||
or in Linux
|
||||
|
||||
```Could Not Display "ontime-linux.AppImage```
|
||||
`Could Not Display "ontime-linux.AppImage`
|
||||
|
||||
You can circumvent this by allowing the execution of the app manually.
|
||||
|
||||
- In Windows: click more and select "Run Anyway"
|
||||
- in macOS: after attempting to run the installer, navigate to System Preferences -> Security &
|
||||
Privacy and allow the execution of the app
|
||||
- in macOS: the solution in macOS is different across versions, please refer to the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
|
||||
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
|
||||
File as a Program
|
||||
|
||||
@@ -185,6 +175,7 @@ please [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
#### Safari
|
||||
|
||||
There are known issues with Safari versions lower than 13:
|
||||
|
||||
- Spacing and text styles might have small inconsistencies
|
||||
- Table view does not work
|
||||
|
||||
@@ -194,7 +185,8 @@ There is no plan for any further work on this.
|
||||
|
||||
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
||||
<br /><br />
|
||||
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a Pull Request.
|
||||
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a
|
||||
Pull Request.
|
||||
<br />
|
||||
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"stylelint-config-standard-scss",
|
||||
"stylelint-config-prettier"
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "2.0.2",
|
||||
"version": "2.13.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.5.5",
|
||||
"@chakra-ui/react": "^2.7.0",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
@@ -14,7 +14,8 @@
|
||||
"@sentry/tracing": "^7.46.0",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"@tanstack/react-query-devtools": "^4.29.0",
|
||||
"autosize": "^5.0.2",
|
||||
"@tanstack/react-table": "^8.9.2",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.2.0",
|
||||
"color": "^4.2.3",
|
||||
"csv-stringify": "^6.2.3",
|
||||
@@ -27,7 +28,6 @@
|
||||
"react-hook-form": "^7.43.5",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-table": "^7.7.0",
|
||||
"typeface-open-sans": "^1.1.13",
|
||||
"web-vitals": "^3.1.1",
|
||||
"zustand": "^4.3.6"
|
||||
@@ -40,7 +40,6 @@
|
||||
"build:local": "cross-env NODE_ENV=local vite build",
|
||||
"build:docker": "vite build",
|
||||
"lint": "eslint .",
|
||||
"stylelint": "npx stylelint \"**/*.scss\"\n",
|
||||
"test": "vitest",
|
||||
"test:pipeline": "vitest run",
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
|
||||
@@ -65,7 +64,6 @@
|
||||
"@testing-library/user-event": "^14.1.1",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/luxon": "^3.2.0",
|
||||
"@types/prop-types": "^15.7.5",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
@@ -84,11 +82,7 @@
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "^2.8.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"sass": "^1.57.1",
|
||||
"stylelint": "^14.16.1",
|
||||
"stylelint-config-prettier": "^9.0.4",
|
||||
"stylelint-config-standard-scss": "^6.1.0",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^4.3.1",
|
||||
"vite-plugin-compression2": "^0.9.0",
|
||||
|
||||
+15
-13
@@ -4,11 +4,12 @@ import { ChakraProvider } from '@chakra-ui/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
import { ContextMenu } from './common/components/context-menu/ContextMenu';
|
||||
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
||||
import { AppContextProvider } from './common/context/AppContext';
|
||||
import { ContextMenuProvider } from './common/context/ContextMenuContext';
|
||||
import useElectronEvent from './common/hooks/useElectronEvent';
|
||||
import { ontimeQueryClient } from './common/queryClient';
|
||||
import { socketClientName } from './common/stores/connectionName';
|
||||
import { connectSocket } from './common/utils/socket';
|
||||
import theme from './theme/theme';
|
||||
import { TranslationProvider } from './translation/TranslationProvider';
|
||||
@@ -18,7 +19,8 @@ import AppRouter from './AppRouter';
|
||||
// @ts-expect-error no types from font import
|
||||
import('typeface-open-sans');
|
||||
|
||||
connectSocket();
|
||||
const preferredClientName = socketClientName.getState().name;
|
||||
connectSocket(preferredClientName);
|
||||
|
||||
function App() {
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
@@ -50,18 +52,18 @@ function App() {
|
||||
<ChakraProvider resetCSS theme={theme}>
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
<AppContextProvider>
|
||||
<ContextMenuProvider>
|
||||
<BrowserRouter>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<TranslationProvider>
|
||||
<BrowserRouter>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<TranslationProvider>
|
||||
<ContextMenu>
|
||||
<AppRouter />
|
||||
</TranslationProvider>
|
||||
</ErrorBoundary>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</ContextMenuProvider>
|
||||
</ContextMenu>
|
||||
</TranslationProvider>
|
||||
</ErrorBoundary>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</AppContextProvider>
|
||||
</QueryClientProvider>
|
||||
</ChakraProvider>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import useAliases from './common/hooks-query/useAliases';
|
||||
import withAlias from './features/AliasWrapper';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
|
||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Table = lazy(() => import('./features/table/ProtectedTable'));
|
||||
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = lazy(() => import('./features/operator/Operator'));
|
||||
|
||||
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
||||
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
@@ -17,14 +18,14 @@ const Public = lazy(() => import('./features/viewers/public/Public'));
|
||||
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper'));
|
||||
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
|
||||
const STimer = withData(TimerView);
|
||||
const SMinimalTimer = withData(MinimalTimerView);
|
||||
const SClock = withData(ClockView);
|
||||
const SCountdown = withData(Countdown);
|
||||
const SBackstage = withData(Backstage);
|
||||
const SPublic = withData(Public);
|
||||
const SLowerThird = withData(Lower);
|
||||
const SStudio = withData(StudioClock);
|
||||
const STimer = withAlias(withData(TimerView));
|
||||
const SMinimalTimer = withAlias(withData(MinimalTimerView));
|
||||
const SClock = withAlias(withData(ClockView));
|
||||
const SCountdown = withAlias(withData(Countdown));
|
||||
const SBackstage = withAlias(withData(Backstage));
|
||||
const SPublic = withAlias(withData(Public));
|
||||
const SLowerThird = withAlias(withData(Lower));
|
||||
const SStudio = withAlias(withData(StudioClock));
|
||||
|
||||
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
|
||||
@@ -33,22 +34,6 @@ const MessageControl = lazy(() => import('./features/control/message/MessageCont
|
||||
const Info = lazy(() => import('./features/info/InfoExport'));
|
||||
|
||||
export default function AppRouter() {
|
||||
const { data } = useAliases();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// navigate if is alias route
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
for (const d of data) {
|
||||
if (`/${d.alias}` === location.pathname && d.enabled) {
|
||||
navigate(`/${d.pathAndParams}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [data, location, navigate]);
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
@@ -74,11 +59,14 @@ export default function AppRouter() {
|
||||
{/*/!* Lower cannot have fallback *!/*/}
|
||||
<Route path='/lower' element={<SLowerThird />} />
|
||||
|
||||
<Route path='/op' element={<Operator />} />
|
||||
<Route path='/operator' element={<Operator />} />
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<Table />} />
|
||||
<Route path='/cuelist' element={<Table />} />
|
||||
<Route path='/table' element={<Table />} />
|
||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||
<Route path='/cuelist' element={<Cuesheet />} />
|
||||
<Route path='/table' element={<Cuesheet />} />
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export const STATIC_PORT = 4001;
|
||||
|
||||
// REST stuff
|
||||
export const EVENT_DATA = ['eventdata'];
|
||||
export const PROJECT_DATA = ['project'];
|
||||
export const ALIASES = ['aliases'];
|
||||
export const USERFIELDS = ['userFields'];
|
||||
export const RUNDOWN_TABLE_KEY = 'rundown';
|
||||
@@ -12,11 +10,16 @@ export const APP_SETTINGS = ['appSettings'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const RUNTIME = ['runtimeStore'];
|
||||
|
||||
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 = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${serverPort}/ws`;
|
||||
const location = window.location;
|
||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
|
||||
export const eventURL = `${serverURL}/eventdata`;
|
||||
const STATIC_PORT = 4001;
|
||||
export const serverPort = isProduction ? location.port : STATIC_PORT;
|
||||
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
|
||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
||||
|
||||
export const projectDataURL = `${serverURL}/project`;
|
||||
export const rundownURL = `${serverURL}/events`;
|
||||
export const ontimeURL = `${serverURL}/ontime`;
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { LogLevel } from 'ontime-types';
|
||||
import { generateId, millisToString } from 'ontime-utils';
|
||||
|
||||
import { addLog } from '../stores/logger';
|
||||
import { nowInMillis } from '../utils/time';
|
||||
|
||||
export function logAxiosError(prepend: string, error: unknown) {
|
||||
let message;
|
||||
if (axios.isAxiosError(error)) {
|
||||
const statusText = (error as AxiosError).response?.statusText ?? '';
|
||||
const data = (error as AxiosError).response?.data ?? '';
|
||||
message = `${prepend} ${statusText}: ${data}`;
|
||||
} else {
|
||||
message = `${prepend}: ${error}`;
|
||||
}
|
||||
|
||||
addLog({
|
||||
id: generateId(),
|
||||
origin: 'SERVER',
|
||||
time: millisToString(nowInMillis()),
|
||||
level: LogLevel.Error,
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { EventData } from 'ontime-types';
|
||||
|
||||
import { eventURL } from './apiConstants';
|
||||
|
||||
/**
|
||||
* @description HTTP request to fetch event data
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function fetchEventData(): Promise<EventData> {
|
||||
const res = await axios.get(eventURL);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate event data
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postEventData(data: EventData) {
|
||||
return axios.post(eventURL, data);
|
||||
}
|
||||
@@ -28,14 +28,6 @@ export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
|
||||
return axios.put(rundownURL, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to modify event
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function requestPatchEvent(data: OntimeRundownEntry) {
|
||||
return axios.patch(rundownURL, data);
|
||||
}
|
||||
|
||||
export type ReorderEntry = {
|
||||
eventId: string;
|
||||
from: number;
|
||||
@@ -58,6 +50,19 @@ export async function requestApplyDelay(eventId: string) {
|
||||
return axios.patch(`${rundownURL}/applydelay/${eventId}`);
|
||||
}
|
||||
|
||||
export type SwapEntry = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description HTTP request to swap two events
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function requestEventSwap(data: SwapEntry) {
|
||||
return axios.patch(`${rundownURL}/swap`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to delete given event
|
||||
* @return {Promise}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alias,
|
||||
EventData,
|
||||
OSCSettings,
|
||||
OscSubscription,
|
||||
Settings,
|
||||
UserFields,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { apiRepoLatest } from '../../externals';
|
||||
import { InfoType } from '../models/Info';
|
||||
import fileDownload from '../utils/fileDownload';
|
||||
|
||||
import { ontimeURL } from './apiConstants';
|
||||
|
||||
@@ -117,32 +110,17 @@ export async function postOscSubscriptions(data: OscSubscription) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to download db
|
||||
* @return {Promise}
|
||||
* @description HTTP request to download db in CSV format
|
||||
*/
|
||||
export const downloadRundown = async () => {
|
||||
await axios({
|
||||
url: `${ontimeURL}/db`,
|
||||
method: 'GET',
|
||||
responseType: 'blob', // important
|
||||
}).then((response) => {
|
||||
const headerLine = response.headers['Content-Disposition'];
|
||||
let filename = 'rundown.json';
|
||||
export const downloadCSV = () => {
|
||||
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
|
||||
};
|
||||
|
||||
// try and get the filename from the response
|
||||
if (headerLine != null) {
|
||||
const startFileNameIndex = headerLine.indexOf('"') + 1;
|
||||
const endFileNameIndex = headerLine.lastIndexOf('"');
|
||||
filename = headerLine.substring(startFileNameIndex, endFileNameIndex);
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/json' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
});
|
||||
/**
|
||||
* @description HTTP request to download db in JSON format
|
||||
*/
|
||||
export const downloadRundown = () => {
|
||||
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -186,6 +164,6 @@ export async function getLatestVersion(): Promise<HasUpdate> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function postNew(initialData: Partial<EventData>) {
|
||||
export async function postNew(initialData: Partial<ProjectData>) {
|
||||
return axios.post(`${ontimeURL}/new`, initialData);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios from 'axios';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
import { projectDataURL } from './apiConstants';
|
||||
|
||||
/**
|
||||
* @description HTTP request to fetch project data
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function getProjectData(): Promise<ProjectData> {
|
||||
const res = await axios.get(projectDataURL);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate project data
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postProjectData(data: ProjectData) {
|
||||
return axios.post(projectDataURL, data);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// logic (with some modifications) culled from:
|
||||
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
||||
|
||||
import { Fragment, ReactElement } from 'react';
|
||||
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
|
||||
import { IconType } from '@react-icons/all-files';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import style from './ContextMenu.module.scss';
|
||||
|
||||
type ContextMenuCoords = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type Option = {
|
||||
label: string;
|
||||
icon: IconType;
|
||||
onClick: () => void;
|
||||
withDivider?: boolean;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
type ContextMenuStore = {
|
||||
coords: ContextMenuCoords;
|
||||
options: Option[];
|
||||
isOpen: boolean;
|
||||
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
|
||||
setIsOpen: (newIsOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
|
||||
coords: { x: 0, y: 0 },
|
||||
options: [],
|
||||
isOpen: false,
|
||||
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
|
||||
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
|
||||
}));
|
||||
|
||||
interface ContextMenuProps {
|
||||
// ReactElement type required due to early `return` (line 51) returning {children}
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
export const ContextMenu = ({ children }: ContextMenuProps) => {
|
||||
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
|
||||
|
||||
const onClose = () => {
|
||||
return setIsOpen(false);
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<div className={style.contextMenuBackdrop} />
|
||||
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
||||
<MenuButton
|
||||
className={style.contextMenuButton}
|
||||
aria-hidden
|
||||
w={1}
|
||||
h={1}
|
||||
style={{
|
||||
left: coords.x,
|
||||
top: coords.y,
|
||||
}}
|
||||
/>
|
||||
<MenuList>
|
||||
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
|
||||
<Fragment key={label}>
|
||||
{withDivider && <MenuDivider />}
|
||||
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.delaySymbol {
|
||||
svg {
|
||||
font-size: 1.5rem;
|
||||
color: $ontime-delay;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
||||
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { millisToDelayString } from '../../utils/dateConfig';
|
||||
|
||||
import style from './DelayIndicator.module.scss';
|
||||
|
||||
interface DelayIndicatorProps {
|
||||
delayValue?: number;
|
||||
}
|
||||
|
||||
export default function DelayIndicator(props: DelayIndicatorProps) {
|
||||
const { delayValue } = props;
|
||||
|
||||
if (typeof delayValue === 'number') {
|
||||
if (delayValue < 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronDown />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (delayValue > 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronUp />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import React from 'react';
|
||||
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
import { runtime } from '@/common/stores/runtime';
|
||||
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
|
||||
|
||||
import style from './ErrorBoundary.module.scss';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
@@ -25,7 +28,9 @@ class ErrorBoundary extends React.Component {
|
||||
});
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setExtras(error);
|
||||
scope.setExtras('error', error);
|
||||
scope.setExtras('store', runtime.getState());
|
||||
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
|
||||
const eventId = Sentry.captureException(error);
|
||||
this.setState({ eventId, info });
|
||||
});
|
||||
|
||||
@@ -3,12 +3,7 @@ import { Textarea, TextareaProps } from '@chakra-ui/react';
|
||||
// @ts-expect-error no types from library
|
||||
import autosize from 'autosize/dist/autosize';
|
||||
|
||||
interface AutoTextAreaProps extends TextareaProps {
|
||||
isDark?: boolean;
|
||||
}
|
||||
|
||||
export const AutoTextArea = (props: AutoTextAreaProps) => {
|
||||
const { isDark, ...rest } = props;
|
||||
export const AutoTextArea = (props: TextareaProps) => {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -26,8 +21,8 @@ export const AutoTextArea = (props: AutoTextAreaProps) => {
|
||||
resize='none'
|
||||
ref={ref}
|
||||
transition='height none'
|
||||
variant={isDark ? 'ontime-filled' : 'ontime-filled-on-light'}
|
||||
{...rest}
|
||||
variant='ontime-transparent'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorTitles';
|
||||
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
|
||||
|
||||
import Swatch from './Swatch';
|
||||
|
||||
|
||||
@@ -37,7 +37,17 @@ function ButtonTooltip(name: TimeEntryField, warning?: string) {
|
||||
}
|
||||
|
||||
export default function TimeInput(props: TimeInputProps) {
|
||||
const { id, name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props;
|
||||
const {
|
||||
id,
|
||||
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>('');
|
||||
@@ -191,7 +201,7 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
data-testid='time-input'
|
||||
data-testid={`time-input-${name}`}
|
||||
className={style.inputField}
|
||||
type='text'
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { KeyboardEvent, memo, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||
@@ -14,9 +15,11 @@ import useFullscreen from '../../hooks/useFullscreen';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
|
||||
import RenameClientModal from './rename-client-modal/RenameClientModal';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
export default function NavigationMenu() {
|
||||
function NavigationMenu() {
|
||||
const location = useLocation();
|
||||
|
||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||
@@ -27,8 +30,10 @@ export default function NavigationMenu() {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const toggleMenu = () => setShowMenu((prev) => !prev);
|
||||
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' });
|
||||
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen });
|
||||
|
||||
useEffect(() => {
|
||||
let fadeOut: NodeJS.Timeout | null = null;
|
||||
@@ -51,13 +56,15 @@ export default function NavigationMenu() {
|
||||
const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter';
|
||||
const handleFullscreen = () => toggleFullScreen();
|
||||
const handleMirror = () => toggleMirror();
|
||||
|
||||
const showEditFormDrawer = () => {
|
||||
searchParams.append('edit', 'true');
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}>
|
||||
<RenameClientModal isOpen={isOpen} onClose={onClose} />
|
||||
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
|
||||
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
|
||||
<IoApps />
|
||||
@@ -92,11 +99,28 @@ export default function NavigationMenu() {
|
||||
Flip Screen
|
||||
<IoSwapVertical />
|
||||
</div>
|
||||
{/*<div className={style.link} tabIndex={0}>*/}
|
||||
{/* Rename Client*/}
|
||||
{/*</div>*/}
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && onOpen();
|
||||
}}
|
||||
>
|
||||
Rename Client
|
||||
</div>
|
||||
</div>
|
||||
<hr className={style.separator} />
|
||||
<Link to='/cuesheet' className={style.link} tabIndex={0}>
|
||||
Cuesheet
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link to='/op' className={style.link} tabIndex={0}>
|
||||
Operator
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<hr className={style.separator} />
|
||||
{navigatorConstants.map((route) => (
|
||||
<Link
|
||||
key={route.url}
|
||||
@@ -116,3 +140,5 @@ export default function NavigationMenu() {
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(NavigationMenu);
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
.modalBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { setClientName } from '../../../hooks/useSocket';
|
||||
import { useSocketClientName } from '../../../stores/connectionName';
|
||||
|
||||
import style from './RenameClientModal.module.scss';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) {
|
||||
const { name: clientName, persistName } = useSocketClientName();
|
||||
const [newName, setNewName] = useState(clientName);
|
||||
|
||||
useEffect(() => {
|
||||
setNewName(clientName);
|
||||
}, [isOpen, clientName]);
|
||||
|
||||
const handleRename = async () => {
|
||||
if (newName) {
|
||||
await setClientName(newName);
|
||||
persistName(newName);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size='sm'
|
||||
closeOnOverlayClick={false}
|
||||
motionPreset='slideInBottom'
|
||||
scrollBehavior='inside'
|
||||
preserveScrollBarGap
|
||||
variant='ontime-small'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Rename client</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody className={style.modalBody}>
|
||||
<Input
|
||||
placeholder='Connection must have a name'
|
||||
defaultValue={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
variant='ontime-filled-on-light'
|
||||
/>
|
||||
<Button
|
||||
isDisabled={newName === clientName || !newName}
|
||||
onClick={handleRename}
|
||||
width='100%'
|
||||
variant='ontime-filled'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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 { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
|
||||
interface PlaybackIconProps {
|
||||
state: Playback;
|
||||
skipTooltip?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PlaybackIcon(props: PlaybackIconProps) {
|
||||
const { state, skipTooltip, className } = props;
|
||||
|
||||
// if timer is Pause or Armed
|
||||
let label = 'Timer Paused';
|
||||
let Icon = IoPause;
|
||||
|
||||
if (state === Playback.Roll) {
|
||||
label = 'Timer Rolling';
|
||||
Icon = IoPlay;
|
||||
} else if (state === Playback.Play) {
|
||||
label = 'Timer Playing';
|
||||
Icon = IoPlay;
|
||||
} else if (state === Playback.Stop) {
|
||||
label = 'Timer Stopped';
|
||||
Icon = IoStop;
|
||||
}
|
||||
|
||||
if (skipTooltip) {
|
||||
return <Icon className={className} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
|
||||
<Icon className={className} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import Empty from '../state/Empty';
|
||||
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
import ScheduleItem from './ScheduleItem';
|
||||
|
||||
import './Schedule.scss';
|
||||
|
||||
interface ScheduleProps {
|
||||
isProduction?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Schedule({ className }: ScheduleProps) {
|
||||
export default function Schedule({ isProduction, className }: ScheduleProps) {
|
||||
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
||||
|
||||
// TODO: design a nice placeholder for empty schedules
|
||||
if (paginatedEvents?.length < 1) {
|
||||
return <Empty text='No events to show' />;
|
||||
return null;
|
||||
}
|
||||
|
||||
let selectedState: 'past' | 'now' | 'future' = 'past';
|
||||
@@ -30,12 +30,16 @@ export default function Schedule({ className }: ScheduleProps) {
|
||||
selectedState = 'future';
|
||||
}
|
||||
}
|
||||
|
||||
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
|
||||
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
|
||||
|
||||
return (
|
||||
<ScheduleItem
|
||||
key={event.id}
|
||||
selected={selectedState}
|
||||
timeStart={event.timeStart}
|
||||
timeEnd={event.timeEnd}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
title={event.title}
|
||||
colour={isBackstage ? event.colour : ''}
|
||||
backstageEvent={!event.isPublic}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createContext, PropsWithChildren, useContext, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { useInterval } from '../../hooks/useInterval';
|
||||
import { isStringBoolean } from '../../utils/viewUtils';
|
||||
|
||||
interface ScheduleContextState {
|
||||
events: OntimeEvent[];
|
||||
@@ -32,12 +34,25 @@ export const ScheduleProvider = ({
|
||||
time = 10,
|
||||
}: PropsWithChildren<ScheduleProviderProps>) => {
|
||||
const [visiblePage, setVisiblePage] = useState(0);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const numPages = Math.ceil(events.length / eventsPerPage);
|
||||
// look for overrides from views
|
||||
const hidePast = isStringBoolean(searchParams.get('hidePast'));
|
||||
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
|
||||
|
||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
|
||||
const viewEvents = [...events];
|
||||
if (hidePast) {
|
||||
// we want to show the event after the next
|
||||
viewEvents.splice(0, selectedEventIndex + 2);
|
||||
selectedEventIndex = 0;
|
||||
}
|
||||
|
||||
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
|
||||
const eventStart = eventsPerPage * visiblePage;
|
||||
const eventEnd = eventsPerPage * (visiblePage + 1);
|
||||
const paginatedEvents = events.slice(eventStart, eventEnd);
|
||||
const selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
|
||||
|
||||
const resolveScheduleType = () => {
|
||||
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
|
||||
@@ -52,7 +67,9 @@ export const ScheduleProvider = ({
|
||||
|
||||
// every SCROLL_TIME go to the next array
|
||||
useInterval(() => {
|
||||
if (events.length > eventsPerPage) {
|
||||
if (stopCycle) {
|
||||
setVisiblePage(0);
|
||||
} else if (events.length > eventsPerPage) {
|
||||
const next = (visiblePage + 1) % numPages;
|
||||
setVisiblePage(next);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
|
||||
|
||||
import style from './Empty.module.scss';
|
||||
|
||||
@@ -15,12 +15,12 @@ export default function ParamInput({ paramField }: EditFormInputProps) {
|
||||
|
||||
if (type === 'option') {
|
||||
const optionFromParams = searchParams.get(id);
|
||||
const defaultOptionValue = paramField.values.find((value) => value === optionFromParams);
|
||||
const defaultOptionValue = optionFromParams || undefined;
|
||||
|
||||
return (
|
||||
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}>
|
||||
{paramField.values.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{Object.entries(paramField.values).map(([key, value]) => (
|
||||
<option key={key} value={key}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
.drawerContent {
|
||||
background-color: $gray-1200;
|
||||
background-color: $gray-1250;
|
||||
}
|
||||
|
||||
.drawerHeader {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UserFields } from 'ontime-types';
|
||||
|
||||
import { ParamField } from './types';
|
||||
|
||||
export const TIME_FORMAT_OPTION: ParamField = {
|
||||
@@ -5,7 +7,7 @@ export const TIME_FORMAT_OPTION: ParamField = {
|
||||
title: '12 / 24 hour timer',
|
||||
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
|
||||
type: 'option',
|
||||
values: ['12', '24'],
|
||||
values: { '12': '12 hour AM/PM', '24': '24 hour' },
|
||||
};
|
||||
|
||||
export const CLOCK_OPTIONS: ParamField[] = [
|
||||
@@ -45,7 +47,7 @@ export const CLOCK_OPTIONS: ParamField[] = [
|
||||
title: 'Align Horizontal',
|
||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
||||
type: 'option',
|
||||
values: ['start', 'center', 'end'],
|
||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||
},
|
||||
{
|
||||
id: 'offsetx',
|
||||
@@ -58,7 +60,7 @@ export const CLOCK_OPTIONS: ParamField[] = [
|
||||
title: 'Align Vertical',
|
||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
||||
type: 'option',
|
||||
values: ['start', 'center', 'end'],
|
||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||
},
|
||||
{
|
||||
id: 'offsety',
|
||||
@@ -106,7 +108,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
title: 'Align Horizontal',
|
||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
||||
type: 'option',
|
||||
values: ['start', 'center', 'end'],
|
||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||
},
|
||||
{
|
||||
id: 'offsetx',
|
||||
@@ -119,7 +121,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
title: 'Align Vertical',
|
||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
||||
type: 'option',
|
||||
values: ['start', 'center', 'end'],
|
||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||
},
|
||||
{
|
||||
id: 'offsety',
|
||||
@@ -130,7 +132,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
{
|
||||
id: 'hideovertime',
|
||||
title: 'Hide Overtime',
|
||||
description: 'Whether to supress overtime styles (red borders and red text)',
|
||||
description: 'Whether to suppress overtime styles (red borders and red text)',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
@@ -192,6 +194,37 @@ export const LOWER_THIRDS_OPTIONS: ParamField[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const BACKSTAGE_OPTIONS: ParamField[] = [
|
||||
TIME_FORMAT_OPTION,
|
||||
{
|
||||
id: 'hidePast',
|
||||
title: 'Hide past events',
|
||||
description: 'Scheduler will only show upcoming events',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'stopCycle',
|
||||
title: 'Stop cycling through event pages',
|
||||
description: 'Schedule will not auto-cycle through events',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
|
||||
export const PUBLIC_OPTIONS: ParamField[] = [
|
||||
TIME_FORMAT_OPTION,
|
||||
{
|
||||
id: 'hidePast',
|
||||
title: 'Hide past events',
|
||||
description: 'Scheduler will only show upcoming events',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'stopCycle',
|
||||
title: 'Stop cycling through event pages',
|
||||
description: 'Schedule will not auto-cycle through events',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
||||
TIME_FORMAT_OPTION,
|
||||
{
|
||||
@@ -201,3 +234,61 @@ export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
|
||||
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
|
||||
return [
|
||||
TIME_FORMAT_OPTION,
|
||||
{
|
||||
id: 'showseconds',
|
||||
title: 'Show seconds',
|
||||
description: 'Schedule shows hh:mm:ss',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'hidepast',
|
||||
title: 'Hide Past Events',
|
||||
description: 'Whether to events that have passed',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'main',
|
||||
title: 'Main data field',
|
||||
description: 'Field to be shown in the first line of text',
|
||||
type: 'option',
|
||||
values: {
|
||||
title: 'Title',
|
||||
subtitle: 'Subtitle',
|
||||
presenter: 'Presenter',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'secondary',
|
||||
title: 'Secondary data field',
|
||||
description: 'Field to be shown in the second line of text',
|
||||
type: 'option',
|
||||
values: {
|
||||
title: 'Title',
|
||||
subtitle: 'Subtitle',
|
||||
presenter: 'Presenter',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'subscribe',
|
||||
title: 'Highlight Field',
|
||||
description: 'Choose a field to highlight',
|
||||
type: 'option',
|
||||
values: {
|
||||
user0: userFields.user0 || 'user0',
|
||||
user1: userFields.user1 || 'user1',
|
||||
user2: userFields.user2 || 'user2',
|
||||
user3: userFields.user3 || 'user3',
|
||||
user4: userFields.user4 || 'user4',
|
||||
user5: userFields.user5 || 'user5',
|
||||
user6: userFields.user6 || 'user6',
|
||||
user7: userFields.user7 || 'user7',
|
||||
user8: userFields.user8 || 'user8',
|
||||
user9: userFields.user9 || 'user9',
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ type BaseField = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
type OptionsField = { type: 'option'; values: string[] };
|
||||
type OptionsField = { type: 'option'; values: Record<string, string> };
|
||||
type StringField = { type: 'string' };
|
||||
type BooleanField = { type: 'boolean' };
|
||||
type NumberField = { type: 'number' };
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// logic (with some modifications) culled from:
|
||||
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
||||
|
||||
import { createContext, ReactNode, useState } from 'react';
|
||||
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
|
||||
import { IconType } from '@react-icons/all-files';
|
||||
|
||||
import style from './ContextMenuContext.module.scss';
|
||||
|
||||
type ContextMenuCoords = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type ContextMenuContextType = {
|
||||
createContextMenu: (options: Option[], menuCoordinates: ContextMenuCoords) => void;
|
||||
};
|
||||
|
||||
export const ContextMenuContext = createContext<ContextMenuContextType | null>(null);
|
||||
|
||||
export type Option = {
|
||||
label: string;
|
||||
icon: IconType;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
interface ContextMenuProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [coords, setCoords] = useState<ContextMenuCoords>({ x: 0, y: 0 });
|
||||
const [options, setOptions] = useState<Option[]>([]);
|
||||
|
||||
const onClose = () => {
|
||||
return setIsOpen(false);
|
||||
};
|
||||
|
||||
const createContextMenu = (options: Option[], menuCoords: ContextMenuCoords) => {
|
||||
setCoords(menuCoords);
|
||||
setOptions(options);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenuContext.Provider value={{ createContextMenu }}>
|
||||
{children}
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className={style.contextMenuBackdrop} />
|
||||
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
||||
<MenuButton
|
||||
className={style.contextMenuButton}
|
||||
aria-hidden
|
||||
w={1}
|
||||
h={1}
|
||||
style={{
|
||||
left: coords.x,
|
||||
top: coords.y,
|
||||
}}
|
||||
/>
|
||||
<MenuList>
|
||||
{options.map(({ label, icon: Icon, onClick }, i) => (
|
||||
<MenuItem key={i} icon={<Icon />} onClick={onClick}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import { createContext, useCallback, useState } from 'react';
|
||||
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage';
|
||||
|
||||
export const TableSettingsContext = createContext({
|
||||
theme: '',
|
||||
showSettings: false,
|
||||
followSelected: false,
|
||||
|
||||
toggleSettings: () => undefined,
|
||||
toggleTheme: () => undefined,
|
||||
toggleFollow: () => undefined,
|
||||
});
|
||||
|
||||
export const TableSettingsProvider = ({ children }) => {
|
||||
const [theme, setTheme] = useLocalStorage('table-color-theme', 'dark');
|
||||
const [followSelected, setFollowSelected] = useLocalStorage('table-follow-selected', false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
/**
|
||||
* @description Toggles the current value of dark mode
|
||||
* @param {string} val - 'light' or 'dark'
|
||||
*/
|
||||
const toggleTheme = useCallback(
|
||||
(val) => {
|
||||
if (val === undefined) {
|
||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||
} else {
|
||||
setTheme(val);
|
||||
}
|
||||
},
|
||||
[setTheme]
|
||||
);
|
||||
|
||||
/**
|
||||
* @description Toggles visibility state for settings
|
||||
* @param {boolean} val - whether the settings window is visible
|
||||
*/
|
||||
const toggleSettings = useCallback(
|
||||
(val) => {
|
||||
if (val === undefined) {
|
||||
setShowSettings((prev) => !prev);
|
||||
} else {
|
||||
setShowSettings(val);
|
||||
}
|
||||
},
|
||||
[setShowSettings]
|
||||
);
|
||||
|
||||
/**
|
||||
* @description Toggles follow option
|
||||
* @param {boolean} val - whether the window follows selected event
|
||||
*/
|
||||
const toggleFollow = useCallback(
|
||||
(val) => {
|
||||
if (val === undefined) {
|
||||
setFollowSelected((prev) => !prev);
|
||||
} else {
|
||||
setFollowSelected(val);
|
||||
}
|
||||
},
|
||||
[setFollowSelected]
|
||||
);
|
||||
|
||||
return (
|
||||
<TableSettingsContext.Provider
|
||||
value={{
|
||||
theme,
|
||||
showSettings,
|
||||
followSelected,
|
||||
toggleSettings,
|
||||
toggleTheme,
|
||||
toggleFollow,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TableSettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
function getMatches(query: string): boolean {
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
|
||||
// TODO: debounce handleChange
|
||||
export default function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(getMatches(query));
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
setMatches(getMatches(query));
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const matchMedia = window.matchMedia(query);
|
||||
|
||||
// Triggered at the first client-side load and if query changes
|
||||
handleChange();
|
||||
|
||||
// Listen matchMedia
|
||||
matchMedia.addEventListener('change', handleChange);
|
||||
|
||||
return () => {
|
||||
matchMedia.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [handleChange, query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { OSCSettings } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { OSC_SETTINGS } from '../api/apiConstants';
|
||||
import { logAxiosError } from '../api/apiUtils';
|
||||
import { getOSC, postOSC, postOscSubscriptions } from '../api/ontimeApi';
|
||||
import { oscPlaceholderSettings } from '../models/OscSettings';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
@@ -25,6 +26,7 @@ export default function useOscSettings() {
|
||||
export function useOscSettingsMutation() {
|
||||
const { isLoading, mutateAsync } = useMutation({
|
||||
mutationFn: postOSC,
|
||||
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
||||
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
||||
});
|
||||
@@ -34,6 +36,7 @@ export function useOscSettingsMutation() {
|
||||
export function usePostOscSubscriptions() {
|
||||
const { isLoading, mutateAsync } = useMutation({
|
||||
mutationFn: postOscSubscriptions,
|
||||
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
||||
});
|
||||
return { isLoading, mutateAsync };
|
||||
|
||||
+7
-7
@@ -1,15 +1,15 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { EVENT_DATA } from '../api/apiConstants';
|
||||
import { fetchEventData } from '../api/eventDataApi';
|
||||
import { eventDataPlaceholder } from '../models/EventData';
|
||||
import { PROJECT_DATA } from '../api/apiConstants';
|
||||
import { getProjectData } from '../api/projectDataApi';
|
||||
import { projectDataPlaceholder } from '../models/ProjectData';
|
||||
|
||||
export default function useEventData() {
|
||||
export default function useProjectData() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: EVENT_DATA,
|
||||
queryFn: fetchEventData,
|
||||
placeholderData: eventDataPlaceholder,
|
||||
queryKey: PROJECT_DATA,
|
||||
queryFn: getProjectData,
|
||||
placeholderData: projectDataPlaceholder,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
@@ -1,22 +1,16 @@
|
||||
import { MouseEvent, useContext } from 'react';
|
||||
import { MouseEvent } from 'react';
|
||||
|
||||
import { ContextMenuContext, Option } from '../context/ContextMenuContext';
|
||||
import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu';
|
||||
|
||||
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
|
||||
const contextMenuContext = useContext(ContextMenuContext);
|
||||
|
||||
if (contextMenuContext === null) {
|
||||
throw new Error('useContextMenu should be wrapped by ContextMenuProvider');
|
||||
}
|
||||
|
||||
const { createContextMenu } = contextMenuContext;
|
||||
const { setContextMenu } = useContextMenuStore();
|
||||
|
||||
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
|
||||
// prevent browser default context menu from showing up
|
||||
contextMenuEvent.preventDefault();
|
||||
|
||||
const { pageX, pageY } = contextMenuEvent;
|
||||
return createContextMenu(options, { x: pageX, y: pageY });
|
||||
return setContextMenu({ x: pageX, y: pageY }, options);
|
||||
};
|
||||
|
||||
return [localCreateContextMenu];
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
|
||||
import { isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
|
||||
import { logAxiosError } from '../api/apiUtils';
|
||||
import {
|
||||
ReorderEntry,
|
||||
requestApplyDelay,
|
||||
requestDelete,
|
||||
requestDeleteAll,
|
||||
requestEventSwap,
|
||||
requestPostEvent,
|
||||
requestPutEvent,
|
||||
requestReorderEvent,
|
||||
SwapEntry,
|
||||
} from '../api/eventsApi';
|
||||
import { useLocalEvent } from '../stores/localEvent';
|
||||
import { useEmitLog } from '../stores/logger';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
|
||||
/**
|
||||
* @description Set of utilities for events
|
||||
*/
|
||||
export const useEventAction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { emitError } = useEmitLog();
|
||||
const eventSettings = useLocalEvent((state) => state.eventSettings);
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const defaultPublic = eventSettings.defaultPublic;
|
||||
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
|
||||
|
||||
@@ -30,9 +31,10 @@ export const useEventAction = () => {
|
||||
* Calls mutation to add new event
|
||||
* @private
|
||||
*/
|
||||
const _addEventMutation = useMutation(requestPostEvent, {
|
||||
const _addEventMutation = useMutation({
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
mutationFn: requestPostEvent,
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
},
|
||||
@@ -57,7 +59,7 @@ export const useEventAction = () => {
|
||||
const newEvent: Partial<OntimeRundownEntry> = { ...event };
|
||||
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (newEvent.type === SupportedEvent.Event) {
|
||||
if (isOntimeEvent(newEvent)) {
|
||||
const applicationOptions = {
|
||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
|
||||
@@ -65,16 +67,20 @@ export const useEventAction = () => {
|
||||
after: options?.after,
|
||||
};
|
||||
|
||||
if (newEvent?.cue === undefined) {
|
||||
newEvent.cue = getCueCandidate(queryClient.getQueryData(RUNDOWN_TABLE) || [], options?.after);
|
||||
}
|
||||
|
||||
// hard coding duration value to be as expected for now
|
||||
// this until timeOptions gets implemented
|
||||
if (typeof newEvent?.timeStart !== 'undefined' && typeof newEvent.timeEnd !== 'undefined') {
|
||||
if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) {
|
||||
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
|
||||
}
|
||||
|
||||
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
|
||||
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
|
||||
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
|
||||
if (typeof previousEvent !== 'undefined' && previousEvent.type === 'event') {
|
||||
if (previousEvent !== undefined && previousEvent.type === 'event') {
|
||||
newEvent.timeStart = previousEvent.timeEnd;
|
||||
newEvent.timeEnd = previousEvent.timeEnd;
|
||||
}
|
||||
@@ -94,21 +100,18 @@ export const useEventAction = () => {
|
||||
// @ts-expect-error -- we know that the object is well formed now
|
||||
await _addEventMutation.mutateAsync(newEvent);
|
||||
} catch (error) {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
emitError(`Error fetching data: ${(error as AxiosError).message}`);
|
||||
} else {
|
||||
emitError(`Error fetching data: ${error}`);
|
||||
}
|
||||
logAxiosError('Error fetching data', error);
|
||||
}
|
||||
},
|
||||
[_addEventMutation, defaultPublic, emitError, queryClient, startTimeIsLastEnd],
|
||||
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to update existing event
|
||||
* @private
|
||||
*/
|
||||
const _updateEventMutation = useMutation(requestPutEvent, {
|
||||
const _updateEventMutation = useMutation({
|
||||
mutationFn: requestPutEvent,
|
||||
// we optimistically update here
|
||||
onMutate: async (newEvent) => {
|
||||
// cancel ongoing queries
|
||||
@@ -123,7 +126,6 @@ export const useEventAction = () => {
|
||||
// Return a context with the previous and new events
|
||||
return { previousEvent, newEvent };
|
||||
},
|
||||
|
||||
// Mutation fails, rollback undoes optimist update
|
||||
onError: (_error, _newEvent, context) => {
|
||||
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
|
||||
@@ -144,21 +146,18 @@ export const useEventAction = () => {
|
||||
try {
|
||||
await _updateEventMutation.mutateAsync(event);
|
||||
} catch (error) {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
emitError(`Error updating event: ${(error as AxiosError).message}`);
|
||||
} else {
|
||||
emitError(`Error updating event: ${error}`);
|
||||
}
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
},
|
||||
[_updateEventMutation, emitError],
|
||||
[_updateEventMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to delete an event
|
||||
* @private
|
||||
*/
|
||||
const _deleteEventMutation = useMutation(requestDelete, {
|
||||
const _deleteEventMutation = useMutation({
|
||||
mutationFn: requestDelete,
|
||||
// we optimistically update here
|
||||
onMutate: async (eventId) => {
|
||||
// cancel ongoing queries
|
||||
@@ -196,21 +195,18 @@ export const useEventAction = () => {
|
||||
try {
|
||||
await _deleteEventMutation.mutateAsync(eventId);
|
||||
} catch (error) {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
emitError(`Error deleting event: ${(error as AxiosError).message}`);
|
||||
} else {
|
||||
emitError(`Error deleting event: ${error}`);
|
||||
}
|
||||
logAxiosError('Error deleting event', error);
|
||||
}
|
||||
},
|
||||
[_deleteEventMutation, emitError],
|
||||
[_deleteEventMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to delete all events
|
||||
* @private
|
||||
*/
|
||||
const _deleteAllEventsMutation = useMutation(requestDeleteAll, {
|
||||
const _deleteAllEventsMutation = useMutation({
|
||||
mutationFn: requestDeleteAll,
|
||||
// we optimistically update here
|
||||
onMutate: async () => {
|
||||
// cancel ongoing queries
|
||||
@@ -245,19 +241,16 @@ export const useEventAction = () => {
|
||||
try {
|
||||
await _deleteAllEventsMutation.mutateAsync();
|
||||
} catch (error) {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
emitError(`Error deleting events: ${(error as AxiosError).message}`);
|
||||
} else {
|
||||
emitError(`Error deleting events: ${error}`);
|
||||
}
|
||||
logAxiosError('Error deleting events', error);
|
||||
}
|
||||
}, [_deleteAllEventsMutation, emitError]);
|
||||
}, [_deleteAllEventsMutation]);
|
||||
|
||||
/**
|
||||
* Calls mutation to apply a delay
|
||||
* @private
|
||||
*/
|
||||
const _applyDelayMutation = useMutation(requestApplyDelay, {
|
||||
const _applyDelayMutation = useMutation({
|
||||
mutationFn: requestApplyDelay,
|
||||
// Mutation finished, failed or successful
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
@@ -273,21 +266,18 @@ export const useEventAction = () => {
|
||||
try {
|
||||
await _applyDelayMutation.mutateAsync(delayEventId);
|
||||
} catch (error) {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
emitError(`Error applying delay: ${(error as AxiosError).message}`);
|
||||
} else {
|
||||
emitError(`Error applying delay: ${error}`);
|
||||
}
|
||||
logAxiosError('Error applying delay', error);
|
||||
}
|
||||
},
|
||||
[_applyDelayMutation, emitError],
|
||||
[_applyDelayMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to reorder an event
|
||||
* @private
|
||||
*/
|
||||
const _reorderEventMutation = useMutation(requestReorderEvent, {
|
||||
const _reorderEventMutation = useMutation({
|
||||
mutationFn: requestReorderEvent,
|
||||
// we optimistically update here
|
||||
onMutate: async (data) => {
|
||||
// cancel ongoing queries
|
||||
@@ -332,15 +322,73 @@ export const useEventAction = () => {
|
||||
};
|
||||
await _reorderEventMutation.mutateAsync(reorderObject);
|
||||
} catch (error) {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
emitError(`Error re-ordering event: ${(error as AxiosError).message}`);
|
||||
} else {
|
||||
emitError(`Error re-ordering event: ${error}`);
|
||||
}
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
}
|
||||
},
|
||||
[_reorderEventMutation, emitError],
|
||||
[_reorderEventMutation],
|
||||
);
|
||||
|
||||
return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent };
|
||||
/**
|
||||
* Calls mutation to swap events
|
||||
* @private
|
||||
*/
|
||||
const _swapEvents = useMutation({
|
||||
mutationFn: requestEventSwap,
|
||||
// we optimistically update here
|
||||
onMutate: async ({ from, to }) => {
|
||||
// cancel ongoing queries
|
||||
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
|
||||
|
||||
// Snapshot the previous value
|
||||
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
|
||||
|
||||
const fromEventIndex = rundown.findIndex((event) => event.id === from);
|
||||
const toEventIndex = rundown.findIndex((event) => event.id === to);
|
||||
|
||||
const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
|
||||
|
||||
// optimistically update object
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, previousEvents);
|
||||
|
||||
// Return a context with the previous events
|
||||
return { previousEvents };
|
||||
},
|
||||
|
||||
// Mutation fails, rollback undoes optimist update
|
||||
onError: (_error, _eventId, context) => {
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
|
||||
},
|
||||
// Mutation finished, failed or successful
|
||||
// Fetch anyway, just to be sure
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
},
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
/**
|
||||
* Swaps the schedule of two events
|
||||
*/
|
||||
const swapEvents = useCallback(
|
||||
async ({ from, to }: SwapEntry) => {
|
||||
// TODO: before calling `/swapEvents`,
|
||||
// we should determine the events are of type `OntimeEvent`
|
||||
try {
|
||||
await _swapEvents.mutateAsync({ from, to });
|
||||
} catch (error) {
|
||||
logAxiosError('Error re-ordering event', error);
|
||||
}
|
||||
},
|
||||
[_swapEvents],
|
||||
);
|
||||
|
||||
return {
|
||||
addEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
deleteAllEvents,
|
||||
applyDelay,
|
||||
reorderEvent,
|
||||
swapEvents,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { MutableRefObject, useCallback, useEffect } from 'react';
|
||||
|
||||
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||
componentRef: MutableRefObject<ComponentRef>,
|
||||
scrollRef: MutableRefObject<ScrollRef>,
|
||||
topOffset: number,
|
||||
) {
|
||||
if (!componentRef.current || !scrollRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentRect = componentRef.current.getBoundingClientRect();
|
||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
||||
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
|
||||
|
||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
interface UseFollowComponentProps {
|
||||
followRef: MutableRefObject<HTMLElement | null>;
|
||||
scrollRef: MutableRefObject<HTMLElement | null>;
|
||||
doFollow: boolean;
|
||||
topOffset?: number;
|
||||
setScrollFlag?: () => void;
|
||||
}
|
||||
|
||||
export default function useFollowComponent(props: UseFollowComponentProps) {
|
||||
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
|
||||
|
||||
// when cursor moves, view should follow
|
||||
useEffect(() => {
|
||||
if (!doFollow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (followRef.current && scrollRef.current) {
|
||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||
window.requestAnimationFrame(() => {
|
||||
setScrollFlag?.();
|
||||
scrollToComponent(
|
||||
followRef as MutableRefObject<HTMLElement>,
|
||||
scrollRef as MutableRefObject<HTMLElement>,
|
||||
topOffset,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line -- the prompt seems incorrect
|
||||
}, [followRef?.current, scrollRef?.current]);
|
||||
|
||||
const scrollToRefComponent = useCallback(
|
||||
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
||||
if (componentRef.current && containerRef.current) {
|
||||
// @ts-expect-error -- we know this are not null
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
scrollToComponent(componentRef!, scrollRef!, offset);
|
||||
}
|
||||
},
|
||||
[followRef, scrollRef, topOffset],
|
||||
);
|
||||
|
||||
return scrollToRefComponent;
|
||||
}
|
||||
@@ -34,24 +34,30 @@ export default function useFullscreen() {
|
||||
const toggleFullScreen = useCallback(() => {
|
||||
if (!document.fullscreenElement && !(document as WebkitDocument).webkitIsFullScreen) {
|
||||
// Fullscreen mode is not active, so we can enter fullscreen mode
|
||||
if (document.documentElement.requestFullscreen) {
|
||||
const element = document.documentElement;
|
||||
if (element.requestFullscreen) {
|
||||
// Standard fullscreen API is supported
|
||||
document.documentElement.requestFullscreen();
|
||||
// @ts-expect-error -- the whole casting dance is not worth it
|
||||
} else if (document.documentElement.webkitRequestFullscreen) {
|
||||
element.requestFullscreen().catch(() => {
|
||||
/* nothing to do */
|
||||
});
|
||||
} else if (element.webkitRequestFullscreen) {
|
||||
// iOS Safari fullscreen API is supported
|
||||
// @ts-expect-error -- we know this is not undefined now
|
||||
(document.documentElement as WebkitDocument).webkitRequestFullscreen();
|
||||
element.webkitRequestFullscreen?.().catch(() => {
|
||||
/* nothing to do */
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fullscreen mode is active, so we can exit fullscreen mode
|
||||
if (document.exitFullscreen) {
|
||||
// Standard fullscreen API is supported
|
||||
document.exitFullscreen();
|
||||
document.exitFullscreen().catch((error) => {
|
||||
console.error('Error while trying to exit fullscreen:', error);
|
||||
});
|
||||
} else if ((document as WebkitDocument).webkitExitFullscreen) {
|
||||
// iOS Safari fullscreen API is supported
|
||||
// @ts-expect-error -- we know this is not undefined now
|
||||
(document as WebkitDocument).webkitExitFullscreen();
|
||||
(document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
|
||||
/* nothing to do */
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -13,6 +13,15 @@ export const useRundownEditor = () => {
|
||||
return useRuntimeStore(featureSelector, deepCompare);
|
||||
};
|
||||
|
||||
export const useOperator = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.playback,
|
||||
selectedEventId: state.loaded.selectedEventId,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector, deepCompare);
|
||||
};
|
||||
|
||||
export const useMessageControl = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
timerMessage: state.timerMessage,
|
||||
@@ -70,7 +79,8 @@ export const setPlayback = {
|
||||
|
||||
export const useInfoPanel = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
titles: state.titles,
|
||||
eventNow: state.eventNow,
|
||||
eventNext: state.eventNext,
|
||||
playback: state.playback,
|
||||
selectedEventIndex: state.loaded.selectedEventIndex,
|
||||
numEvents: state.loaded.numEvents,
|
||||
@@ -85,7 +95,7 @@ export const useCuesheet = () => {
|
||||
selectedEventId: state.loaded.selectedEventId,
|
||||
selectedEventIndex: state.loaded.selectedEventIndex,
|
||||
numEvents: state.loaded.numEvents,
|
||||
titleNow: state.titles.titleNow,
|
||||
titleNow: state.eventNow?.title || '',
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector, deepCompare);
|
||||
@@ -105,3 +115,5 @@ export const useTimer = () => {
|
||||
|
||||
return useRuntimeStore(featureSelector, deepCompare);
|
||||
};
|
||||
|
||||
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { EventData } from 'ontime-types';
|
||||
|
||||
export const eventDataPlaceholder: EventData = {
|
||||
title: '',
|
||||
publicUrl: '',
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
export const projectDataPlaceholder: ProjectData = {
|
||||
title: '',
|
||||
description: '',
|
||||
publicUrl: '',
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useStore } from 'zustand';
|
||||
import { createStore } from 'zustand/vanilla';
|
||||
|
||||
interface SocketClientNameState {
|
||||
name?: string;
|
||||
setName: (newValue: string) => void;
|
||||
persistName: (newValue: string) => void;
|
||||
}
|
||||
|
||||
const clientNameKey = 'ontime-client-name';
|
||||
|
||||
function persistKeyToStorage(newValue: string) {
|
||||
localStorage.setItem(clientNameKey, newValue);
|
||||
}
|
||||
|
||||
export const socketClientName = createStore<SocketClientNameState>((set) => ({
|
||||
name: localStorage.getItem(clientNameKey) ?? undefined,
|
||||
setName: (newValue: string) => set(() => ({ name: newValue })),
|
||||
persistName: (newValue: string) =>
|
||||
set(() => {
|
||||
persistKeyToStorage(newValue);
|
||||
return { name: newValue };
|
||||
}),
|
||||
}));
|
||||
|
||||
export const useSocketClientName = () => useStore(socketClientName);
|
||||
@@ -0,0 +1,67 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
|
||||
type EditorSettings = {
|
||||
showQuickEntry: boolean;
|
||||
startTimeIsLastEnd: boolean;
|
||||
defaultPublic: boolean;
|
||||
showNif: boolean;
|
||||
};
|
||||
|
||||
type EditorSettingsStore = {
|
||||
eventSettings: EditorSettings;
|
||||
setLocalEventSettings: (newState: EditorSettings) => void;
|
||||
setShowQuickEntry: (showQuickEntry: boolean) => void;
|
||||
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
|
||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||
setShowNif: (showNif: boolean) => void;
|
||||
};
|
||||
|
||||
enum EditorSettingsKeys {
|
||||
ShowQuickEntry = 'ontime-show-quick-entry',
|
||||
StartTimeIsLastEnd = 'ontime-start-is-last-end',
|
||||
DefaultPublic = 'ontime-default-public',
|
||||
ShowNif = 'ontime-show-nif',
|
||||
}
|
||||
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
|
||||
eventSettings: {
|
||||
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
|
||||
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
|
||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
|
||||
showNif: booleanFromLocalStorage(EditorSettingsKeys.ShowNif, true),
|
||||
},
|
||||
|
||||
setLocalEventSettings: (value) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
|
||||
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
|
||||
return { eventSettings: value };
|
||||
}),
|
||||
|
||||
setShowQuickEntry: (showQuickEntry) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
|
||||
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
|
||||
}),
|
||||
|
||||
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
|
||||
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
|
||||
}),
|
||||
|
||||
setDefaultPublic: (defaultPublic) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
|
||||
return { eventSettings: { ...state.eventSettings, defaultPublic } };
|
||||
}),
|
||||
|
||||
setShowNif: (showNif) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.ShowNif, String(showNif));
|
||||
return { eventSettings: { ...state.eventSettings, showNif } };
|
||||
}),
|
||||
}));
|
||||
@@ -1,57 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
|
||||
type EventSettings = {
|
||||
showQuickEntry: boolean;
|
||||
startTimeIsLastEnd: boolean;
|
||||
defaultPublic: boolean;
|
||||
};
|
||||
|
||||
type LocalEventStore = {
|
||||
eventSettings: EventSettings;
|
||||
setLocalEventSettings: (newState: EventSettings) => void;
|
||||
setShowQuickEntry: (showQuickEntry: boolean) => void;
|
||||
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
|
||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||
};
|
||||
|
||||
enum LocalEventKeys {
|
||||
ShowQuickEntry = 'ontime-show-quick-entry',
|
||||
StartTimeIsLastEnd = 'ontime-start-is-last-end',
|
||||
DefaultPublic = 'ontime-default-public',
|
||||
}
|
||||
|
||||
export const useLocalEvent = create<LocalEventStore>((set) => ({
|
||||
eventSettings: {
|
||||
showQuickEntry: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, false),
|
||||
startTimeIsLastEnd: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true),
|
||||
defaultPublic: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true),
|
||||
},
|
||||
|
||||
setLocalEventSettings: (value) =>
|
||||
set(() => {
|
||||
localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(value.showQuickEntry));
|
||||
localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
|
||||
localStorage.setItem(LocalEventKeys.DefaultPublic, String(value.defaultPublic));
|
||||
return { eventSettings: value };
|
||||
}),
|
||||
|
||||
setShowQuickEntry: (showQuickEntry) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(showQuickEntry));
|
||||
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
|
||||
}),
|
||||
|
||||
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
|
||||
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
|
||||
}),
|
||||
|
||||
setDefaultPublic: (defaultPublic) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(LocalEventKeys.DefaultPublic, String(defaultPublic));
|
||||
return { eventSettings: { ...state.eventSettings, defaultPublic } };
|
||||
}),
|
||||
}));
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Log, LogLevel } from 'ontime-types';
|
||||
import { Log, LogLevel, LogOrigin } from 'ontime-types';
|
||||
import { generateId, millisToString } from 'ontime-utils';
|
||||
import { useStore } from 'zustand';
|
||||
import { createStore } from 'zustand/vanilla';
|
||||
@@ -34,7 +34,7 @@ export function useEmitLog() {
|
||||
const _emit = useCallback((text: string, level: LogLevel) => {
|
||||
const log = {
|
||||
id: generateId(),
|
||||
origin: 'CLIENT',
|
||||
origin: LogOrigin.Client,
|
||||
time: millisToString(nowInMillis()),
|
||||
level,
|
||||
text,
|
||||
|
||||
@@ -42,26 +42,10 @@ export const runtimeStorePlaceholder = {
|
||||
nextEventId: null,
|
||||
nextPublicEventId: null,
|
||||
},
|
||||
titles: {
|
||||
titleNow: null,
|
||||
subtitleNow: null,
|
||||
presenterNow: null,
|
||||
noteNow: null,
|
||||
titleNext: null,
|
||||
subtitleNext: null,
|
||||
presenterNext: null,
|
||||
noteNext: null,
|
||||
},
|
||||
titlesPublic: {
|
||||
titleNow: null,
|
||||
subtitleNow: null,
|
||||
presenterNow: null,
|
||||
noteNow: null,
|
||||
titleNext: null,
|
||||
subtitleNext: null,
|
||||
presenterNext: null,
|
||||
noteNext: null,
|
||||
},
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
publicEventNow: null,
|
||||
publicEventNext: null,
|
||||
};
|
||||
|
||||
export const runtime = createStore<RuntimeStore>(() => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { validateAlias } from '../aliases';
|
||||
import { resolvePath } from 'react-router-dom';
|
||||
import { validateAlias, generateURLFromAlias, getAliasRoute } from '../aliases';
|
||||
|
||||
describe('An alias fails if incorrect', () => {
|
||||
const testsToFail = [
|
||||
@@ -23,3 +24,79 @@ describe('An alias fails if incorrect', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
describe('generateURLFromAlias and getAliasRoute function', () => {
|
||||
test('generate the expected url from an alias', () => {
|
||||
const testData = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'demopage',
|
||||
pathAndParams: '/timer?user=guest',
|
||||
},
|
||||
];
|
||||
|
||||
const expected = [
|
||||
{
|
||||
url: '/timer?user=guest&alias=demopage',
|
||||
},
|
||||
];
|
||||
|
||||
expect(generateURLFromAlias(testData[0])).toStrictEqual(expected[0].url);
|
||||
});
|
||||
test('generate the url to redirect to when the current URL is just the alias', () => {
|
||||
const aliases = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'demopage',
|
||||
pathAndParams: '/timer?user=guest',
|
||||
},
|
||||
];
|
||||
// let current location be the alias
|
||||
const location = resolvePath(aliases[0].alias);
|
||||
|
||||
const expected = [
|
||||
{
|
||||
url: '/timer?user=guest&alias=demopage',
|
||||
},
|
||||
];
|
||||
|
||||
expect(getAliasRoute(location, aliases, null)).toStrictEqual(expected[0].url);
|
||||
});
|
||||
test('generate the url to redirect to when the current URL the same url but with a change of params', () => {
|
||||
const aliases = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'demopage',
|
||||
pathAndParams: '/timer?user=guest',
|
||||
},
|
||||
];
|
||||
// let current location be the actual url with alias attached to it
|
||||
const location = resolvePath(aliases[0].pathAndParams);
|
||||
const urlSearchParams = new URLSearchParams(location.search);
|
||||
urlSearchParams.append('alias', aliases[0].alias); //
|
||||
|
||||
// update current alias with extra param
|
||||
aliases[0].pathAndParams += '&eventId=674';
|
||||
const expected = [
|
||||
{
|
||||
url: '/timer?user=guest&eventId=674&alias=demopage',
|
||||
},
|
||||
];
|
||||
|
||||
expect(getAliasRoute(location, aliases, urlSearchParams)).toStrictEqual(expected[0].url);
|
||||
});
|
||||
test('generate no url to redirect to when the current URL the same url', () => {
|
||||
const aliases = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'demopage',
|
||||
pathAndParams: '/timer?user=guest',
|
||||
},
|
||||
];
|
||||
// let current location be the actual url with alias attached to it
|
||||
const location = resolvePath(aliases[0].pathAndParams);
|
||||
const urlSearchParams = new URLSearchParams(location.search);
|
||||
urlSearchParams.append('alias', aliases[0].alias); //
|
||||
|
||||
expect(getAliasRoute(location, aliases, urlSearchParams)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
import { forgivingStringToMillis, millisToDelayString, millisToMinutes, millisToSeconds } from '../dateConfig';
|
||||
import {
|
||||
forgivingStringToMillis,
|
||||
millisToDelayString,
|
||||
millisToMinutes,
|
||||
millisToSeconds,
|
||||
secondsInMillis,
|
||||
} from '../dateConfig';
|
||||
|
||||
describe('test secondsInMillis function', () => {
|
||||
it('return 0 if value is null', () => {
|
||||
expect(secondsInMillis(null)).toBe(0);
|
||||
});
|
||||
it('returns the seconds value of a millis date', () => {
|
||||
const date = 1686255053619; // Thu Jun 08 2023 20:10:53
|
||||
const seconds = secondsInMillis(date);
|
||||
expect(seconds).toBe(53);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test millisToSeconds function', () => {
|
||||
it('test with null values', () => {
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
import { formatEventList, getEventsWithDelay, trimRundown } from '../eventsManager';
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
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: [],
|
||||
expect: [],
|
||||
};
|
||||
|
||||
expect(getEventsWithDelay(emptyArray.test)).toStrictEqual(emptyArray.expect);
|
||||
});
|
||||
|
||||
it('given an undefined object', () => {
|
||||
const withUndefined = {
|
||||
test: undefined,
|
||||
expect: [],
|
||||
};
|
||||
|
||||
expect(getEventsWithDelay(withUndefined.test)).toStrictEqual(withUndefined.expect);
|
||||
});
|
||||
|
||||
it('given a corrupted event object', () => {
|
||||
const testData = [
|
||||
{
|
||||
title: 'Welcome to Ontime',
|
||||
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',
|
||||
},
|
||||
];
|
||||
const expected = [
|
||||
{
|
||||
title: 'Welcome to Ontime',
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('given a corrupted delay object', () => {
|
||||
const testData = [
|
||||
{
|
||||
title: 'Welcome to Ontime',
|
||||
timeStart: 28800000,
|
||||
timeEnd: 30600000,
|
||||
colour: '',
|
||||
type: 'event',
|
||||
id: '5946',
|
||||
},
|
||||
{
|
||||
type: 'delay',
|
||||
id: '24240',
|
||||
},
|
||||
{
|
||||
title: 'Unless recalled by the OSC address',
|
||||
timeStart: 34920000,
|
||||
timeEnd: 35520000,
|
||||
colour: '',
|
||||
type: 'event',
|
||||
id: '8ee5',
|
||||
},
|
||||
];
|
||||
const expected = [
|
||||
{
|
||||
title: 'Welcome to Ontime',
|
||||
timeStart: 28800000,
|
||||
timeEnd: 30600000,
|
||||
colour: '',
|
||||
type: 'event',
|
||||
id: '5946',
|
||||
},
|
||||
{
|
||||
title: 'Unless recalled by the OSC address',
|
||||
timeStart: 34920000,
|
||||
timeEnd: 35520000,
|
||||
colour: '',
|
||||
type: 'event',
|
||||
id: '8ee5',
|
||||
},
|
||||
];
|
||||
|
||||
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test trimEventlist function', () => {
|
||||
const limit = 8;
|
||||
const testData = [
|
||||
{ id: '1' },
|
||||
{ id: '2' },
|
||||
{ id: '3' },
|
||||
{ id: '4' },
|
||||
{ id: '5' },
|
||||
{ id: '6' },
|
||||
{ id: '7' },
|
||||
{ id: '8' },
|
||||
{ id: '9' },
|
||||
{ id: '10' },
|
||||
{ id: '11' },
|
||||
{ id: '12' },
|
||||
];
|
||||
|
||||
it('when we use the first item', () => {
|
||||
const selectedId = '1';
|
||||
const expected = [
|
||||
{ id: '1' },
|
||||
{ id: '2' },
|
||||
{ id: '3' },
|
||||
{ id: '4' },
|
||||
{ id: '5' },
|
||||
{ id: '6' },
|
||||
{ id: '7' },
|
||||
{ id: '8' },
|
||||
];
|
||||
|
||||
const l = trimRundown(testData, selectedId, limit);
|
||||
expect(l.length).toBe(limit);
|
||||
expect(l).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('when we use the third item', () => {
|
||||
const selectedId = '3';
|
||||
const expected = [
|
||||
{ id: '1' },
|
||||
{ id: '2' },
|
||||
{ id: '3' },
|
||||
{ id: '4' },
|
||||
{ id: '5' },
|
||||
{ id: '6' },
|
||||
{ id: '7' },
|
||||
{ id: '8' },
|
||||
];
|
||||
|
||||
const l = trimRundown(testData, selectedId, limit);
|
||||
expect(l.length).toBe(limit);
|
||||
expect(l).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('when we use the fourth item', () => {
|
||||
const selectedId = '4';
|
||||
const expected = [
|
||||
{ id: '2' },
|
||||
{ id: '3' },
|
||||
{ id: '4' },
|
||||
{ id: '5' },
|
||||
{ id: '6' },
|
||||
{ id: '7' },
|
||||
{ id: '8' },
|
||||
{ id: '9' },
|
||||
];
|
||||
|
||||
const l = trimRundown(testData, selectedId, limit);
|
||||
expect(l.length).toBe(limit);
|
||||
expect(l).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('if selected is not found', () => {
|
||||
const selectedId = '15';
|
||||
const expected = [
|
||||
{ id: '1' },
|
||||
{ id: '2' },
|
||||
{ id: '3' },
|
||||
{ id: '4' },
|
||||
{ id: '5' },
|
||||
{ id: '6' },
|
||||
{ id: '7' },
|
||||
{ id: '8' },
|
||||
];
|
||||
|
||||
const l = trimRundown(testData, selectedId, limit);
|
||||
expect(l.length).toBe(limit);
|
||||
expect(l).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test formatEvents function', () => {
|
||||
const testEvent = [
|
||||
{
|
||||
title: 'Welcome to Ontime',
|
||||
subtitle: 'Subtitles are useful',
|
||||
presenter: 'cpvalente',
|
||||
note: 'Maybe a running note for the operator?',
|
||||
timeStart: 28800000,
|
||||
timeEnd: 30600000,
|
||||
isPublic: false,
|
||||
colour: '',
|
||||
type: 'event',
|
||||
revision: 0,
|
||||
id: '5946',
|
||||
},
|
||||
{
|
||||
title: 'Unless recalled by the OSC address',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: 'In green, below',
|
||||
timeStart: 34800000,
|
||||
timeEnd: 35400000,
|
||||
isPublic: false,
|
||||
colour: '',
|
||||
type: 'event',
|
||||
revision: 0,
|
||||
id: '8ee5',
|
||||
},
|
||||
];
|
||||
|
||||
it('it parses correctly', () => {
|
||||
const selectedId = 'otherEvent';
|
||||
const nextId = 'notHere';
|
||||
const expected = [
|
||||
{
|
||||
id: '5946',
|
||||
time: '08:00 - 08:30',
|
||||
title: 'Welcome to Ontime',
|
||||
isNow: false,
|
||||
isNext: false,
|
||||
colour: '',
|
||||
},
|
||||
{
|
||||
id: '8ee5',
|
||||
time: '09:40 - 09:50',
|
||||
title: 'Unless recalled by the OSC address',
|
||||
isNow: false,
|
||||
isNext: false,
|
||||
colour: '',
|
||||
},
|
||||
];
|
||||
|
||||
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
|
||||
expect(parsed).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('it handles selected correctly', () => {
|
||||
const selectedId = '5946';
|
||||
const nextId = '8ee5';
|
||||
const expected = [
|
||||
{
|
||||
id: '5946',
|
||||
time: '08:00 - 08:30',
|
||||
title: 'Welcome to Ontime',
|
||||
isNow: true,
|
||||
isNext: false,
|
||||
colour: '',
|
||||
},
|
||||
{
|
||||
id: '8ee5',
|
||||
time: '09:40 - 09:50',
|
||||
title: 'Unless recalled by the OSC address',
|
||||
isNow: false,
|
||||
isNext: true,
|
||||
colour: '',
|
||||
},
|
||||
];
|
||||
|
||||
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
|
||||
expect(parsed).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('it handles next correctly', () => {
|
||||
const selectedId = '8ee5';
|
||||
const nextId = 'notHere';
|
||||
|
||||
const expected = [
|
||||
{
|
||||
id: '5946',
|
||||
time: '08:00 - 08:30',
|
||||
title: 'Welcome to Ontime',
|
||||
isNow: false,
|
||||
isNext: false,
|
||||
colour: '',
|
||||
},
|
||||
{
|
||||
id: '8ee5',
|
||||
time: '09:40 - 09:50',
|
||||
title: 'Unless recalled by the OSC address',
|
||||
isNow: true,
|
||||
isNext: false,
|
||||
colour: '',
|
||||
},
|
||||
];
|
||||
|
||||
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
|
||||
expect(parsed).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { EndAction, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
|
||||
|
||||
import { cloneEvent } from '../eventsManager';
|
||||
|
||||
describe('cloneEvent()', () => {
|
||||
it('creates a stem from a given event', () => {
|
||||
const original = {
|
||||
id: 'unique',
|
||||
type: SupportedEvent.Event,
|
||||
title: 'title',
|
||||
cue: 'cue',
|
||||
subtitle: 'subtitle',
|
||||
presenter: 'presenter',
|
||||
note: 'note',
|
||||
timeStart: 0,
|
||||
duration: 10,
|
||||
timeEnd: 10,
|
||||
timerType: TimerType.CountDown,
|
||||
endAction: EndAction.None,
|
||||
isPublic: false,
|
||||
skip: false,
|
||||
colour: 'F00',
|
||||
revision: 10,
|
||||
user0: 'user0',
|
||||
user1: 'user1',
|
||||
user2: 'user2',
|
||||
user3: 'user3',
|
||||
user4: 'user4',
|
||||
user5: 'user5',
|
||||
user6: 'user6',
|
||||
user7: 'user7',
|
||||
user8: 'user8',
|
||||
user9: 'user9',
|
||||
} as OntimeEvent;
|
||||
|
||||
const cloned = cloneEvent(original);
|
||||
expect(cloned).not.toBe(original);
|
||||
// @ts-expect-error -- safeguarding this
|
||||
expect(cloned?.id).toBe(undefined);
|
||||
expect(cloned.title).toBe(original.title);
|
||||
expect(cloned.subtitle).toBe(original.subtitle);
|
||||
expect(cloned.presenter).toBe(original.presenter);
|
||||
expect(cloned.note).toBe(original.note);
|
||||
expect(cloned.endAction).toBe(original.endAction);
|
||||
expect(cloned.timerType).toBe(original.timerType);
|
||||
expect(cloned.timeStart).toBe(original.timeStart);
|
||||
expect(cloned.timeEnd).toBe(original.timeEnd);
|
||||
expect(cloned.duration).toBe(original.duration);
|
||||
expect(cloned.isPublic).toBe(original.isPublic);
|
||||
expect(cloned.skip).toBe(original.skip);
|
||||
expect(cloned.colour).toBe(original.colour);
|
||||
expect(cloned.type).toBe(SupportedEvent.Event);
|
||||
expect(cloned.revision).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import getDelayTo from '../getDelayTo';
|
||||
|
||||
describe('getDelayTo function', () => {
|
||||
it('handles list with delays', () => {
|
||||
const delayDuration = 100;
|
||||
const events = [
|
||||
{ type: 'event' },
|
||||
{ type: 'delay', duration: delayDuration },
|
||||
{ type: 'event' },
|
||||
];
|
||||
|
||||
const notDelayed = getDelayTo(events, 0);
|
||||
expect(notDelayed).toBe(0);
|
||||
const delayedEvent = getDelayTo(events, 2);
|
||||
expect(delayedEvent).toBe(delayDuration);
|
||||
});
|
||||
it('handles list without delays', () => {
|
||||
const events = [{ type: 'event' }, { type: 'event' }];
|
||||
const notDelayed = getDelayTo(events, 1);
|
||||
expect(notDelayed).toBe(0);
|
||||
});
|
||||
|
||||
it('handles list with multiple delays', () => {
|
||||
const delayDuration = 100;
|
||||
const events = [
|
||||
{ type: 'event' },
|
||||
{ type: 'delay', duration: delayDuration },
|
||||
{ type: 'event' },
|
||||
{ type: 'delay', duration: delayDuration },
|
||||
{ type: 'event' },
|
||||
];
|
||||
const doubleDelay = getDelayTo(events, 4);
|
||||
expect(doubleDelay).toBe(delayDuration * 2);
|
||||
});
|
||||
it('handles list with blocks', () => {
|
||||
const events = [
|
||||
{ type: 'event' },
|
||||
{ type: 'delay', duration: 100 },
|
||||
{ type: 'event' },
|
||||
{ type: 'block' },
|
||||
{ type: 'event' },
|
||||
];
|
||||
const notDelayed = getDelayTo(events, 4);
|
||||
expect(notDelayed).toBe(0);
|
||||
});
|
||||
it('handles index greater than list', () => {
|
||||
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
|
||||
const notDelayed = getDelayTo(events, 3);
|
||||
expect(notDelayed).toBe(0);
|
||||
});
|
||||
it('handles negative index (not found)', () => {
|
||||
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
|
||||
const notDelayed = getDelayTo(events, -1);
|
||||
expect(notDelayed).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { calculateDuration, DAY_TO_MS } from '../timesManager';
|
||||
|
||||
describe('calculateDuration()', () => {
|
||||
describe('Given start and end values', () => {
|
||||
it('calculates duration correctly', () => {
|
||||
const testStart = 1;
|
||||
const testEnd = 2;
|
||||
const val = calculateDuration(testStart, testEnd);
|
||||
expect(val).toBe(testEnd - testStart);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Handles edge cases', () => {
|
||||
it('when start is after end', () => {
|
||||
const testStart = 3;
|
||||
const testEnd = 2;
|
||||
const val = calculateDuration(testStart, testEnd);
|
||||
expect(val).toBe(testEnd + DAY_TO_MS - testStart);
|
||||
});
|
||||
it('when both are equal', () => {
|
||||
const testStart = 1;
|
||||
const testEnd = 1;
|
||||
const val = calculateDuration(testStart, testEnd);
|
||||
expect(val).toBe(testEnd - testStart);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { Location, resolvePath } from 'react-router-dom';
|
||||
import { Alias } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Validates an alias against defined parameters
|
||||
* @param {string} alias
|
||||
* @returns {{message: string, status: boolean}}
|
||||
*/
|
||||
export const validateAlias = (alias: string) => {
|
||||
|
||||
const valid = { status: true, message: 'ok' };
|
||||
|
||||
if (alias === '' || alias == null) {
|
||||
@@ -26,4 +29,49 @@ export const validateAlias = (alias: string) => {
|
||||
}
|
||||
|
||||
return valid;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the URL to send an alias to
|
||||
* @param location
|
||||
* @param data
|
||||
* @param searchParams
|
||||
*/
|
||||
export const getAliasRoute = (location: Location, data: Alias[], searchParams: URLSearchParams) => {
|
||||
const currentURL = location.pathname.substring(1);
|
||||
// we need to check if the whole url here is an alias, so we can redirect
|
||||
const foundAlias = data.filter((d) => d.alias === currentURL && d.enabled)[0];
|
||||
if (foundAlias) {
|
||||
return generateURLFromAlias(foundAlias);
|
||||
}
|
||||
const aliasOnPage = searchParams.get('alias');
|
||||
for (const d of data) {
|
||||
if (aliasOnPage) {
|
||||
// if the alias fits the alias on this page, but the URL is different, we redirect user to the new URL
|
||||
// if we have the same alias and its enabled and its not empty
|
||||
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
|
||||
const newAliasPath = resolvePath(d.pathAndParams);
|
||||
const urlParams = new URLSearchParams(newAliasPath.search);
|
||||
urlParams.set('alias', d.alias);
|
||||
// we confirm either the url parameters does not match or the url path doesnt
|
||||
if (!isEqual(urlParams, searchParams) || newAliasPath.pathname !== location.pathname) {
|
||||
// we then redirect to the alias route, since the view listening to this alias has an outdated URL
|
||||
return `${newAliasPath.pathname}?${urlParams}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate URL from an alias
|
||||
* @param aliasData
|
||||
*/
|
||||
export const generateURLFromAlias = (aliasData: Alias) => {
|
||||
const newAliasPath = resolvePath(aliasData.pathAndParams);
|
||||
const urlParams = new URLSearchParams(newAliasPath.search);
|
||||
urlParams.set('alias', aliasData.alias);
|
||||
|
||||
return `${newAliasPath.pathname}?${urlParams}`;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// we need to this as a promise because safari
|
||||
export default async function copyToClipboard(text: string) {
|
||||
setTimeout(async () => await navigator.clipboard.writeText(text));
|
||||
setTimeout(async () => await navigator.clipboard?.writeText(text));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ import { mth, mtm, mts } from './timeConstants';
|
||||
export const timeFormat = 'HH:mm';
|
||||
export const timeFormatSeconds = 'HH:mm:ss';
|
||||
|
||||
export function secondsInMillis(millis: number | null) {
|
||||
if (!millis) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor((millis % mtm) / mts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Converts milliseconds to seconds
|
||||
* @param {number | null} millis - time in seconds
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function debounce(callback: () => void, wait: number) {
|
||||
let timeout: NodeJS.Timeout | null;
|
||||
return () => {
|
||||
if (timeout) {
|
||||
return;
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
callback();
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function isMacOS() {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
return userAgent.includes('macintosh') || userAgent.includes('mac os');
|
||||
}
|
||||
|
||||
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
|
||||
@@ -1,165 +1,32 @@
|
||||
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
|
||||
|
||||
import { formatTime } from './time';
|
||||
|
||||
/**
|
||||
* @description From a list of events, returns only events of type event with calculated delays
|
||||
* @param {Object[]} rundown - given rundown
|
||||
* @returns {Object[]} Filtered events with calculated delays
|
||||
*/
|
||||
|
||||
export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[] => {
|
||||
if (rundown == null) return [];
|
||||
|
||||
const delayedEvents: OntimeEvent[] = [];
|
||||
|
||||
// Add running delay
|
||||
let delay = 0;
|
||||
for (const event of rundown) {
|
||||
if (event.type === SupportedEvent.Block) delay = 0;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return delayedEvents;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns trimmed event list array
|
||||
* @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 trimRundown = (rundown: OntimeRundownEntry[], selectedId: string, limit: number) => {
|
||||
if (rundown == null) return [];
|
||||
|
||||
const BEFORE = 2;
|
||||
const trimmedRundown = [...rundown];
|
||||
|
||||
// limit events length if necessary
|
||||
if (limit != null) {
|
||||
while (trimmedRundown.length > limit) {
|
||||
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
|
||||
if (idx <= BEFORE) {
|
||||
trimmedRundown.pop();
|
||||
} else {
|
||||
trimmedRundown.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
return trimmedRundown;
|
||||
};
|
||||
|
||||
type FormatEventListOptionsProp = {
|
||||
showEnd?: boolean;
|
||||
};
|
||||
/**
|
||||
* @description Returns list of events formatted to be displayed
|
||||
* @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 = (
|
||||
rundown: OntimeEvent[],
|
||||
selectedId: string,
|
||||
nextId: string,
|
||||
options: FormatEventListOptionsProp,
|
||||
) => {
|
||||
if (rundown == null) return [];
|
||||
const { showEnd = false } = options;
|
||||
|
||||
const givenEvents = [...rundown];
|
||||
|
||||
// format list
|
||||
const formattedEvents = [];
|
||||
for (const event of givenEvents) {
|
||||
const start = formatTime(event.timeStart);
|
||||
const end = formatTime(event.timeEnd);
|
||||
|
||||
formattedEvents.push({
|
||||
id: event.id,
|
||||
time: showEnd ? `${start} - ${end}` : start,
|
||||
title: event.title,
|
||||
isNow: event.id === selectedId,
|
||||
isNext: event.id === nextId,
|
||||
colour: event.colour,
|
||||
});
|
||||
}
|
||||
|
||||
return formattedEvents;
|
||||
};
|
||||
import { OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* @description Creates a safe duplicate of an event
|
||||
* @param {object} event
|
||||
* @return {object} clean event
|
||||
* @param {OntimeEvent} event
|
||||
* @param {string} [after]
|
||||
* @return {OntimeEvent} clean event
|
||||
*/
|
||||
type ClonedEvent = OntimeEvent | { after?: string };
|
||||
type ClonedEvent = Omit<
|
||||
OntimeEvent,
|
||||
'id' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
|
||||
>;
|
||||
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
||||
return {
|
||||
type: SupportedEvent.Event,
|
||||
title: event.title,
|
||||
cue: event.cue,
|
||||
subtitle: event.subtitle,
|
||||
presenter: event.presenter,
|
||||
note: event.note,
|
||||
timeStart: event.timeStart,
|
||||
duration: event.duration,
|
||||
timeEnd: event.timeEnd,
|
||||
timerType: event.timerType,
|
||||
endAction: event.endAction,
|
||||
isPublic: event.isPublic,
|
||||
skip: event.skip,
|
||||
colour: event.colour,
|
||||
after: after,
|
||||
revision: 0,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
|
||||
type FileOptions = {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type BlobOptions = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
|
||||
const response = await axios({
|
||||
url: `${url}/db`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const headerLine = response.headers['Content-Disposition'];
|
||||
let { name: fileName } = fileOptions;
|
||||
const { type: fileType } = fileOptions;
|
||||
const { project, rundown, userFields } = response.data;
|
||||
|
||||
// try and get the filename from the response
|
||||
if (headerLine != null) {
|
||||
const startFileNameIndex = headerLine.indexOf('"') + 1;
|
||||
const endFileNameIndex = headerLine.lastIndexOf('"');
|
||||
fileName = headerLine.substring(startFileNameIndex, endFileNameIndex);
|
||||
}
|
||||
|
||||
let fileContent = '';
|
||||
|
||||
if (fileType === 'json') {
|
||||
fileContent = JSON.stringify(response.data);
|
||||
fileName += '.json';
|
||||
}
|
||||
|
||||
if (fileType === 'csv') {
|
||||
const sheetData = makeTable(project, rundown, userFields);
|
||||
fileContent = makeCSV(sheetData);
|
||||
fileName += '.csv';
|
||||
}
|
||||
|
||||
const blob = new Blob([fileContent], { type: blobOptions.type });
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', downloadUrl);
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
// Clean up the URL.createObjectURL to release resources
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
return;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* @description calculates delay to a given event
|
||||
* @param {array} events
|
||||
* @param {number} eventIndex
|
||||
* @return {number} - delay value of given event
|
||||
*/
|
||||
export default function getDelayTo(events, eventIndex) {
|
||||
let delay = 0;
|
||||
let index = 0;
|
||||
if (eventIndex >= 0) {
|
||||
for (const event of events) {
|
||||
if (eventIndex === index) {
|
||||
return delay;
|
||||
}
|
||||
|
||||
if (event.type === 'delay') {
|
||||
delay += event.duration;
|
||||
} else if (event.type === 'block') {
|
||||
delay = 0;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
import { Log, RuntimeStore } from 'ontime-types';
|
||||
|
||||
import { RUNTIME, websocketUrl } from '../api/apiConstants';
|
||||
import { isProduction, RUNTIME, websocketUrl } from '../api/apiConstants';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
import { socketClientName } from '../stores/connectionName';
|
||||
import { addLog } from '../stores/logger';
|
||||
import { runtime } from '../stores/runtime';
|
||||
|
||||
export let websocket: WebSocket | null = null;
|
||||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
const reconnectInterval = 1000;
|
||||
let shouldReconnect = true;
|
||||
|
||||
export const connectSocket = () => {
|
||||
export let shouldReconnect = true;
|
||||
export let hasConnected = false;
|
||||
export let reconnectAttempts = 0;
|
||||
export const connectSocket = (preferredClientName?: string) => {
|
||||
websocket = new WebSocket(websocketUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
clearTimeout(reconnectTimeout as NodeJS.Timeout);
|
||||
hasConnected = true;
|
||||
reconnectAttempts = 0;
|
||||
|
||||
if (preferredClientName) {
|
||||
socketSendJson('set-client-name', preferredClientName);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
@@ -23,6 +31,7 @@ export const connectSocket = () => {
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
console.warn('WebSocket: attempting reconnect');
|
||||
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
||||
reconnectAttempts += 1;
|
||||
connectSocket();
|
||||
}
|
||||
}, reconnectInterval);
|
||||
@@ -45,13 +54,17 @@ export const connectSocket = () => {
|
||||
|
||||
// TODO: implement partial store updates
|
||||
switch (type) {
|
||||
case 'client-name': {
|
||||
socketClientName.getState().setName(payload);
|
||||
break;
|
||||
}
|
||||
case 'ontime-log': {
|
||||
addLog(payload as Log);
|
||||
break;
|
||||
}
|
||||
case 'ontime': {
|
||||
runtime.setState(payload as RuntimeStore);
|
||||
if (import.meta.env.DEV) {
|
||||
if (!isProduction) {
|
||||
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
|
||||
}
|
||||
break;
|
||||
@@ -74,18 +87,6 @@ export const connectSocket = () => {
|
||||
runtime.setState(state);
|
||||
break;
|
||||
}
|
||||
case 'ontime-titles': {
|
||||
const state = runtime.getState();
|
||||
state.titles = payload;
|
||||
runtime.setState(state);
|
||||
break;
|
||||
}
|
||||
case 'ontime-titlesPublic': {
|
||||
const state = runtime.getState();
|
||||
state.titlesPublic = payload;
|
||||
runtime.setState(state);
|
||||
break;
|
||||
}
|
||||
case 'ontime-timerMessage': {
|
||||
const state = runtime.getState();
|
||||
state.timerMessage = payload;
|
||||
|
||||
@@ -3,27 +3,27 @@ import Color from 'color';
|
||||
type ColourCombination = {
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Selects text colour to maintain accessible contrast
|
||||
* @param bgColour
|
||||
* @return {{backgroundColor, color: string}}
|
||||
*/
|
||||
export const getAccessibleColour = (bgColour: string): ColourCombination => {
|
||||
export const getAccessibleColour = (bgColour?: string): ColourCombination => {
|
||||
if (bgColour) {
|
||||
try {
|
||||
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
|
||||
return { backgroundColor: bgColour, color: textColor };
|
||||
} catch (error) {
|
||||
console.log(`Unable to parse colour: ${bgColour}`);
|
||||
} catch (_error) {
|
||||
/* we do not handle errors here */
|
||||
}
|
||||
}
|
||||
return { backgroundColor: '#000', color: "#fffffa" };
|
||||
return { backgroundColor: '#000', color: '#fffffa' };
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Creates a list of classnames from array of css module conditions
|
||||
* @param classNames - css modules objects
|
||||
*/
|
||||
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(" ");
|
||||
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
|
||||
|
||||
@@ -51,6 +51,7 @@ export const formatTime = (milliseconds: number | null, options?: FormatOptions,
|
||||
return '...';
|
||||
}
|
||||
const timeFormat = resolver();
|
||||
const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {};
|
||||
const fallback = options?.showSeconds ? 'hh:mm:ss a' : 'hh:mm a';
|
||||
const { showSeconds = false, format: formatString = fallback } = options || {};
|
||||
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
|
||||
};
|
||||
|
||||
@@ -10,15 +10,8 @@ export const mts = 1000;
|
||||
*/
|
||||
export const mtm = 1000 * 60;
|
||||
|
||||
|
||||
/**
|
||||
* millis to hours
|
||||
* @type {number}
|
||||
*/
|
||||
export const mth = 1000 * 60 * 60;
|
||||
|
||||
/**
|
||||
* milliseconds in a day
|
||||
* @type {number}
|
||||
*/
|
||||
export const DAY_TO_MS = 86400000;
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride';
|
||||
|
||||
/**
|
||||
* @description Milliseconds in a day
|
||||
*/
|
||||
export const DAY_TO_MS = 86400000;
|
||||
|
||||
/**
|
||||
* @description calculates duration from given values
|
||||
*/
|
||||
export const calculateDuration = (start: number, end: number): number =>
|
||||
start > end ? end + DAY_TO_MS - start : end - start;
|
||||
|
||||
/**
|
||||
* @description Checks which field the value relates to
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { ComponentType, useEffect } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import useAliases from '../common/hooks-query/useAliases';
|
||||
import { getAliasRoute } from '../common/utils/aliases';
|
||||
|
||||
const withAlias = <P extends object>(Component: ComponentType<P>) => {
|
||||
return (props: Partial<P>) => {
|
||||
const { data } = useAliases();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// navigate if is alias route
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const url = getAliasRoute(location, data, searchParams);
|
||||
// navigate to this route if its not empty
|
||||
if (url) {
|
||||
navigate(url);
|
||||
}
|
||||
}, [data, searchParams, navigate, location]);
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
};
|
||||
|
||||
export default withAlias;
|
||||
@@ -42,7 +42,7 @@ export default function MessageControl() {
|
||||
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
|
||||
/>
|
||||
<div className={style.buttonSection}>
|
||||
<label className={style.label}>Timer messsage blink</label>
|
||||
<label className={style.label}>Timer message blink</label>
|
||||
<label className={style.label}>Blackout timer screens</label>
|
||||
<Button
|
||||
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
|
||||
|
||||
@@ -5,8 +5,9 @@ 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 { IoTime } from '@react-icons/all-files/io5/IoTime';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { validatePlayback } from 'ontime-utils';
|
||||
|
||||
import { setPlayback } from '../../../../common/hooks/useSocket';
|
||||
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
||||
@@ -34,7 +35,13 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
const noEvents = numEvents === 0;
|
||||
|
||||
const disableGo = isRolling || noEvents || (isLast && !isArmed);
|
||||
const disablePrev = isRolling || noEvents || isFirst;
|
||||
const disablePrev = noEvents || isFirst;
|
||||
|
||||
const playbackCan = validatePlayback(playback);
|
||||
const disableStart = !playbackCan.start;
|
||||
const disablePause = !playbackCan.pause;
|
||||
const disableRoll = !playbackCan.roll || noEvents;
|
||||
const disableStop = !playbackCan.stop;
|
||||
|
||||
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next';
|
||||
const goModeAction = () => {
|
||||
@@ -51,21 +58,11 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
{goModeText}
|
||||
</TapButton>
|
||||
<div className={style.playbackContainer}>
|
||||
<TapButton
|
||||
onClick={setPlayback.start}
|
||||
disabled={isStopped || isRolling}
|
||||
theme={Playback.Play}
|
||||
active={isPlaying}
|
||||
>
|
||||
<TapButton onClick={setPlayback.start} disabled={disableStart} theme={Playback.Play} active={isPlaying}>
|
||||
<IoPlay />
|
||||
</TapButton>
|
||||
|
||||
<TapButton
|
||||
onClick={setPlayback.pause}
|
||||
disabled={isStopped || isRolling || isArmed}
|
||||
theme={Playback.Pause}
|
||||
active={isPaused}
|
||||
>
|
||||
<TapButton onClick={setPlayback.pause} disabled={disablePause} theme={Playback.Pause} active={isPaused}>
|
||||
<IoPause />
|
||||
</TapButton>
|
||||
</div>
|
||||
@@ -82,21 +79,16 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={style.extra}>
|
||||
<TapButton
|
||||
onClick={setPlayback.roll}
|
||||
disabled={!isStopped || noEvents}
|
||||
theme={Playback.Roll}
|
||||
active={isRolling}
|
||||
>
|
||||
<IoTimeOutline />
|
||||
<TapButton onClick={setPlayback.roll} disabled={disableRoll} theme={Playback.Roll} active={isRolling}>
|
||||
<IoTime />
|
||||
</TapButton>
|
||||
<Tooltip label='Reload event' openDelay={tooltipDelayMid}>
|
||||
<TapButton onClick={setPlayback.reload} disabled={isStopped || isRolling}>
|
||||
<TapButton onClick={setPlayback.reload} disabled={isStopped}>
|
||||
<IoReload className={style.invertX} />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
<Tooltip label='Unload Event' openDelay={tooltipDelayMid}>
|
||||
<TapButton onClick={setPlayback.stop} disabled={isStopped && !isRolling} theme={Playback.Stop}>
|
||||
<TapButton onClick={setPlayback.stop} disabled={disableStop} theme={Playback.Stop}>
|
||||
<IoStop />
|
||||
</TapButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
@use '../../theme/v2Styles' as *;
|
||||
|
||||
$table-font-size: calc(1rem - 2px);
|
||||
$table-header-font-size: calc(1rem - 3px);
|
||||
|
||||
@mixin ellipsis-overflow() {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cuesheetContainer {
|
||||
grid-area: table;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding-bottom: 640px; // allow focus to reach last elements
|
||||
}
|
||||
|
||||
.cuesheet {
|
||||
font-size: $table-font-size;
|
||||
font-weight: 400;
|
||||
|
||||
tr {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
th, td {
|
||||
margin: 1px;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
@include ellipsis-overflow;
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeader,
|
||||
.eventRow {
|
||||
.indexColumn {
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
font-weight: 400;
|
||||
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background-color: $gray-1300;
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: $gray-1300;
|
||||
|
||||
font-size: $table-header-font-size;
|
||||
color: $gray-700;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.eventRow {
|
||||
vertical-align: top;
|
||||
|
||||
&:hover {
|
||||
outline: 1px solid $blue-700;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
td {
|
||||
background-color: $gray-1250;
|
||||
border-radius: 2px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
&.skip {
|
||||
text-decoration: line-through;
|
||||
opacity: $opacity-disabled !important; // fighting inline styles
|
||||
}
|
||||
}
|
||||
|
||||
.blockRow {
|
||||
width: 100%;
|
||||
background-color: $gray-1350;
|
||||
font-size: 1rem;
|
||||
height: 2.5rem;
|
||||
|
||||
td {
|
||||
align-self: flex-end;
|
||||
position: sticky;
|
||||
left: 1rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.delayRow {
|
||||
width: 100%;
|
||||
color: $ontime-delay-text;
|
||||
|
||||
td {
|
||||
position: sticky;
|
||||
left: 47.5%; // center of the screen, ish
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.check {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
> * {
|
||||
@include ellipsis-overflow;
|
||||
}
|
||||
}
|
||||
|
||||
.delayedTime {
|
||||
color: $ontime-delay-text;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
.resizer {
|
||||
cursor: col-resize;
|
||||
opacity: 0;
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translateX(50%);
|
||||
background-color: $action-blue;
|
||||
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
|
||||
transition-duration: $transition-time-action;
|
||||
transition-property: width, background-color;
|
||||
|
||||
&:hover {
|
||||
opacity: $opacity-disabled;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&.isResizing {
|
||||
opacity: 1;
|
||||
width: 6px;
|
||||
background-color: $action-blue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useRef } from 'react';
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import Color from 'color';
|
||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
|
||||
|
||||
import BlockRow from './cuesheet-table-elements/BlockRow';
|
||||
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
||||
import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||
import EventRow from './cuesheet-table-elements/EventRow';
|
||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
import { initialColumnOrder } from './cuesheetCols';
|
||||
|
||||
import style from './Cuesheet.module.scss';
|
||||
|
||||
interface CuesheetProps {
|
||||
data: OntimeRundown;
|
||||
columns: ColumnDef<OntimeRundownEntry>[];
|
||||
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
|
||||
selectedId: string | null;
|
||||
}
|
||||
|
||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
|
||||
const followSelected = useCuesheetSettings((state) => state.followSelected);
|
||||
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
||||
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
|
||||
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage('table-hidden', {});
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
|
||||
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
|
||||
|
||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
columnResizeMode: 'onChange',
|
||||
state: {
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
},
|
||||
meta: {
|
||||
handleUpdate,
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const resetColumnOrder = () => {
|
||||
saveColumnOrder(initialColumnOrder);
|
||||
};
|
||||
|
||||
const setAllVisible = () => {
|
||||
table.toggleAllColumnsVisible(true);
|
||||
};
|
||||
|
||||
const resetColumnResizing = () => {
|
||||
setColumnSizing({});
|
||||
};
|
||||
|
||||
const headerGroups = table.getHeaderGroups;
|
||||
|
||||
let eventIndex = 0;
|
||||
let isPast = Boolean(selectedId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSettings && (
|
||||
<CuesheetTableSettings
|
||||
columns={table.getAllLeafColumns()}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
)}
|
||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||
<table className={style.cuesheet}>
|
||||
<CuesheetHeader headerGroups={headerGroups} />
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const key = row.original.id;
|
||||
const isSelected = selectedId === key;
|
||||
if (isSelected) {
|
||||
isPast = false;
|
||||
}
|
||||
|
||||
if (isOntimeBlock(row.original)) {
|
||||
return <BlockRow key={key} title={row.original.title} />;
|
||||
}
|
||||
if (isOntimeDelay(row.original)) {
|
||||
const delayVal = row.original.duration;
|
||||
|
||||
if (!showDelayBlock || delayVal === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <DelayRow key={key} duration={delayVal} />;
|
||||
}
|
||||
if (isOntimeEvent(row.original)) {
|
||||
eventIndex++;
|
||||
const isSelected = key === selectedId;
|
||||
if (isSelected) {
|
||||
isPast = false;
|
||||
}
|
||||
|
||||
if (isPast && !showPrevious) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (isSelected) {
|
||||
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700
|
||||
} else if (row.original.colour) {
|
||||
try {
|
||||
// the colour is user defined and might be invalid
|
||||
const colour = new Color(row.original.colour).alpha(0.25);
|
||||
rowBgColour = colour.hsl().string();
|
||||
} catch (_error) {
|
||||
/* we do not handle errors here */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EventRow
|
||||
key={key}
|
||||
eventIndex={eventIndex}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
skip={row.original.skip}
|
||||
colour={row.original.colour}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</EventRow>
|
||||
);
|
||||
}
|
||||
|
||||
// currently there is no scenario where entryType is not handled above, either way...
|
||||
return null;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
@use '../../theme/v2Styles' as *;
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
|
||||
.tableWrapper {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 1rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
grid-template-areas:
|
||||
'header'
|
||||
'settings'
|
||||
'table';
|
||||
gap: 1rem;
|
||||
|
||||
background-color: $gray-1300;
|
||||
color: white;
|
||||
|
||||
& > * {
|
||||
border: 1px solid $white-10;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
|
||||
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { useCuesheet } from '../../common/hooks/useSocket';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
|
||||
|
||||
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
|
||||
import Cuesheet from './Cuesheet';
|
||||
import { makeCuesheetColumns } from './cuesheetCols';
|
||||
import { makeCSV, makeTable } from './cuesheetUtils';
|
||||
|
||||
import styles from './CuesheetWrapper.module.scss';
|
||||
|
||||
export default function CuesheetWrapper() {
|
||||
const { data: rundown } = useRundown();
|
||||
const { data: userFields } = useUserFields();
|
||||
const { updateEvent } = useEventAction();
|
||||
const featureData = useCuesheet();
|
||||
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [headerData, setheaderData] = useState<ProjectData | null>(null);
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime - Cuesheet';
|
||||
}, []);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => {
|
||||
if (!rundown) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowIndex == null || accessor == null || payload == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if value is the same
|
||||
const event = rundown[rowIndex];
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event[accessor] === payload) {
|
||||
return;
|
||||
}
|
||||
// check if value is valid
|
||||
// as of now, the fields do not have any validation
|
||||
if (typeof payload !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
// cleanup
|
||||
const cleanVal = payload.trim();
|
||||
const mutationObject = {
|
||||
id: event.id,
|
||||
[accessor]: cleanVal,
|
||||
};
|
||||
|
||||
// submit
|
||||
try {
|
||||
await updateEvent(mutationObject);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[updateEvent, rundown],
|
||||
);
|
||||
|
||||
const exportHandler = useCallback(
|
||||
(headerData: ProjectData, exportType: ExportType) => {
|
||||
if (!headerData || !rundown || !userFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fileName = '';
|
||||
let url = '';
|
||||
|
||||
if (exportType === 'json') {
|
||||
const jsonContent = JSON.stringify({
|
||||
headerData,
|
||||
rundown,
|
||||
userFields,
|
||||
});
|
||||
|
||||
fileName = 'ontime export.json';
|
||||
|
||||
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
|
||||
url = URL.createObjectURL(blob);
|
||||
} else if (exportType === 'csv') {
|
||||
const sheetData = makeTable(headerData, rundown, userFields);
|
||||
const csvContent = makeCSV(sheetData);
|
||||
|
||||
fileName = 'ontime export.csv';
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
url = URL.createObjectURL(blob);
|
||||
} else {
|
||||
console.error('Invalid export type: ', exportType);
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
// Clean up the URL.createObjectURL to release resources
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
},
|
||||
[rundown, userFields],
|
||||
);
|
||||
|
||||
const onModalClose = (exportType?: ExportType) => {
|
||||
setIsModalOpen(false);
|
||||
|
||||
if (!exportType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (headerData) {
|
||||
exportHandler(headerData, exportType);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (projectData: ProjectData) => {
|
||||
setheaderData(projectData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
if (!rundown || !userFields) {
|
||||
return <Empty text='Loading...' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetTableHeader handleExport={handleOpenModal} featureData={featureData} />
|
||||
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
|
||||
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
|
||||
import CuesheetWrapper from './CuesheetWrapper';
|
||||
|
||||
export default function ProtectedCuesheet() {
|
||||
return (
|
||||
<ProtectRoute permission='operator'>
|
||||
<CuesheetWrapper />
|
||||
</ProtectRoute>
|
||||
);
|
||||
}
|
||||
+12
-2
@@ -6,7 +6,11 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
|
||||
"Ontime · Schedule Template",
|
||||
],
|
||||
[
|
||||
"Event Name",
|
||||
"Project Title",
|
||||
"",
|
||||
],
|
||||
[
|
||||
"Project Description",
|
||||
"",
|
||||
],
|
||||
[
|
||||
@@ -25,8 +29,11 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
|
||||
"Presenter Name",
|
||||
"Event Subtitle",
|
||||
"Is Public? (x)",
|
||||
"Notes",
|
||||
"Note",
|
||||
"Colour",
|
||||
"End Action",
|
||||
"Timer Type",
|
||||
"Skip?",
|
||||
"user0:test",
|
||||
],
|
||||
[
|
||||
@@ -38,6 +45,9 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
|
||||
"x",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"test",
|
||||
"test",
|
||||
"",
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import { makeCSV, makeTable, parseField } from '../tableUtils';
|
||||
import { makeCSV, makeTable, parseField } from '../cuesheetUtils';
|
||||
|
||||
describe('parseField()', () => {
|
||||
it('returns a string from given millis on timeStart and TimeEnd', () => {
|
||||
@@ -26,7 +26,7 @@ describe('parseField()', () => {
|
||||
});
|
||||
|
||||
it('returns an empty string on undefined fields', () => {
|
||||
expect(parseField('presenter', undefined)).toBe('');
|
||||
expect(parseField('presenter')).toBe('');
|
||||
});
|
||||
|
||||
describe('simply returns any other value in any other field', () => {
|
||||
@@ -35,7 +35,7 @@ describe('parseField()', () => {
|
||||
{ field: 'title', value: 'test' },
|
||||
{ field: 'presenter', value: 'test' },
|
||||
{ field: 'subtitle', value: 'test' },
|
||||
{ field: 'notes', value: 'test' },
|
||||
{ field: 'note', value: 'test' },
|
||||
{ field: 'colour', value: 'test' },
|
||||
{ field: 'user0', value: 'test' },
|
||||
{ field: 'user1', value: 'test' },
|
||||
@@ -84,7 +84,7 @@ describe('make CSV()', () => {
|
||||
it('joins an array of arrays with commas and newlines', () => {
|
||||
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
|
||||
expect(makeCSV(testdata)).toMatchInlineSnapshot(`
|
||||
"data:text/csv;charset=utf-8,field
|
||||
"field
|
||||
after newline,after comma
|
||||
,after empty
|
||||
"
|
||||
@@ -0,0 +1,18 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import style from '../Cuesheet.module.scss';
|
||||
|
||||
interface BlockRowProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
function BlockRow(props: BlockRowProps) {
|
||||
const { title } = props;
|
||||
return (
|
||||
<tr className={style.blockRow}>
|
||||
<td>{title}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(BlockRow);
|
||||
@@ -0,0 +1,108 @@
|
||||
import { memo } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { initialColumnOrder } from '../cuesheetCols';
|
||||
|
||||
import { SortableCell } from './SortableCell';
|
||||
|
||||
import style from '../Cuesheet.module.scss';
|
||||
|
||||
interface CuesheetHeaderProps {
|
||||
headerGroups: () => HeaderGroup<OntimeRundownEntry>[];
|
||||
}
|
||||
|
||||
function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
const { headerGroups } = props;
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
|
||||
|
||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||
const { delta, active, over } = event;
|
||||
|
||||
// cancel if delta y is greater than 200
|
||||
if (delta.y > 200) return;
|
||||
// cancel if we do not have an over id
|
||||
if (over?.id == null) return;
|
||||
|
||||
// get index of from
|
||||
const fromIndex = columnOrder.indexOf(active.id as string);
|
||||
|
||||
// get index of to
|
||||
const toIndex = columnOrder.indexOf(over.id as string);
|
||||
|
||||
if (toIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reorderedCols = [...columnOrder];
|
||||
const reorderedItem = reorderedCols.splice(fromIndex, 1);
|
||||
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
|
||||
|
||||
saveColumnOrder(reorderedCols);
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
delay: 100,
|
||||
tolerance: 50,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 100,
|
||||
tolerance: 50,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<thead className={style.tableHeader}>
|
||||
{headerGroups().map((headerGroup) => {
|
||||
const key = headerGroup.id;
|
||||
|
||||
return (
|
||||
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
|
||||
<tr key={headerGroup.id}>
|
||||
<th className={style.indexColumn}>
|
||||
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
|
||||
#
|
||||
</Tooltip>
|
||||
</th>
|
||||
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const width = header.getSize();
|
||||
|
||||
return (
|
||||
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</SortableCell>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
</DndContext>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CuesheetHeader);
|
||||
@@ -0,0 +1,6 @@
|
||||
interface CuesheetRowProps {
|
||||
row: OntimeRundownEntry;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
function CuesheetRow() {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { millisToDelayString } from '../../../common/utils/dateConfig';
|
||||
|
||||
import style from '../Cuesheet.module.scss';
|
||||
|
||||
interface DelayRowProps {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function DelayRow(props: DelayRowProps) {
|
||||
const { duration } = props;
|
||||
const delayTime = millisToDelayString(duration);
|
||||
|
||||
return (
|
||||
<tr className={style.delayRow}>
|
||||
<td>{delayTime}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DelayRow);
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ChangeEvent, memo, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
|
||||
|
||||
interface EditableCellProps {
|
||||
value: string;
|
||||
handleUpdate: (newValue: string) => void;
|
||||
}
|
||||
|
||||
const EditableCell = (props: EditableCellProps) => {
|
||||
const { value: initialValue, handleUpdate } = props;
|
||||
|
||||
// We need to keep and update the state of the cell normally
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
const onChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => setValue(event.target.value), []);
|
||||
|
||||
// We'll only update the external data when the input is blurred
|
||||
const onBlur = useCallback(() => handleUpdate(value), [handleUpdate, value]);
|
||||
|
||||
// If the initialValue is changed external, sync it up with our state
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return (
|
||||
<AutoTextArea
|
||||
size='sm'
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
rows={1}
|
||||
transition='none'
|
||||
spellCheck={false}
|
||||
style={{ padding: 0 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(EditableCell);
|
||||
@@ -0,0 +1,66 @@
|
||||
import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from '../Cuesheet.module.scss';
|
||||
|
||||
const pastOpacity = '0.2';
|
||||
|
||||
interface EventRowProps {
|
||||
eventIndex: number;
|
||||
isPast?: boolean;
|
||||
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
|
||||
skip?: boolean;
|
||||
colour?: string;
|
||||
}
|
||||
|
||||
function EventRow(props: PropsWithChildren<EventRowProps>) {
|
||||
const { children, eventIndex, isPast, selectedRef, skip, colour } = props;
|
||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const bgColour = colour;
|
||||
const textColour = getAccessibleColour(bgColour);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
threshold: 0.01,
|
||||
},
|
||||
);
|
||||
|
||||
const handleRefCurrent = ownRef.current;
|
||||
if (selectedRef) {
|
||||
setIsVisible(true);
|
||||
} else if (handleRefCurrent) {
|
||||
observer.observe(handleRefCurrent);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (handleRefCurrent) {
|
||||
observer.unobserve(handleRefCurrent);
|
||||
}
|
||||
};
|
||||
}, [ownRef, selectedRef]);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={`${style.eventRow} ${skip ? style.skip : ''}`}
|
||||
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
|
||||
ref={selectedRef ?? ownRef}
|
||||
>
|
||||
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
|
||||
{eventIndex}
|
||||
</td>
|
||||
{isVisible ? children : null}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(EventRow);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { CSSProperties, ReactNode } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Header } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import styles from '../Cuesheet.module.scss';
|
||||
|
||||
interface SortableCellProps {
|
||||
header: Header<OntimeRundownEntry, unknown>;
|
||||
style: CSSProperties;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SortableCell({ header, style, children }: SortableCellProps) {
|
||||
const { column, colSpan } = header;
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: column.id,
|
||||
});
|
||||
|
||||
// build drag styles
|
||||
const dragStyle = {
|
||||
...style,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
|
||||
|
||||
return (
|
||||
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
|
||||
<div {...attributes} {...listeners}>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
{...{
|
||||
onMouseDown: header.getResizeHandler(),
|
||||
onTouchStart: header.getResizeHandler(),
|
||||
}}
|
||||
className={resizerClasses}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
$label-colour: $gray-700;
|
||||
$active-colour: $gray-500;
|
||||
|
||||
@mixin label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-colour;
|
||||
text-align: center;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
@mixin time {
|
||||
font-family: "Open Sans Light", $ontime-font-family;
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
padding: 0.25rem 1rem;
|
||||
height: max-content;
|
||||
column-gap: 2rem;
|
||||
|
||||
grid-template-areas:
|
||||
'event playback timer clock actions';
|
||||
grid-template-columns: 1fr auto auto auto auto;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.event {
|
||||
grid-area: event;
|
||||
justify-self: start;
|
||||
|
||||
.title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.eventNow {
|
||||
justify-self: start;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.playback {
|
||||
grid-area: playback;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
|
||||
.playbackLabel {
|
||||
@include label;
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 2rem;
|
||||
height: 3rem;
|
||||
color: $label-colour;
|
||||
}
|
||||
}
|
||||
|
||||
.timer {
|
||||
grid-area: timer;
|
||||
|
||||
.timerLabel {
|
||||
@include label;
|
||||
}
|
||||
|
||||
.value {
|
||||
@include time;
|
||||
}
|
||||
}
|
||||
|
||||
.clock {
|
||||
grid-area: clock;
|
||||
|
||||
.clockLabel {
|
||||
@include label;
|
||||
}
|
||||
|
||||
.value {
|
||||
grid-area: clock;
|
||||
@include time;
|
||||
}
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
color: $label-colour;
|
||||
height: 100%;
|
||||
|
||||
.actionIcon {
|
||||
cursor: pointer;
|
||||
|
||||
&.enabled {
|
||||
color: $active-indicator;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $active-colour;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
// in large screens we want to space the buttons
|
||||
.headerActions {
|
||||
padding-left: 10vw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
import { Playback, ProjectData } from 'ontime-types';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
import useFullscreen from '../../../common/hooks/useFullscreen';
|
||||
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
||||
|
||||
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
|
||||
|
||||
import style from './CuesheetTableHeader.module.scss';
|
||||
|
||||
interface CuesheetTableHeaderProps {
|
||||
handleExport: (headerData: ProjectData) => void;
|
||||
featureData: {
|
||||
playback: Playback;
|
||||
selectedEventIndex: number | null;
|
||||
numEvents: number;
|
||||
titleNow: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
|
||||
const followSelected = useCuesheetSettings((state) => state.followSelected);
|
||||
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
||||
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
|
||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||
const { data: project } = useProjectData();
|
||||
|
||||
const exportProject = () => {
|
||||
if (project) {
|
||||
handleExport(project);
|
||||
}
|
||||
};
|
||||
|
||||
const selected = !featureData.numEvents
|
||||
? 'No events'
|
||||
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : '-'}/${
|
||||
featureData.numEvents ? featureData.numEvents : '-'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={style.header}>
|
||||
<div className={style.event}>
|
||||
<div className={style.title}>{project?.title || '-'}</div>
|
||||
<div className={style.eventNow}>{featureData?.titleNow || '-'}</div>
|
||||
</div>
|
||||
<div className={style.playback}>
|
||||
<div className={style.playbackLabel}>{selected}</div>
|
||||
<PlaybackIcon state={featureData.playback} />
|
||||
</div>
|
||||
<CuesheetTableHeaderTimers />
|
||||
<div className={style.headerActions}>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
|
||||
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
|
||||
<IoLocate />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle settings'>
|
||||
<span onClick={() => toggleSettings()} className={`${style.actionIcon} ${showSettings ? style.enabled : ''}`}>
|
||||
<IoSettingsOutline />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
|
||||
<span onClick={() => toggleFullScreen()} className={style.actionIcon}>
|
||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
|
||||
<span className={style.actionIcon} onClick={exportProject}>
|
||||
Export
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { formatDisplay } from 'ontime-utils';
|
||||
|
||||
import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
import style from './CuesheetTableHeader.module.scss';
|
||||
|
||||
export default function CuesheetTableHeaderTimers() {
|
||||
const timer = useTimer();
|
||||
|
||||
// prepare presentation variables
|
||||
const isOvertime = (timer.current ?? 0) < 0;
|
||||
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
|
||||
const timeNow = formatTime(timer.clock, {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss a',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={style.timer}>
|
||||
<div className={style.timerLabel}>Running Timer</div>
|
||||
<div className={style.value}>{timerNow}</div>
|
||||
</div>
|
||||
<div className={style.clock}>
|
||||
<div className={style.clockLabel}>Time Now</div>
|
||||
<div className={style.value}>{timeNow}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
.tableSettings {
|
||||
grid-area: settings;
|
||||
padding: 1rem;
|
||||
background-color: $ui-black;
|
||||
display: flex;
|
||||
font-size: $inner-section-text-size;
|
||||
}
|
||||
|
||||
.leftPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
color: $gray-700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: 1rem;
|
||||
row-gap: 0.25em;
|
||||
}
|
||||
|
||||
.option {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rightPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
padding-left: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { memo } from 'react';
|
||||
import { Button, Checkbox, Switch } from '@chakra-ui/react';
|
||||
import { Column } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
||||
|
||||
import style from './CuesheetTableSettings.module.scss';
|
||||
|
||||
// reusable button styles
|
||||
const buttonProps = {
|
||||
size: 'sm',
|
||||
variant: 'ontime-subtle',
|
||||
};
|
||||
|
||||
interface CuesheetTableSettingsProps {
|
||||
columns: Column<OntimeRundownEntry, unknown>[];
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
}
|
||||
|
||||
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
||||
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
||||
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
|
||||
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
|
||||
const toggleDelayVisibility = useCuesheetSettings((state) => state.toggleDelayVisibility);
|
||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
||||
const toggleDelayedTimes = useCuesheetSettings((state) => state.toggleDelayedTimes);
|
||||
|
||||
return (
|
||||
<div className={style.tableSettings}>
|
||||
<div className={style.leftPanel}>
|
||||
<div className={style.sectionTitle}>Toggle column visibility</div>
|
||||
<div className={style.options}>
|
||||
{columns.map((column) => {
|
||||
const columnHeader = column.columnDef.header;
|
||||
const visible = column.getIsVisible();
|
||||
return (
|
||||
<label key={`${column.id}-${visible}`} className={style.option}>
|
||||
<Checkbox
|
||||
variant='ontime-ondark'
|
||||
defaultChecked={visible}
|
||||
onChange={column.getToggleVisibilityHandler()}
|
||||
/>
|
||||
{columnHeader}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={style.sectionTitle}>Table Options</div>
|
||||
<div className={style.options}>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
|
||||
Show past events
|
||||
</label>
|
||||
</div>
|
||||
<div className={style.sectionTitle}>Delay Flow</div>
|
||||
<div className={style.options}>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={showDelayedTimes} onChange={() => toggleDelayedTimes()} />
|
||||
Show delayed times
|
||||
</label>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
|
||||
Show delay blocks
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.rightPanel}>
|
||||
<Button onClick={handleClearToggles} {...buttonProps}>
|
||||
Show All
|
||||
</Button>
|
||||
<Button onClick={handleResetResizing} {...buttonProps}>
|
||||
Reset Resizing
|
||||
</Button>
|
||||
<Button onClick={handleResetReordering} {...buttonProps}>
|
||||
Reset Reordering
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CuesheetTableSettings);
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback } from 'react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
||||
|
||||
import EditableCell from './cuesheet-table-elements/EditableCell';
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
|
||||
import style from './Cuesheet.module.scss';
|
||||
|
||||
function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const cellValue = row.getValue();
|
||||
return cellValue ? <IoCheckmark className={style.check} /> : '';
|
||||
}
|
||||
|
||||
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
||||
const cellValue = (getValue() as number | null) ?? 0;
|
||||
const delayValue = (original as OntimeEvent)?.delay ?? 0;
|
||||
|
||||
return (
|
||||
<span className={style.time}>
|
||||
<DelayIndicator delayValue={delayValue} />
|
||||
{millisToString(cellValue)}
|
||||
{delayValue !== 0 && showDelayedTimes && (
|
||||
<span className={style.delayedTime}>{` ${millisToString(cellValue + delayValue)}`}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MakeUserField({ getValue, row: { index }, column: { id }, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
// @ts-expect-error -- we inject this into react-table
|
||||
table.options.meta?.handleUpdate(index, id, newValue);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||
[id, index],
|
||||
);
|
||||
|
||||
const initialValue = getValue() as string;
|
||||
|
||||
return <EditableCell value={initialValue} handleUpdate={update} />;
|
||||
}
|
||||
|
||||
export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'cue',
|
||||
id: 'cue',
|
||||
header: 'Cue',
|
||||
cell: (row) => row.getValue(),
|
||||
size: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'isPublic',
|
||||
id: 'isPublic',
|
||||
header: 'Public',
|
||||
cell: makePublic,
|
||||
size: 45,
|
||||
},
|
||||
{
|
||||
accessorKey: 'timeStart',
|
||||
id: 'timeStart',
|
||||
header: 'Start',
|
||||
cell: MakeTimer,
|
||||
size: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'timeEnd',
|
||||
id: 'timeEnd',
|
||||
header: 'End',
|
||||
cell: MakeTimer,
|
||||
size: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'duration',
|
||||
id: 'duration',
|
||||
header: 'Duration',
|
||||
cell: (row) => millisToString(row.getValue() as number | null),
|
||||
size: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
id: 'title',
|
||||
header: 'Title',
|
||||
cell: (row) => row.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'subtitle',
|
||||
id: 'subtitle',
|
||||
header: 'Subtitle',
|
||||
cell: (row) => row.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'presenter',
|
||||
id: 'presenter',
|
||||
header: 'Presenter',
|
||||
cell: (row) => row.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'note',
|
||||
id: 'note',
|
||||
header: 'Note',
|
||||
cell: (row) => row.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'user0',
|
||||
id: 'user0',
|
||||
header: userFields?.user0 || 'User 0',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user1',
|
||||
id: 'user1',
|
||||
header: userFields?.user1 || 'User 1',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user2',
|
||||
id: 'user2',
|
||||
header: userFields?.user2 || 'User 2',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user3',
|
||||
id: 'user3',
|
||||
header: userFields?.user3 || 'User 3',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user4',
|
||||
id: 'user4',
|
||||
header: userFields?.user4 || 'User 4',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user5',
|
||||
id: 'user5',
|
||||
header: userFields?.user5 || 'User 5',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user6',
|
||||
id: 'user6',
|
||||
header: userFields?.user6 || 'User 6',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user7',
|
||||
id: 'user7',
|
||||
header: userFields?.user7 || 'User 7',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user8',
|
||||
id: 'user8',
|
||||
header: userFields?.user8 || 'User 8',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user9',
|
||||
id: 'user9',
|
||||
header: userFields?.user9 || 'User 9',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const initialColumnOrder: string[] = makeCuesheetColumns().map((column) => column.id as string);
|
||||
+24
-15
@@ -1,4 +1,5 @@
|
||||
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||
import { OntimeEntryCommonKeys, OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
@@ -8,14 +9,15 @@ import { millisToString } from 'ontime-utils';
|
||||
* @return {string}
|
||||
*/
|
||||
|
||||
export const parseField = (field, data) => {
|
||||
export const parseField = (field: keyof OntimeRundown, data: unknown): string => {
|
||||
let val;
|
||||
switch (field) {
|
||||
case 'timeStart':
|
||||
case 'timeEnd':
|
||||
val = millisToString(data);
|
||||
val = millisToString(data as number | null);
|
||||
break;
|
||||
case 'isPublic':
|
||||
case 'skip':
|
||||
val = data ? 'x' : '';
|
||||
break;
|
||||
default:
|
||||
@@ -25,34 +27,39 @@ export const parseField = (field, data) => {
|
||||
if (typeof data === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
return val;
|
||||
// all other values are strings
|
||||
return val as string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Creates an array of arrays usable by xlsx for export
|
||||
* @param {object} headerData
|
||||
* @param {array} tableData
|
||||
* @param {array} rundown
|
||||
* @param {object} userFields
|
||||
* @return {(string[])[]}
|
||||
*/
|
||||
export const makeTable = (headerData, tableData, userFields) => {
|
||||
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
|
||||
const data = [
|
||||
['Ontime · Schedule Template'],
|
||||
['Event Name', headerData?.title || ''],
|
||||
['Project Title', headerData?.title || ''],
|
||||
['Project Description', headerData?.description || ''],
|
||||
['Public URL', headerData?.publicUrl || ''],
|
||||
['Backstage URL', headerData?.backstageUrl || ''],
|
||||
[],
|
||||
];
|
||||
|
||||
const fieldOrder = [
|
||||
const fieldOrder: OntimeEntryCommonKeys[] = [
|
||||
'timeStart',
|
||||
'timeEnd',
|
||||
'title',
|
||||
'presenter',
|
||||
'subtitle',
|
||||
'isPublic',
|
||||
'notes',
|
||||
'note',
|
||||
'colour',
|
||||
'endAction',
|
||||
'timerType',
|
||||
'skip',
|
||||
'user0',
|
||||
'user1',
|
||||
'user2',
|
||||
@@ -72,20 +79,23 @@ export const makeTable = (headerData, tableData, userFields) => {
|
||||
'Presenter Name',
|
||||
'Event Subtitle',
|
||||
'Is Public? (x)',
|
||||
'Notes',
|
||||
'Note',
|
||||
'Colour',
|
||||
'End Action',
|
||||
'Timer Type',
|
||||
'Skip?',
|
||||
];
|
||||
|
||||
for (const field in userFields) {
|
||||
const fieldValue = userFields[field];
|
||||
const fieldValue = userFields[field as keyof UserFields];
|
||||
const displayName = `${field}${fieldValue !== field && fieldValue !== '' ? `:${fieldValue}` : ''}`;
|
||||
fieldTitles.push(displayName);
|
||||
}
|
||||
|
||||
data.push(fieldTitles);
|
||||
|
||||
tableData.forEach((entry) => {
|
||||
const row = [];
|
||||
rundown.forEach((entry) => {
|
||||
const row: string[] = [];
|
||||
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
|
||||
data.push(row);
|
||||
});
|
||||
@@ -98,8 +108,7 @@ export const makeTable = (headerData, tableData, userFields) => {
|
||||
* @param {array[]} arrayOfArrays
|
||||
* @return {string}
|
||||
*/
|
||||
export const makeCSV = (arrayOfArrays) => {
|
||||
let csvData = 'data:text/csv;charset=utf-8,';
|
||||
export const makeCSV = (arrayOfArrays: string[][]) => {
|
||||
const stringifiedData = stringify(arrayOfArrays);
|
||||
return csvData + stringifiedData;
|
||||
return stringifiedData;
|
||||
};
|
||||
+5
-2
@@ -1,8 +1,11 @@
|
||||
import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* @description set default column order
|
||||
*/
|
||||
export const defaultColumnOrder = [
|
||||
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
|
||||
'isPublic',
|
||||
'cue',
|
||||
'timeStart',
|
||||
'timeEnd',
|
||||
'duration',
|
||||
@@ -25,7 +28,7 @@ export const defaultColumnOrder = [
|
||||
/**
|
||||
* @description set default hidden columns
|
||||
*/
|
||||
export const defaultHiddenColumns = [
|
||||
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [
|
||||
'user0',
|
||||
'user1',
|
||||
'user2',
|
||||
@@ -0,0 +1,65 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
|
||||
|
||||
interface CuesheetSettings {
|
||||
showSettings: boolean;
|
||||
followSelected: boolean;
|
||||
showPrevious: boolean;
|
||||
showDelayBlock: boolean;
|
||||
showDelayedTimes: boolean;
|
||||
|
||||
toggleSettings: (newValue?: boolean) => void;
|
||||
toggleFollow: (newValue?: boolean) => void;
|
||||
togglePreviousVisibility: (newValue?: boolean) => void;
|
||||
toggleDelayVisibility: (newValue?: boolean) => void;
|
||||
toggleDelayedTimes: (newValue?: boolean) => void;
|
||||
}
|
||||
|
||||
function toggle(oldValue: boolean, value?: boolean) {
|
||||
if (typeof value === 'undefined') {
|
||||
return !oldValue;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
enum CuesheetKeys {
|
||||
Follow = 'ontime-cuesheet-follow-selected',
|
||||
DelayVisibility = 'ontime-cuesheet-show-delay',
|
||||
PreviousVisibility = 'ontime-cuesheet-show-previous',
|
||||
DelayedTimes = 'ontime-cuesheet-show-delayed',
|
||||
}
|
||||
|
||||
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
||||
showSettings: false,
|
||||
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
|
||||
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
|
||||
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
|
||||
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
|
||||
|
||||
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
|
||||
toggleFollow: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const followSelected = toggle(state.followSelected, newValue);
|
||||
localStorage.setItem(CuesheetKeys.Follow, String(followSelected));
|
||||
return { followSelected };
|
||||
}),
|
||||
togglePreviousVisibility: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const showPrevious = toggle(state.showPrevious, newValue);
|
||||
localStorage.setItem(CuesheetKeys.PreviousVisibility, String(showPrevious));
|
||||
return { showPrevious };
|
||||
}),
|
||||
toggleDelayVisibility: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const showDelayBlock = toggle(state.showDelayBlock, newValue);
|
||||
localStorage.setItem(CuesheetKeys.DelayVisibility, String(showDelayBlock));
|
||||
return { showDelayBlock };
|
||||
}),
|
||||
toggleDelayedTimes: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const showDelayedTimes = toggle(state.showDelayedTimes, newValue);
|
||||
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
|
||||
return { showDelayedTimes };
|
||||
}),
|
||||
}));
|
||||
@@ -144,7 +144,7 @@ $playback-width: 26rem;
|
||||
.eventEditor {
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: $bg-container-l2;
|
||||
box-shadow: rgba(0, 0, 0, 0.35) 0 3px 6px 6px;
|
||||
box-shadow: $large-bottom-drawer-shadow;
|
||||
border-top: 1px solid $white-20;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -164,7 +164,7 @@ $playback-width: 26rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: $gray-1200;
|
||||
background-color: $gray-1250;
|
||||
padding: 0.5rem;
|
||||
border-left: 1px solid $white-10;
|
||||
border-radius: 0 8px 0 0;
|
||||
@@ -215,6 +215,11 @@ $playback-width: 26rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
.mainContainer > .rundown {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user