Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e481d49da | |||
| 0a68377b82 | |||
| cb39a13bea | |||
| e56460a940 | |||
| 390d466673 | |||
| b778835469 | |||
| 32b5ab0574 | |||
| 03e7428348 | |||
| 0e47b5c10c | |||
| 6582f4a9bc | |||
| 7456205b6e | |||
| 9338a615d4 | |||
| 03d5389540 | |||
| bca7ad30b1 | |||
| e937af62b1 | |||
| 73533600a0 | |||
| 76c8f8a4d5 | |||
| 11648ee546 | |||
| 5b977790b0 | |||
| 3e2626d4f9 | |||
| 99f4738f4a | |||
| c405698813 | |||
| 6d138ce9f1 | |||
| 068c72662d | |||
| edac1d7f75 | |||
| 03552056cb | |||
| 70cc071425 | |||
| d8a84ac88d | |||
| 63d763cbaf | |||
| df351b8cba | |||
| 298af3ec5a | |||
| de9a7a87fd | |||
| 3918758d32 | |||
| 832c060940 | |||
| 110d3fb7a3 | |||
| 062ff7226a | |||
| 6f634c36f9 | |||
| 1bb67eb82a | |||
| 6720626bd3 | |||
| c56c5a636d | |||
| d486d78594 | |||
| 3626b5b357 | |||
| b54c7dd454 | |||
| ed3554d033 | |||
| 139f466beb | |||
| 23b1ced3fb | |||
| 0f5747839c | |||
| ff5735fe36 | |||
| c1f87736eb | |||
| edd983dfd6 | |||
| 2a5ba90530 | |||
| c03ed07762 | |||
| a3f14182a4 | |||
| 918f8517d2 | |||
| 54442dc9a1 | |||
| 1b1aced296 | |||
| d8ee0d4f82 | |||
| 08d2ebcc5e | |||
| 0fea4064c3 | |||
| 13be6ea2bc | |||
| c2ed9d7634 | |||
| cd0577bf99 |
@@ -11,16 +11,9 @@
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"e2e/**/**.spec.ts",
|
||||
"e2e/**/**.test.ts"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:playwright/playwright-test"
|
||||
]
|
||||
"files": ["e2e/**/**.spec.ts", "e2e/**/**.test.ts"],
|
||||
"extends": ["plugin:playwright/playwright-test"]
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "warn"
|
||||
}
|
||||
"rules": {}
|
||||
}
|
||||
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 660 KiB |
@@ -0,0 +1,139 @@
|
||||
name: Ontime build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "v1.*.*" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_mac:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14.x'
|
||||
|
||||
# React
|
||||
- name: React - Install dependencies
|
||||
run: yarn install --frozen-lockfile --network-timeout 300000
|
||||
working-directory: ./client
|
||||
|
||||
- name: React - Build project
|
||||
run: yarn build
|
||||
working-directory: ./client
|
||||
|
||||
# Node server
|
||||
- name: Server - Install dependencies
|
||||
run: yarn install --frozen-lockfile --production --network-timeout 300000
|
||||
working-directory: ./server/src
|
||||
|
||||
# App
|
||||
- name: Electron - Install dependencies
|
||||
shell: bash
|
||||
run: yarn install --frozen-lockfile --network-timeout 300000 && yarn setdb
|
||||
working-directory: ./server
|
||||
- name: Electron - Build app
|
||||
run: yarn dist-mac
|
||||
working-directory: ./server
|
||||
|
||||
# Release
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: ./server/dist/ontime-macOS.dmg
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build_win:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '14.x'
|
||||
|
||||
# React
|
||||
- name: React - Install dependencies
|
||||
run: yarn install --frozen-lockfile --network-timeout 300000
|
||||
working-directory: ./client
|
||||
|
||||
- name: React - Build project
|
||||
run: yarn build
|
||||
working-directory: ./client
|
||||
|
||||
# Node server
|
||||
- name: Server - Install dependencies
|
||||
run: yarn install --frozen-lockfile --production --network-timeout 300000
|
||||
working-directory: ./server/src
|
||||
|
||||
# App
|
||||
- name: Electron - Install dependencies
|
||||
shell: bash
|
||||
run: yarn install --frozen-lockfile --network-timeout 300000 && yarn setdb
|
||||
working-directory: ./server
|
||||
- name: Electron - Build app
|
||||
run: yarn dist-win
|
||||
working-directory: ./server
|
||||
|
||||
# Release
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: ./server/dist/ontime-win64.exe
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build_linux:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '14.x'
|
||||
|
||||
# React
|
||||
- name: React - Install dependencies
|
||||
run: yarn install --frozen-lockfile --network-timeout 300000
|
||||
working-directory: ./client
|
||||
|
||||
- name: React - Build project
|
||||
run: yarn build
|
||||
working-directory: ./client
|
||||
|
||||
# Node server
|
||||
- name: Server - Install dependencies
|
||||
run: yarn install --frozen-lockfile --production --network-timeout 300000
|
||||
working-directory: ./server/src
|
||||
|
||||
# App
|
||||
- name: Electron - Install dependencies
|
||||
shell: bash
|
||||
run: yarn install --frozen-lockfile --network-timeout 300000 && yarn setdb
|
||||
working-directory: ./server
|
||||
- name: Electron - Build app
|
||||
run: yarn dist-linux
|
||||
working-directory: ./server
|
||||
|
||||
# Release
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: ./server/dist/ontime-linux.AppImage
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -2,7 +2,7 @@ name: Docker Image CI Ontime V2
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
tags: [ "v2.*.*" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -52,5 +52,5 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
|
||||
# Push is a shorthand for --output=type=registry
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:beta_${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:beta_v2
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Ontime build v2
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ "*" ]
|
||||
tags: [ "v2.*.*" ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -35,9 +35,7 @@ jobs:
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
./apps/electron/dist/ontime-macOS-x64.dmg
|
||||
./apps/electron/dist/ontime-macOS-arm64.dmg
|
||||
files: './apps/electron/dist/ontime-macOS.dmg'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: ontime_test_CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
# React
|
||||
- name: React - Install dependencies
|
||||
run: yarn install
|
||||
working-directory: ./client
|
||||
|
||||
- name: React - Run tests
|
||||
run: yarn test:pipeline
|
||||
working-directory: ./client
|
||||
|
||||
- name: React - Build project
|
||||
run: yarn build
|
||||
working-directory: ./client
|
||||
|
||||
# Node server
|
||||
- name: Server - Install dependencies
|
||||
run: yarn install
|
||||
working-directory: ./server/src
|
||||
|
||||
# App
|
||||
- name: Electron - Install dependencies
|
||||
run: yarn setup
|
||||
working-directory: ./server
|
||||
|
||||
- name: Server - run tests
|
||||
run: yarn test
|
||||
working-directory: ./server
|
||||
|
||||
# - name: Install Playwright Browsers
|
||||
# run: npx playwright install --with-deps
|
||||
# working-directory: ./server
|
||||
#
|
||||
# - name: Run Playwright tests
|
||||
# run: yarn e2e
|
||||
# working-directory: ./server
|
||||
#
|
||||
# - uses: actions/upload-artifact@v3
|
||||
# if: always()
|
||||
# with:
|
||||
# name: playwright-report
|
||||
# path: playwright-report/
|
||||
# retention-days: 7
|
||||
@@ -2,7 +2,7 @@ name: Ontime test v2
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: '*'
|
||||
branches: [ v2 ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -20,34 +20,6 @@ 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.
|
||||
- Unit tests for functions that contain business logic
|
||||
- End-to-end tests for core features
|
||||
|
||||
### Unit tests
|
||||
Unit tests are contained in mostly all the apps and packages (client, server and utils)
|
||||
|
||||
You can run unit tests by running turbo `turbo test:pipeline` from the project root.
|
||||
This will run all tests and close test runner.
|
||||
|
||||
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
|
||||
|
||||
### E2E tests
|
||||
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the webserver to test against
|
||||
These tests also run against a separate version of the DB (test-db)
|
||||
|
||||
You can run playwright tests from project root with `pnpm e2e`
|
||||
|
||||
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually start the webserver with `pnpm dev:server`
|
||||
|
||||
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
|
||||
|
||||
Ontime uses Electron to distribute the application.
|
||||
@@ -55,7 +27,7 @@ You can generate a distribution for your OS by running the following steps.
|
||||
|
||||
From the project root, run the following commands
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Build the UI and server__ by running `turbo build:local`
|
||||
- __Build the UI and server__ by running `turbo build`
|
||||
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
|
||||
|
||||
The build distribution assets will be at `.apps/electron/dist`
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
[](https://github.com/cpvalente/ontime/actions/workflows/build_v2.yml)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0) [](https://ontime.gitbook.io)
|
||||
[](https://github.com/cpvalente/ontime/actions/workflows/ontime_cy.yml) [](https://github.com/cpvalente/ontime/actions/workflows/build.yml)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0) [](https://cpvalente.gitbook.io/ontime/)
|
||||
|
||||
## Download the latest releases here
|
||||
|
||||
<div style="display: flex; justify-content: space-around">
|
||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
|
||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
|
||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
|
||||
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
|
||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/mac-download.png"/></a>
|
||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/win-download.png"/></a>
|
||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/linux-download.png"/></a>
|
||||
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/dockerhub.png"/></a>
|
||||
</div>
|
||||
|
||||
# Ontime
|
||||
|
||||
Ontime is an application for creating and managing event running order and timers.
|
||||
Ontime is an application for managing event rundowns and running stage timers.
|
||||
|
||||
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.
|
||||
A single, locally hosted central application distributes your event information over the local network.
|
||||
This enables the distribution of the data to a series of viewers and allows integration into video and control workflows, including OBS and d3.
|
||||
|
||||
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## Using Ontime
|
||||
|
||||
@@ -32,8 +30,8 @@ Any device with a browser in the same network can choose one of the supported vi
|
||||
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
|
||||
or `192.168.1.3:4001`
|
||||
<br />
|
||||
You can then use the menu in the top left corner to select the desired view.
|
||||
The menu will be initially hidden until there is mouse interaction.
|
||||
You can then use the Ontime logo in the top left corner to select the desired view.
|
||||
The logo will be initially hidden until there is mouse interaction.
|
||||
|
||||
In the case of unattended machines or automation, it is possible to use different URL to recall
|
||||
individual views and extend view settings using the URL aliases feature
|
||||
@@ -57,43 +55,43 @@ IP.ADDRESS:4001/editor > the control interface, same as the app
|
||||
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||
```
|
||||
|
||||
More documentation is available [in our docs](https://ontime.gitbook.io)
|
||||
More documentation is available [in our docs](https://cpvalente.gitbook.io/ontime/)
|
||||
|
||||
## Feature List (in no specific order)
|
||||
|
||||
- [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)
|
||||
- [x] Configurable Lower Thirds
|
||||
- [x] Collaborative editing with the cuesheet view
|
||||
- [x] Cuesheets with user definable fields
|
||||
- [x] Send live messages to different screen types
|
||||
- [x] Differentiate between backstage and public data
|
||||
- [x] Workflow for managing delays
|
||||
- [x] Rich protocol integrations for Control and Feedback
|
||||
- [x] Protocol integrations for Control and Feedback
|
||||
- OSC (Open Sound Control)
|
||||
- HTTP
|
||||
- WebSockets
|
||||
- [x] Roll mode: run standalone using the system clock
|
||||
- [x] Roll mode: run independently using the system clock
|
||||
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
|
||||
- [x] [Countdown to anything!](https://ontime.gitbook.io/v2/views/countdown): have
|
||||
- [x] [Countdown to anything!](https://cpvalente.gitbook.io/ontime/views/countdown): have
|
||||
a countdown to any scheduled event
|
||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
|
||||
|
||||
## Unopinionated
|
||||
|
||||
We want Ontime to be unique by targeting freelancers instead of roles.
|
||||
We are not interested in forcing workflows and have made Ontime, so it is flexible to whichever way
|
||||
you would like to work.
|
||||
|
||||
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.
|
||||
- [x] If you want just the info screens, there is no need to use the timer!
|
||||
- [x] Don't have or care for a schedule?
|
||||
- [x] a single event with no data is enough to use one of the APIs and use a dynamic timer
|
||||
- [x] use the order list to create a set of quick timers by setting the beginning and start
|
||||
times to 00:00 and 00:10 (**BAM**! 10 minute timer). You can quickly recall this with OSC or any of the other available integrations
|
||||
|
||||
## Rich APIs for workflow integrations
|
||||
|
||||
@@ -111,13 +109,13 @@ Taking advantage of the integrations, we currently use Ontime with:
|
||||
|
||||
### Make your own viewer
|
||||
|
||||
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
|
||||
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside of the application.
|
||||
|
||||
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language that can run in the browser).
|
||||
<br />
|
||||
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
|
||||
See [this repository](https://github.com/cpvalente/ontime-viewer-template) with a small template on
|
||||
how to get you started and read the docs about
|
||||
the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-apis#osc-and-websocket-api)
|
||||
the [Websocket API](https://app.gitbook.com/s/-Mc0giSOToAhq0ROd0CR/control-and-feedback/websocket-api)
|
||||
|
||||
### Headless run️
|
||||
|
||||
@@ -134,14 +132,18 @@ in [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime
|
||||
docker pull getontime/ontime
|
||||
```
|
||||
|
||||
and use the included docker compose to get started
|
||||
```bash
|
||||
# Port 4001 - ontime server port
|
||||
# Port 8888 - OSC input, bound to localhost IP Address
|
||||
docker run -p 4001:4001 -p 127.0.0.1:8888:8888/udp --mount type=bind,source="$(pwd)/ontime-db",target=/server/preloaded-db getontime/ontime
|
||||
```
|
||||
|
||||
or if running from the docker compose
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
Related information available [in the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Continued development
|
||||
@@ -200,7 +202,7 @@ Information about the project setup can be found in the [development documentati
|
||||
|
||||
# Help
|
||||
|
||||
Help is underway! ... and can be found [here](https://ontime.gitbook.io)
|
||||
Help is underway! ... and can be found [here](https://cpvalente.gitbook.io/ontime/)
|
||||
|
||||
# License
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"stylelint-config-standard-scss",
|
||||
"stylelint-config-prettier"
|
||||
]
|
||||
}
|
||||
@@ -1,33 +1,33 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "2.3.9",
|
||||
"version": "2.0.0-beta2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.7.0",
|
||||
"@chakra-ui/react": "^2.5.1",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^7.46.0",
|
||||
"@sentry/tracing": "^7.46.0",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"@tanstack/react-query-devtools": "^4.29.0",
|
||||
"@tanstack/react-table": "^8.9.2",
|
||||
"autosize": "^6.0.1",
|
||||
"@sentry/react": "^7.28.1",
|
||||
"@sentry/tracing": "^7.24.1",
|
||||
"@tanstack/react-query": "^4.26.1",
|
||||
"@tanstack/react-query-devtools": "^4.26.1",
|
||||
"autosize": "^5.0.2",
|
||||
"axios": "^1.2.0",
|
||||
"color": "^4.2.3",
|
||||
"csv-stringify": "^6.2.3",
|
||||
"deepmerge": "^4.3.0",
|
||||
"framer-motion": "^10.10.0",
|
||||
"framer-motion": "^8.0.2",
|
||||
"luxon": "^3.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-fast-compare": "^3.2.0",
|
||||
"react-hook-form": "^7.43.5",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"react-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,6 +40,7 @@
|
||||
"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"
|
||||
@@ -57,13 +58,14 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "^0.4.0",
|
||||
"@sentry/vite-plugin": "^0.3.0",
|
||||
"@tanstack/eslint-plugin-query": "^4.26.2",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
"@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",
|
||||
@@ -82,12 +84,15 @@
|
||||
"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",
|
||||
"vite": "^4.0.4",
|
||||
"vite-plugin-svgr": "^2.4.0",
|
||||
"vite-tsconfig-paths": "^4.2.0",
|
||||
"vitest": "^0.30.1"
|
||||
"vite-tsconfig-paths": "^4.0.3",
|
||||
"vitest": "^0.29.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
@@ -6,10 +6,8 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
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';
|
||||
@@ -19,25 +17,24 @@ import AppRouter from './AppRouter';
|
||||
// @ts-expect-error no types from font import
|
||||
import('typeface-open-sans');
|
||||
|
||||
const preferredClientName = socketClientName.getState().name;
|
||||
connectSocket(preferredClientName);
|
||||
connectSocket();
|
||||
|
||||
function App() {
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
// handle held key
|
||||
if (event.repeat) return;
|
||||
// check if the alt key is pressed
|
||||
if (event.altKey) {
|
||||
if (event.code === 'KeyT') {
|
||||
// ask to see debug
|
||||
sendToElectron('set-window', 'show-dev');
|
||||
}
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
// handle held key
|
||||
if (event.repeat) return;
|
||||
// check if the alt key is pressed
|
||||
if (event.altKey) {
|
||||
if (event.code === 'KeyT') {
|
||||
// ask to see debug
|
||||
sendToElectron('set-window', 'show-dev');
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron) {
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
@@ -46,24 +43,24 @@ function App() {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
};
|
||||
}, [isElectron, sendToElectron]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChakraProvider resetCSS theme={theme}>
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
<AppContextProvider>
|
||||
<ContextMenuProvider>
|
||||
<BrowserRouter>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<TranslationProvider>
|
||||
<AppRouter />
|
||||
</TranslationProvider>
|
||||
</ErrorBoundary>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</ContextMenuProvider>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</AppContextProvider>
|
||||
</QueryClientProvider>
|
||||
</ChakraProvider>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { lazy, useEffect } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import useAliases from './common/hooks-query/useAliases';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
import withAlias from './features/AliasWrapper';
|
||||
import { useTranslation } from './translation/TranslationProvider';
|
||||
|
||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Table = lazy(() => import('./features/table/ProtectedTable'));
|
||||
|
||||
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
||||
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
@@ -16,89 +18,114 @@ 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 = withAlias(withData(TimerView));
|
||||
const SMinimalTimer = withAlias(withData(MinimalTimerView));
|
||||
const SClock = withAlias(withData(ClockView));
|
||||
const SCountdown = withAlias(withData(Countdown));
|
||||
const SBackstage = withAlias(withData(Backstage));
|
||||
const SPublic = withAlias(withData(Public));
|
||||
const SLowerThird = withAlias(withData(Lower));
|
||||
const SStudio = withAlias(withData(StudioClock));
|
||||
const STimer = 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 EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const FeatureWrapper = lazy(() => import('./features/FeatureWrapper'));
|
||||
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
|
||||
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
|
||||
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
|
||||
const Info = lazy(() => import('./features/info/InfoExport'));
|
||||
|
||||
export default function AppRouter() {
|
||||
const { data } = useAliases();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setLanguage } = useTranslation();
|
||||
|
||||
// Set output language
|
||||
useEffect(() => {
|
||||
const langParam = searchParams.get('lang');
|
||||
if (langParam && langParam.length === 2) {
|
||||
setLanguage(searchParams.get('lang'));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
// navigate if is alias route
|
||||
useEffect(() => {
|
||||
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>
|
||||
<Route path='/' element={<Navigate to='/timer' />} />
|
||||
<Route path='/speaker' element={<STimer />} />
|
||||
<Route path='/presenter' element={<STimer />} />
|
||||
<Route path='/stage' element={<STimer />} />
|
||||
<Route path='/timer' element={<STimer />} />
|
||||
<Routes>
|
||||
<Route path='/' element={<Navigate to='/timer' />} />
|
||||
<Route path='/speaker' element={<STimer />} />
|
||||
<Route path='/presenter' element={<STimer />} />
|
||||
<Route path='/stage' element={<STimer />} />
|
||||
<Route path='/timer' element={<STimer />} />
|
||||
|
||||
<Route path='/minimal' element={<SMinimalTimer />} />
|
||||
<Route path='/minimalTimer' element={<SMinimalTimer />} />
|
||||
<Route path='/simpleTimer' element={<SMinimalTimer />} />
|
||||
<Route path='/minimal' element={<SMinimalTimer />} />
|
||||
<Route path='/minimalTimer' element={<SMinimalTimer />} />
|
||||
<Route path='/simpleTimer' element={<SMinimalTimer />} />
|
||||
|
||||
<Route path='/clock' element={<SClock />} />
|
||||
<Route path='/clock' element={<SClock />} />
|
||||
|
||||
<Route path='/countdown' element={<SCountdown />} />
|
||||
<Route path='/countdown' element={<SCountdown />} />
|
||||
|
||||
<Route path='/sm' element={<SBackstage />} />
|
||||
<Route path='/backstage' element={<SBackstage />} />
|
||||
<Route path='/sm' element={<SBackstage />} />
|
||||
<Route path='/backstage' element={<SBackstage />} />
|
||||
|
||||
<Route path='/public' element={<SPublic />} />
|
||||
<Route path='/studio' element={<SStudio />} />
|
||||
{/*/!* Lower cannot have fallback *!/*/}
|
||||
<Route path='/lower' element={<SLowerThird />} />
|
||||
<Route path='/public' element={<SPublic />} />
|
||||
<Route path='/studio' element={<SStudio />} />
|
||||
{/*/!* Lower cannot have fallback *!/*/}
|
||||
<Route path='/lower' element={<SLowerThird />} />
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||
<Route path='/cuelist' element={<Cuesheet />} />
|
||||
<Route path='/table' element={<Cuesheet />} />
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<Table />} />
|
||||
<Route path='/cuelist' element={<Table />} />
|
||||
<Route path='/table' element={<Table />} />
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
path='/rundown'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<RundownPanel />
|
||||
</EditorFeatureWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/timercontrol'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<TimerControl />
|
||||
</EditorFeatureWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/messagecontrol'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<MessageControl />
|
||||
</EditorFeatureWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/info'
|
||||
element={
|
||||
<EditorFeatureWrapper>
|
||||
<Info />
|
||||
</EditorFeatureWrapper>
|
||||
}
|
||||
/>
|
||||
{/*/!* Send to default if nothing found *!/*/}
|
||||
<Route path='*' element={<STimer />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
path='/rundown'
|
||||
element={
|
||||
<FeatureWrapper>
|
||||
<RundownPanel />
|
||||
</FeatureWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/timercontrol'
|
||||
element={
|
||||
<FeatureWrapper>
|
||||
<TimerControl />
|
||||
</FeatureWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/messagecontrol'
|
||||
element={
|
||||
<FeatureWrapper>
|
||||
<MessageControl />
|
||||
</FeatureWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/info'
|
||||
element={
|
||||
<FeatureWrapper>
|
||||
<Info />
|
||||
</FeatureWrapper>
|
||||
}
|
||||
/>
|
||||
{/*/!* Send to default if nothing found *!/*/}
|
||||
<Route path='*' element={<STimer />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClientMock = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// Exported viewer link location
|
||||
const minimalLocation = 'minimal';
|
||||
const speakerLocation = 'speaker';
|
||||
const smLocation = 'sm';
|
||||
const publicLocation = 'public';
|
||||
const studioLocation = 'studio';
|
||||
const cuesheetLocation = 'cuesheet';
|
||||
const countdownLocation = 'countdown';
|
||||
const clockLocation = 'clock';
|
||||
const lowerLocation = 'lower';
|
||||
|
||||
export const viewerLocations = [
|
||||
{ link: speakerLocation, label: 'Stage timer' },
|
||||
{ link: clockLocation, label: 'Clock' },
|
||||
{ link: minimalLocation, label: 'Minimal timer' },
|
||||
{ link: smLocation, label: 'Backstage screen' },
|
||||
{ link: publicLocation, label: 'Public screen' },
|
||||
{ link: lowerLocation, label: 'Lower thirds' },
|
||||
{ link: studioLocation, label: 'Studio clock' },
|
||||
{ link: countdownLocation, label: 'Countdown' },
|
||||
{ link: cuesheetLocation, label: 'Cuesheet' },
|
||||
];
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -1,21 +0,0 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" rx="24" fill="#222222"/>
|
||||
<path d="M40.5 131.781V128.193C40.5 114.635 42.4369 102.159 46.3106 90.7659C50.1843 79.2587 55.8239 69.2896 63.2295 60.8586C70.6351 52.4277 79.7497 45.8765 90.5733 41.2053C101.397 36.4202 113.815 34.0276 127.829 34.0276C141.843 34.0276 154.318 36.4202 165.256 41.2053C176.193 45.8765 185.365 52.4277 192.771 60.8586C200.29 69.2896 205.987 79.2587 209.86 90.7659C213.734 102.159 215.671 114.635 215.671 128.193V131.781C215.671 145.226 213.734 157.701 209.86 169.208C205.987 180.601 200.29 190.571 192.771 199.115C185.365 207.546 176.25 214.098 165.427 218.769C154.603 223.44 142.185 225.776 128.171 225.776C114.157 225.776 101.682 223.44 90.7442 218.769C79.9206 214.098 70.7491 207.546 63.2295 199.115C55.8239 190.571 50.1843 180.601 46.3106 169.208C42.4369 157.701 40.5 145.226 40.5 131.781ZM89.7188 128.193V131.781C89.7188 139.529 90.4024 146.764 91.7696 153.486C93.1368 160.208 95.3015 166.132 98.2637 171.259C101.34 176.272 105.328 180.203 110.227 183.051C115.126 185.899 121.107 187.323 128.171 187.323C135.007 187.323 140.874 185.899 145.773 183.051C150.673 180.203 154.603 176.272 157.565 171.259C160.528 166.132 162.692 160.208 164.06 153.486C165.541 146.764 166.281 139.529 166.281 131.781V128.193C166.281 120.673 165.541 113.609 164.06 107.001C162.692 100.279 160.471 94.3547 157.395 89.2278C154.432 83.9869 150.502 79.8853 145.603 76.9231C140.703 73.9609 134.779 72.4797 127.829 72.4797C120.879 72.4797 114.955 73.9609 110.056 76.9231C105.271 79.8853 101.34 83.9869 98.2637 89.2278C95.3015 94.3547 93.1368 100.279 91.7696 107.001C90.4024 113.609 89.7188 120.673 89.7188 128.193Z" fill="#FFFFFA"/>
|
||||
<mask id="mask0_30_31" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="40" y="34" width="176" height="192">
|
||||
<path d="M40.5 131.781V128.193C40.5 114.635 42.4369 102.159 46.3106 90.7659C50.1843 79.2587 55.8239 69.2896 63.2295 60.8586C70.6351 52.4277 79.7497 45.8765 90.5733 41.2053C101.397 36.4202 113.815 34.0276 127.829 34.0276C141.843 34.0276 154.318 36.4202 165.256 41.2053C176.193 45.8765 185.365 52.4277 192.771 60.8586C200.29 69.2896 205.987 79.2587 209.86 90.7659C213.734 102.159 215.671 114.635 215.671 128.193V131.781C215.671 145.226 213.734 157.701 209.86 169.208C205.987 180.601 200.29 190.571 192.771 199.115C185.365 207.546 176.25 214.098 165.427 218.769C154.603 223.44 142.185 225.776 128.171 225.776C114.157 225.776 101.682 223.44 90.7442 218.769C79.9206 214.098 70.7491 207.546 63.2295 199.115C55.8239 190.571 50.1843 180.601 46.3106 169.208C42.4369 157.701 40.5 145.226 40.5 131.781ZM89.7188 128.193V131.781C89.7188 139.529 90.4024 146.764 91.7696 153.486C93.1368 160.208 95.3015 166.132 98.2637 171.259C101.34 176.272 105.328 180.203 110.227 183.051C115.126 185.899 121.107 187.323 128.171 187.323C135.007 187.323 140.874 185.899 145.773 183.051C150.673 180.203 154.603 176.272 157.565 171.259C160.528 166.132 162.692 160.208 164.06 153.486C165.541 146.764 166.281 139.529 166.281 131.781V128.193C166.281 120.673 165.541 113.609 164.06 107.001C162.692 100.279 160.471 94.3547 157.395 89.2278C154.432 83.9869 150.502 79.8853 145.603 76.9231C140.703 73.9609 134.779 72.4797 127.829 72.4797C120.879 72.4797 114.955 73.9609 110.056 76.9231C105.271 79.8853 101.34 83.9869 98.2637 89.2278C95.3015 94.3547 93.1368 100.279 91.7696 107.001C90.4024 113.609 89.7188 120.673 89.7188 128.193Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_30_31)">
|
||||
<path d="M17.7519 90.6278C32.2336 84.5548 31.0535 58.0568 38.3066 62.5986C47.3731 68.2758 38.3066 41.1095 50.9198 47.1825C71.1339 56.9152 40.7009 64.5389 62.3666 76.9778C84.0323 89.4167 171.756 99.642 202.839 160.802C214.766 184.27 220.843 191.199 223.166 192.958C224.57 192.837 224.823 194.214 223.166 192.958C222.812 192.989 222.385 193.115 221.898 193.401C216.763 196.422 122.406 273.336 83.6205 225.168C44.8346 177 2.09417 132.401 13.321 120.526C22.3025 111.025 14.6184 97.1566 17.7519 90.6278Z" fill="url(#paint0_linear_30_31)"/>
|
||||
<path d="M39.2409 66.8029C31.9877 62.2612 38.8572 79.7789 16.8175 87.8247C13.684 94.3536 21.1274 106.353 12.1459 115.854C0.919109 127.73 47.1703 180.27 85.9563 228.438C124.742 276.606 200.88 205.765 206.015 202.745C211.149 199.724 219.133 212.629 196.599 168.158C165.523 106.828 78.8569 98.1563 57.3454 76.3867C38.7095 57.5272 88.9205 36.7794 67.2701 41.1095C39.2409 46.7153 48.3073 72.4802 39.2409 66.8029Z" fill="url(#paint1_linear_30_31)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_30_31" x1="87.4684" y1="-29.7758" x2="62.9476" y2="328.852" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF0000" stop-opacity="0.74"/>
|
||||
<stop offset="1" stop-color="#FF0000" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_30_31" x1="82.3228" y1="-32.8735" x2="57.802" y2="325.754" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF005C" stop-opacity="0.74"/>
|
||||
<stop offset="1" stop-color="#FF005C" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,7 +1,7 @@
|
||||
export const STATIC_PORT = 4001;
|
||||
|
||||
// REST stuff
|
||||
export const EVENT_DATA = ['eventdata'];
|
||||
export const EVENTDATA_TABLE = ['eventdata'];
|
||||
export const ALIASES = ['aliases'];
|
||||
export const USERFIELDS = ['userFields'];
|
||||
export const RUNDOWN_TABLE_KEY = 'rundown';
|
||||
@@ -12,15 +12,20 @@ export const APP_SETTINGS = ['appSettings'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const RUNTIME = ['runtimeStore'];
|
||||
|
||||
const location = window.location;
|
||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
// external stuff
|
||||
export const githubURL = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
|
||||
|
||||
export const serverPort = import.meta.env.DEV ? STATIC_PORT : location.port;
|
||||
export const serverURL = import.meta.env.DEV ? `http://${location.hostname}:${serverPort}` : location.origin;
|
||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
||||
/**
|
||||
* @description finds server path given the current location, it
|
||||
* @return {*}
|
||||
*/
|
||||
export const calculateServer = () => (import.meta.env.DEV ? `http://localhost:${STATIC_PORT}` : window.location.origin);
|
||||
|
||||
export const serverURL = calculateServer();
|
||||
export const websocketUrl = `ws://${window.location.hostname}:${STATIC_PORT}/ws`;
|
||||
|
||||
export const eventURL = `${serverURL}/eventdata`;
|
||||
export const rundownURL = `${serverURL}/events`;
|
||||
export const rundownURL = `${serverURL}/eventlist`;
|
||||
export const ontimeURL = `${serverURL}/ontime`;
|
||||
|
||||
export const stylesPath = 'external/styles/override.css';
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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) {
|
||||
const message = axios.isAxiosError(error)
|
||||
? `${prepend} ${(error as AxiosError).response?.statusText ?? ''}: ${(error as AxiosError).response?.data ?? ''}`
|
||||
: `${prepend}: ${error}`;
|
||||
|
||||
addLog({
|
||||
id: generateId(),
|
||||
origin: 'SERVER',
|
||||
time: millisToString(nowInMillis()),
|
||||
level: LogLevel.Error,
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,14 @@ 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;
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alias,
|
||||
EventData,
|
||||
OSCSettings,
|
||||
OscSubscription,
|
||||
Settings,
|
||||
UserFields,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import { Alias, OSCSettings, Settings, UserFields, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { apiRepoLatest } from '../../externals';
|
||||
import { InfoType } from '../models/Info';
|
||||
|
||||
import { ontimeURL } from './apiConstants';
|
||||
import { githubURL, ontimeURL } from './apiConstants';
|
||||
|
||||
/**
|
||||
* @description HTTP request to retrieve application settings
|
||||
@@ -53,7 +44,7 @@ export async function getView(): Promise<ViewSettings> {
|
||||
* @description HTTP request to mutate view settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postViewSettings(data: ViewSettings) {
|
||||
export async function postView(data: ViewSettings) {
|
||||
return axios.post(`${ontimeURL}/views`, data);
|
||||
}
|
||||
|
||||
@@ -108,14 +99,6 @@ export async function postOSC(data: OSCSettings) {
|
||||
return axios.post(`${ontimeURL}/osc`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate osc subscriptions
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postOscSubscriptions(data: OscSubscription) {
|
||||
return axios.post(`${ontimeURL}/osc-subscriptions`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to download db
|
||||
* @return {Promise}
|
||||
@@ -152,7 +135,7 @@ export const downloadRundown = async () => {
|
||||
type UploadDataOptions = {
|
||||
onlyRundown?: boolean;
|
||||
};
|
||||
export const uploadData = async (file: File, setProgress: (value: number) => void, options?: UploadDataOptions) => {
|
||||
export const uploadData = async (file: string, setProgress: (value: number) => void, options?: UploadDataOptions) => {
|
||||
const formData = new FormData();
|
||||
formData.append('userFile', file);
|
||||
const onlyRundown = options?.onlyRundown || 'false';
|
||||
@@ -169,23 +152,11 @@ export const uploadData = async (file: File, setProgress: (value: number) => voi
|
||||
.then((response) => response.data.id);
|
||||
};
|
||||
|
||||
export type HasUpdate = {
|
||||
url: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description HTTP request to get the latest version and url from github
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function getLatestVersion(): Promise<HasUpdate> {
|
||||
const res = await axios.get(`${apiRepoLatest}`);
|
||||
return {
|
||||
url: res.data.html_url as string,
|
||||
version: res.data.tag_name as string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function postNew(initialData: Partial<EventData>) {
|
||||
return axios.post(`${ontimeURL}/new`, initialData);
|
||||
export async function getLatestVersion(): Promise<object> {
|
||||
const res = await axios.get(`${githubURL}`);
|
||||
return { url: res.data.html_url, version: res.data.tag_name };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoCheckmarkSharp } from '@react-icons/all-files/io5/IoCheckmarkSharp';
|
||||
import { IoCloseSharp } from '@react-icons/all-files/io5/IoCloseSharp';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default function EnableBtn(props) {
|
||||
const { active, text, actionHandler, size = 'xs' } = props;
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
leftIcon={active ? <IoCheckmarkSharp /> : <IoCloseSharp />}
|
||||
colorScheme='blue'
|
||||
variant={active ? 'solid' : 'outline'}
|
||||
onClick={actionHandler}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
EnableBtn.propTypes = {
|
||||
active: PropTypes.bool,
|
||||
text: PropTypes.string,
|
||||
actionHandler: PropTypes.func,
|
||||
size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']),
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
||||
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
interface PauseIconBtnProps {
|
||||
clickhandler: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export default function PauseIconBtn(props: PauseIconBtnProps) {
|
||||
const { clickhandler, active, disabled, ...rest } = props;
|
||||
return (
|
||||
<Tooltip label='Pause timer' openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
|
||||
<IconButton
|
||||
icon={<IoPause size='24px' />}
|
||||
colorScheme='orange'
|
||||
variant={active ? 'solid' : 'outline'}
|
||||
onClick={clickhandler}
|
||||
width={120}
|
||||
disabled={disabled}
|
||||
aria-label='Pause playback'
|
||||
{...rest}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import { FiUsers } from '@react-icons/all-files/fi/FiUsers';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default function PublicIconBtn(props) {
|
||||
const { actionHandler, active, size = 'xs', ...rest } = props;
|
||||
return (
|
||||
<Tooltip label={active ? 'Make event private' : 'Make event public'}>
|
||||
<IconButton
|
||||
size={size}
|
||||
icon={<FiUsers />}
|
||||
colorScheme='blue'
|
||||
variant={active ? 'solid' : 'outline'}
|
||||
onClick={() => actionHandler('update', { field: 'isPublic', value: !active })}
|
||||
{...rest}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
PublicIconBtn.propTypes = {
|
||||
actionHandler: PropTypes.func,
|
||||
active: PropTypes.bool,
|
||||
size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']),
|
||||
};
|
||||
@@ -18,7 +18,6 @@ import { useEmitLog } from '../../stores/logger';
|
||||
interface QuitIconBtnProps {
|
||||
clickHandler: () => void;
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const quitBtnStyle = {
|
||||
@@ -28,10 +27,6 @@ const quitBtnStyle = {
|
||||
_hover: {
|
||||
background: '#D20300', // $red-700
|
||||
color: 'white',
|
||||
_disabled: {
|
||||
color: '#D20300', // $red-700
|
||||
background: 'none',
|
||||
},
|
||||
},
|
||||
_active: {
|
||||
background: '#9A0000', // $red-1000
|
||||
@@ -42,7 +37,7 @@ const quitBtnStyle = {
|
||||
};
|
||||
|
||||
export default function QuitIconBtn(props: QuitIconBtnProps) {
|
||||
const { clickHandler, size = 'lg', disabled, ...rest } = props;
|
||||
const { clickHandler, size = 'lg', ...rest } = props;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { emitInfo } = useEmitLog();
|
||||
const onClose = () => setIsOpen(false);
|
||||
@@ -70,7 +65,6 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
|
||||
size={size}
|
||||
icon={<FiPower />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
isDisabled={disabled}
|
||||
{...quitBtnStyle}
|
||||
{...rest}
|
||||
/>
|
||||
@@ -81,7 +75,9 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
|
||||
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
|
||||
Ontime Shutdown
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogBody>This will shutdown the program and all running servers. Are you sure?</AlertDialogBody>
|
||||
<AlertDialogBody>
|
||||
This will shutdown the program and all running servers. Are you sure?
|
||||
</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
|
||||
Cancel
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
export default function RollIconBtn(props) {
|
||||
const { clickhandler, active, disabled, ...rest } = props;
|
||||
return (
|
||||
<Tooltip label='Roll mode' openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
|
||||
<IconButton
|
||||
icon={<IoTimeOutline size='24px' />}
|
||||
colorScheme='blue'
|
||||
variant={active ? 'solid' : 'outline'}
|
||||
onClick={clickhandler}
|
||||
width={120}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
RollIconBtn.propTypes = {
|
||||
clickhandler: PropTypes.func,
|
||||
active: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
export default function StartIconBtn(props) {
|
||||
const { clickhandler, active, disabled, ...rest } = props;
|
||||
return (
|
||||
<Tooltip label='Start timer' openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
|
||||
<IconButton
|
||||
icon={<IoPlay size='24px' />}
|
||||
colorScheme='green'
|
||||
variant={active ? 'solid' : 'outline'}
|
||||
onClick={clickhandler}
|
||||
width={120}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
StartIconBtn.propTypes = {
|
||||
clickhandler: PropTypes.func,
|
||||
active: PropTypes.bool,
|
||||
disabled: PropTypes.bool
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
|
||||
|
||||
interface TooltipActionBtnProps extends IconButtonProps {
|
||||
clickHandler: (event?: MouseEvent) => void | Promise<void>;
|
||||
clickHandler: () => void;
|
||||
tooltip: string;
|
||||
openDelay?: number;
|
||||
}
|
||||
@@ -11,7 +10,13 @@ export default function TooltipActionBtn(props: TooltipActionBtnProps) {
|
||||
const { clickHandler, icon, size = 'xs', tooltip, openDelay = 0, className, ...rest } = props;
|
||||
return (
|
||||
<Tooltip label={tooltip} openDelay={openDelay}>
|
||||
<IconButton {...rest} size={size} icon={icon} onClick={clickHandler} className={className} />
|
||||
<IconButton
|
||||
{...rest}
|
||||
size={size}
|
||||
icon={icon}
|
||||
onClick={clickHandler}
|
||||
className={className}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
export default function TransportIconBtn(props) {
|
||||
const { clickHandler, icon, tooltip, disabled, ...rest } = props;
|
||||
return (
|
||||
<Tooltip label={tooltip} openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
|
||||
<IconButton
|
||||
icon={icon}
|
||||
colorScheme='white'
|
||||
variant='outline'
|
||||
_hover={!disabled && { bg: '#ebedf0', color: '#333' }}
|
||||
onClick={clickHandler}
|
||||
width={90}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
TransportIconBtn.propTypes = {
|
||||
clickHandler: PropTypes.func,
|
||||
icon: PropTypes.element,
|
||||
tooltip: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
export default function UnloadIconBtn(props) {
|
||||
const { clickHandler, disabled, ...rest } = props;
|
||||
return (
|
||||
<Tooltip label='Unload event' openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
|
||||
<IconButton
|
||||
icon={<IoStop size='22px' />}
|
||||
colorScheme='red'
|
||||
variant='outline'
|
||||
onClick={clickHandler}
|
||||
width={90}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
UnloadIconBtn.propTypes = {
|
||||
clickHandler: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
@@ -6,7 +6,6 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: $section-white;
|
||||
margin-top: $main-spacing;
|
||||
border-bottom: 1px solid $border-color-ondark;
|
||||
padding-bottom: $element-inner-spacing;
|
||||
margin-bottom: $element-spacing;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { Size } from '../../models/Util.type';
|
||||
import copyToClipboard from '../../utils/copyToClipboard';
|
||||
|
||||
interface CopyTagProps {
|
||||
label: string;
|
||||
@@ -15,15 +14,21 @@ interface CopyTagProps {
|
||||
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
||||
const { label, className, size = 'xs', children } = props;
|
||||
|
||||
const handleClick = () => copyToClipboard(children as string);
|
||||
|
||||
return (
|
||||
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
||||
<ButtonGroup size={size} isAttached className={className}>
|
||||
<Button variant='ontime-subtle' tabIndex={-1}>
|
||||
{children}
|
||||
</Button>
|
||||
<IconButton aria-label={label} icon={<IoCopy />} variant='ontime-filled' tabIndex={-1} onClick={handleClick} />
|
||||
<ButtonGroup
|
||||
size={size}
|
||||
isAttached
|
||||
className={className}
|
||||
>
|
||||
<Button variant='ontime-subtle' tabIndex={-1}>{children}</Button>
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
icon={<IoCopy />}
|
||||
variant='ontime-filled'
|
||||
tabIndex={-1}
|
||||
onClick={() => navigator.clipboard.writeText(children as string)}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
/* eslint-disable react/destructuring-assignment */
|
||||
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 {
|
||||
@@ -28,12 +24,16 @@ class ErrorBoundary extends React.Component {
|
||||
});
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setExtras('error', error);
|
||||
scope.setExtras('store', runtime.getState());
|
||||
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
|
||||
scope.setExtras(error);
|
||||
const eventId = Sentry.captureException(error);
|
||||
this.setState({ eventId, info });
|
||||
});
|
||||
|
||||
try {
|
||||
this.context.emitError(error.toString());
|
||||
} catch (e) {
|
||||
Sentry.captureMessage(`Unable to emit error ${error} ${e}`);
|
||||
}
|
||||
this.reportContent = `${error} ${info.componentStack}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ import { Textarea, TextareaProps } from '@chakra-ui/react';
|
||||
// @ts-expect-error no types from library
|
||||
import autosize from 'autosize/dist/autosize';
|
||||
|
||||
export const AutoTextArea = (props: TextareaProps) => {
|
||||
interface AutoTextAreaProps extends TextareaProps {
|
||||
isDark?: boolean;
|
||||
}
|
||||
|
||||
export const AutoTextArea = (props: AutoTextAreaProps) => {
|
||||
const { isDark, ...rest } = props;
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -14,6 +19,7 @@ export const AutoTextArea = (props: TextareaProps) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
overflow='hidden'
|
||||
@@ -21,8 +27,8 @@ export const AutoTextArea = (props: TextareaProps) => {
|
||||
resize='none'
|
||||
ref={ref}
|
||||
transition='height none'
|
||||
variant='ontime-transparent'
|
||||
{...props}
|
||||
variant={isDark ? 'ontime-filled' : 'ontime-filled-onlight'}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function Swatch(props: SwatchProps) {
|
||||
|
||||
if (!color) {
|
||||
return (
|
||||
<div className={`${classes} ${style.center}`} onClick={() => onClick('')}>
|
||||
<div className={`${classes} ${style.center}`}>
|
||||
<IoBan />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorTitles';
|
||||
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor';
|
||||
|
||||
import Swatch from './Swatch';
|
||||
|
||||
@@ -8,8 +8,8 @@ import style from './SwatchSelect.module.scss';
|
||||
|
||||
interface ColourInputProps {
|
||||
value: string;
|
||||
name: TitleActions;
|
||||
handleChange: (newValue: TitleActions, name: string) => void;
|
||||
name: EventEditorSubmitActions;
|
||||
handleChange: (newValue: EventEditorSubmitActions, name: string) => void;
|
||||
}
|
||||
|
||||
const colours = [
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
@use '../../../../theme/v2Styles' as *;
|
||||
|
||||
$input-font-size: 15px;
|
||||
|
||||
.delayInput {
|
||||
display: flex;
|
||||
gap: $element-spacing;
|
||||
align-items: center;
|
||||
color: $ontime-delay-text;
|
||||
font-size: $text-body-size;
|
||||
|
||||
|
||||
.inputField {
|
||||
font-size: $input-font-size;
|
||||
letter-spacing: 1px;
|
||||
max-width: 7em;
|
||||
padding-left: 16px;
|
||||
color: $ontime-delay-text
|
||||
}
|
||||
}
|
||||
|
||||
.delayOptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.inputField {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,134 +1,88 @@
|
||||
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
import { useEventAction } from '../../../hooks/useEventAction';
|
||||
import { forgivingStringToMillis } from '../../../utils/dateConfig';
|
||||
import { clamp } from '../../../utils/math';
|
||||
|
||||
import style from './DelayInput.module.scss';
|
||||
|
||||
const inputStyleProps = {
|
||||
width: 20,
|
||||
placeholder: '-',
|
||||
size: 'sm',
|
||||
color: '#E69056',
|
||||
variant: 'ontime-filled',
|
||||
fontSize: '15px',
|
||||
letterSpacing: '0.3px',
|
||||
};
|
||||
|
||||
interface DelayInputProps {
|
||||
eventId: string;
|
||||
duration: number;
|
||||
submitHandler: (value: number) => void;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export default function DelayInput(props: DelayInputProps) {
|
||||
const { eventId, duration } = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
|
||||
const [value, setValue] = useState<string>('');
|
||||
const { submitHandler, value = 0 } = props;
|
||||
const [_value, setValue] = useState(value);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// avoid wrong submit on cancel
|
||||
let ignoreChange = false;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof duration === 'undefined') {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setValue(millisToString(duration));
|
||||
}, [duration]);
|
||||
setValue(value);
|
||||
}, [value]);
|
||||
|
||||
/**
|
||||
* @description Prepare delay value for update
|
||||
* @param {string} newValue string to be parsed
|
||||
* @param {string} value string to be parsed
|
||||
*/
|
||||
const validateAndSubmit = (newValue: string) => {
|
||||
if (ignoreChange) {
|
||||
ignoreChange = false;
|
||||
return;
|
||||
}
|
||||
const validate = useCallback(
|
||||
(newValue?: string) => {
|
||||
if (newValue === '') setValue(0);
|
||||
const delayValue = clamp(Number(newValue), -60, 60);
|
||||
if (delayValue === value) return;
|
||||
setValue(delayValue);
|
||||
|
||||
const isNegative = newValue.startsWith('-');
|
||||
let newMillis = forgivingStringToMillis(newValue);
|
||||
|
||||
if (isNegative) {
|
||||
newMillis = newMillis * -1;
|
||||
}
|
||||
|
||||
if (newMillis === duration) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitChange(newMillis);
|
||||
setValue(millisToString(newMillis));
|
||||
};
|
||||
|
||||
const submitChange = (value: number) => {
|
||||
updateEvent({
|
||||
id: eventId,
|
||||
duration: value,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Selects input text on focus
|
||||
*/
|
||||
const handleFocus = () => inputRef.current?.select();
|
||||
submitHandler(delayValue);
|
||||
},
|
||||
[submitHandler, value],
|
||||
);
|
||||
|
||||
/**
|
||||
* @description Handles common keys for submit and cancel
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
const onKeyDownHandler = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
inputRef.current?.blur();
|
||||
validateAndSubmit((event.target as HTMLInputElement).value);
|
||||
} else if (event.key === 'Tab') {
|
||||
validateAndSubmit((event.target as HTMLInputElement).value);
|
||||
} else if (event.key === 'Escape') {
|
||||
ignoreChange = true;
|
||||
setValue(millisToString(duration));
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description handles direction change to delay
|
||||
* @param newDirection
|
||||
*/
|
||||
const handleSlipChange = (newDirection: 'add' | 'subtract') => {
|
||||
if (newDirection === 'add') {
|
||||
// add time
|
||||
if (duration < 0) {
|
||||
submitChange(duration * -1);
|
||||
const onKeyDownHandler = useCallback(
|
||||
(key: string) => {
|
||||
if (key === 'Enter') {
|
||||
inputRef.current?.blur();
|
||||
validate(inputRef.current?.value);
|
||||
} else if (key === 'Escape') {
|
||||
inputRef.current?.blur();
|
||||
setValue(value);
|
||||
}
|
||||
} else if (newDirection === 'subtract') {
|
||||
// subtract time
|
||||
if (duration > 0) {
|
||||
submitChange(duration * -1);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[validate, value],
|
||||
);
|
||||
|
||||
const checkedOption = value.startsWith('-') ? 'subtract' : 'add';
|
||||
const labelText = `${Math.abs(value) !== 1 ? 'minutes' : 'minute'} ${
|
||||
value !== undefined && value >= 0 ? 'delayed' : 'ahead'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={style.delayInput}>
|
||||
<label className={style.delayInput}>
|
||||
<Input
|
||||
size='sm'
|
||||
{...inputStyleProps}
|
||||
ref={inputRef}
|
||||
data-testid='delay-input'
|
||||
className={style.inputField}
|
||||
type='text'
|
||||
placeholder='-'
|
||||
variant='ontime-filled'
|
||||
onFocus={handleFocus}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={(event) => validateAndSubmit(event.target.value)}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
value={value}
|
||||
maxLength={9}
|
||||
value={_value}
|
||||
onChange={(event) => setValue(Number(event.target.value))}
|
||||
onBlur={(event) => validate(event.target.value)}
|
||||
onKeyDown={(event) => onKeyDownHandler(event.key)}
|
||||
type='number'
|
||||
/>
|
||||
<RadioGroup
|
||||
className={style.delayOptions}
|
||||
onChange={handleSlipChange}
|
||||
value={checkedOption}
|
||||
variant='ontime-block'
|
||||
size='sm'
|
||||
>
|
||||
<Radio value='add'>Add time</Radio>
|
||||
<Radio value='subtract'>Subtract time</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
{labelText}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
@use "../../../../theme/_ontimeColours" as *;
|
||||
@use "../../../../theme/_v2Styles" as *;
|
||||
|
||||
.swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0 0 0 1px $gray-300;
|
||||
cursor: pointer;
|
||||
|
||||
transition-property: box-shadow;
|
||||
transition-duration: $transition-time-action;
|
||||
}
|
||||
|
||||
.swatch:hover {
|
||||
box-shadow: 0 0 0 1px $blue-300;
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { HexAlphaColorPicker } from 'react-colorful';
|
||||
import { useController, UseControllerProps } from 'react-hook-form';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import style from './PopoverPicker.module.scss';
|
||||
|
||||
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
|
||||
const { name, control } = props;
|
||||
const {
|
||||
field: { onChange, value },
|
||||
} = useController({ control, name });
|
||||
|
||||
return <PopoverPicker color={value as string} onChange={onChange} />;
|
||||
}
|
||||
|
||||
interface PopoverPickerProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export default function PopoverPicker(props: PopoverPickerProps) {
|
||||
const { color, onChange } = props;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<div className={style.swatch} style={{ backgroundColor: color }} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent style={{ width: 'auto' }}>
|
||||
<HexAlphaColorPicker color={color} onChange={onChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
@@ -11,7 +11,6 @@ import { TimeEntryField } from '../../../utils/timesManager';
|
||||
import style from './TimeInput.module.scss';
|
||||
|
||||
interface TimeInputProps {
|
||||
id?: TimeEntryField;
|
||||
name: TimeEntryField;
|
||||
submitHandler: (field: TimeEntryField, value: number) => void;
|
||||
time?: number;
|
||||
@@ -22,38 +21,22 @@ interface TimeInputProps {
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
function ButtonInitial(name: TimeEntryField) {
|
||||
if (name === 'timeStart') return 'S';
|
||||
if (name === 'timeEnd') return 'E';
|
||||
if (name === 'durationOverride') return 'D';
|
||||
return '';
|
||||
}
|
||||
|
||||
function ButtonTooltip(name: TimeEntryField, warning?: string) {
|
||||
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`;
|
||||
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`;
|
||||
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function TimeInput(props: TimeInputProps) {
|
||||
const { id, name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props;
|
||||
const { name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [value, setValue] = useState<string>('');
|
||||
const ignoreChange = useRef(false);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
/**
|
||||
* @description Resets input value to given
|
||||
*/
|
||||
const resetValue = useCallback(() => {
|
||||
try {
|
||||
setValue(millisToString(time));
|
||||
setValue(millisToString(time + delay));
|
||||
} catch (error) {
|
||||
setValue(millisToString(0));
|
||||
emitError(`Unable to parse time ${time}: ${error}`);
|
||||
emitError(`Unable to parse date: ${error}`);
|
||||
}
|
||||
}, [emitError, time]);
|
||||
}, [delay, emitError, time]);
|
||||
|
||||
/**
|
||||
* @description Selects input text on focus
|
||||
@@ -90,8 +73,11 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
newValMillis = forgivingStringToMillis(newValue);
|
||||
}
|
||||
|
||||
// Time now and time submittedVal
|
||||
const originalMillis = time + delay;
|
||||
|
||||
// check if time is different from before
|
||||
if (newValMillis === time) return false;
|
||||
if (newValMillis === originalMillis) return false;
|
||||
|
||||
// validate with parent
|
||||
if (!validationHandler(name, newValMillis)) return false;
|
||||
@@ -101,7 +87,7 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
|
||||
return true;
|
||||
},
|
||||
[name, previousEnd, submitHandler, time, validationHandler],
|
||||
[delay, name, previousEnd, submitHandler, time, validationHandler],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -113,13 +99,12 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
const success = handleSubmit(newValue);
|
||||
if (success) {
|
||||
const ms = forgivingStringToMillis(newValue);
|
||||
const delayed = name === 'timeEnd' ? Math.max(0, ms + delay) : Math.max(0, ms + delay);
|
||||
setValue(millisToString(delayed));
|
||||
setValue(millisToString(ms + delay));
|
||||
} else {
|
||||
resetValue();
|
||||
}
|
||||
},
|
||||
[delay, handleSubmit, name, resetValue],
|
||||
[delay, handleSubmit, resetValue],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -135,7 +120,6 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
validateAndSubmit((event.target as HTMLInputElement).value);
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
ignoreChange.current = true;
|
||||
inputRef.current?.blur();
|
||||
resetValue();
|
||||
}
|
||||
@@ -145,10 +129,6 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
|
||||
const onBlurHandler = useCallback(
|
||||
(event: FocusEvent<HTMLInputElement>) => {
|
||||
if (ignoreChange.current) {
|
||||
ignoreChange.current = false;
|
||||
return;
|
||||
}
|
||||
validateAndSubmit((event.target as HTMLInputElement).value);
|
||||
},
|
||||
[validateAndSubmit],
|
||||
@@ -157,24 +137,31 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
useEffect(() => {
|
||||
if (time == null) return;
|
||||
resetValue();
|
||||
}, [resetValue, time]);
|
||||
}, [emitError, resetValue, time]);
|
||||
|
||||
const isDelayed = delay != null && delay !== 0;
|
||||
|
||||
const ButtonInitial = () => {
|
||||
if (name === 'timeStart') return 'S';
|
||||
if (name === 'timeEnd') return 'E';
|
||||
if (name === 'durationOverride') return 'D';
|
||||
return '';
|
||||
};
|
||||
|
||||
const ButtonTooltip = () => {
|
||||
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`;
|
||||
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`;
|
||||
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`;
|
||||
return '';
|
||||
};
|
||||
|
||||
const isDelayed = delay !== 0;
|
||||
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
|
||||
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null, warning ? style.warn : null]);
|
||||
|
||||
const TooltipLabel = useMemo(() => {
|
||||
return ButtonTooltip(name, warning);
|
||||
}, [name, warning]);
|
||||
|
||||
const ButtonText = useMemo(() => {
|
||||
return ButtonInitial(name);
|
||||
}, [name]);
|
||||
|
||||
return (
|
||||
<InputGroup size='sm' className={inputClasses}>
|
||||
<InputLeftElement className={style.inputLeft}>
|
||||
<Tooltip label={TooltipLabel} openDelay={tooltipDelayFast} variant='ontime-ondark'>
|
||||
<Tooltip label={ButtonTooltip()} openDelay={tooltipDelayFast} variant='ontime-ondark'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ontime-subtle-white'
|
||||
@@ -184,13 +171,12 @@ export default function TimeInput(props: TimeInputProps) {
|
||||
borderRight='1px solid transparent'
|
||||
borderRadius='2px 0 0 2px'
|
||||
>
|
||||
{ButtonText}
|
||||
{ButtonInitial()}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
data-testid='time-input'
|
||||
className={style.inputField}
|
||||
type='text'
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
$progress-bar-size: 12px;
|
||||
$progress-bar-br: 3px;
|
||||
|
||||
.multiprogress-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: $progress-bar-size;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||
display: flex;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
transition: display 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
.multiprogress-bar__indicator {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
background-color: black;
|
||||
opacity: 0.8;
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-normal {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
border-radius: $progress-bar-br;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-warning {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
border-radius: 0 $progress-bar-br $progress-bar-br 0;
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-danger {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
border-radius: 0 $progress-bar-br $progress-bar-br 0;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { clamp } from '../../utils/math';
|
||||
|
||||
import './MultiPartProgressBar.scss';
|
||||
|
||||
interface MultiPartProgressBar {
|
||||
now: number;
|
||||
complete: number;
|
||||
normalColor: string;
|
||||
warning: number;
|
||||
warningColor: string;
|
||||
danger: number;
|
||||
dangerColor: string;
|
||||
hidden?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
|
||||
|
||||
const percentComplete = 100 - clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
|
||||
|
||||
const dangerWidth = clamp((danger / complete) * 100, 0, 100);
|
||||
const warningWidth = clamp((warning / complete) * 100, 0, 100);
|
||||
|
||||
return (
|
||||
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
|
||||
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
||||
<div className='multiprogress-bar__bg-warning' style={{ width: `${warningWidth}%`, backgroundColor: warningColor }} />
|
||||
<div className='multiprogress-bar__bg-danger' style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }} />
|
||||
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/mixins' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
@use "../../../theme/v2Styles" as *;
|
||||
@use "../../../theme/mixins" as *;
|
||||
@use "../../../theme/ontimeColours" as *;
|
||||
|
||||
$menu-bg: $gray-1200;
|
||||
$menu-hover-bg: $gray-1350;
|
||||
@@ -14,27 +14,14 @@ $button-size: 48px;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 1rem;
|
||||
padding: 0.5em;
|
||||
|
||||
.navButton {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
left: 0.5em;
|
||||
top: 0.5em;
|
||||
transition-property: opacity;
|
||||
transition-duration: 0.3s;
|
||||
|
||||
opacity: 1;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 24px;
|
||||
color: $icon-color;
|
||||
background-color: $button-bg;
|
||||
@@ -43,11 +30,10 @@ $button-size: 48px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.navButton {
|
||||
@extend .button;
|
||||
z-index: 3;
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.menuContainer {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { KeyboardEvent, memo, useEffect, useRef, useState } from 'react';
|
||||
import { KeyboardEvent, 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 { Link, useLocation } from 'react-router-dom';
|
||||
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';
|
||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoPencilSharp } from '@react-icons/all-files/io5/IoPencilSharp';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
@@ -15,25 +13,20 @@ 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';
|
||||
|
||||
function NavigationMenu() {
|
||||
export default function NavigationMenu() {
|
||||
const location = useLocation();
|
||||
|
||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const toggleMenu = () => setShowMenu((prev) => !prev);
|
||||
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen });
|
||||
useKeyDown(toggleMenu, ' ');
|
||||
|
||||
useEffect(() => {
|
||||
let fadeOut: NodeJS.Timeout | null = null;
|
||||
@@ -57,84 +50,62 @@ function NavigationMenu() {
|
||||
const handleFullscreen = () => toggleFullScreen();
|
||||
const handleMirror = () => toggleMirror();
|
||||
|
||||
const showEditFormDrawer = () => {
|
||||
searchParams.append('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 />
|
||||
</button>
|
||||
<button className={style.button} onClick={showEditFormDrawer}>
|
||||
<IoPencilSharp />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<div className={style.menuContainer} data-testid='navigation-menu'>
|
||||
<div className={style.buttonsContainer}>
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={handleFullscreen}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && handleFullscreen();
|
||||
}}
|
||||
>
|
||||
Toggle Fullscreen
|
||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||
</div>
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={handleMirror}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && handleMirror();
|
||||
}}
|
||||
>
|
||||
Flip Screen
|
||||
<IoSwapVertical />
|
||||
</div>
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && onOpen();
|
||||
}}
|
||||
>
|
||||
Rename Client
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
className={`${style.navButton} ${!showButton && !showMenu ? style.hidden : ''}`}
|
||||
>
|
||||
<IoApps />
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className={style.menuContainer} data-testid='navigation-menu'>
|
||||
<div className={style.buttonsContainer}>
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={handleFullscreen}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && handleFullscreen();
|
||||
}}
|
||||
>
|
||||
Toggle Fullscreen
|
||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||
</div>
|
||||
<hr className={style.separator} />
|
||||
<Link to='/cuesheet' className={style.link} tabIndex={0}>
|
||||
Cuesheet
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={handleMirror}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && handleMirror();
|
||||
}}
|
||||
>
|
||||
Flip Screen
|
||||
<IoSwapVertical />
|
||||
</div>
|
||||
{/*<div className={style.link} tabIndex={0}>*/}
|
||||
{/* Rename Client*/}
|
||||
{/*</div>*/}
|
||||
</div>
|
||||
<hr className={style.separator} />
|
||||
{navigatorConstants.map((route) => (
|
||||
<Link
|
||||
key={route.url}
|
||||
to={route.url}
|
||||
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
{route.label}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<hr className={style.separator} />
|
||||
{navigatorConstants.map((route) => (
|
||||
<Link
|
||||
key={route.url}
|
||||
to={route.url}
|
||||
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
{route.label}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(NavigationMenu);
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.modalBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,8 @@ $progress-bar-br: 6px;
|
||||
|
||||
.progress-bar__indicator {
|
||||
height: $progress-bar-size;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--timer-progress-override, $accent-color);
|
||||
transition: 1s linear;
|
||||
border-radius: $progress-bar-br 0 0 $progress-bar-br;
|
||||
transition-property: width;
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
|
||||
import style from './ProtectRoute.module.scss';
|
||||
|
||||
interface PinPageProps {
|
||||
permission: 'editor' | 'operator';
|
||||
handleValidation: (pin: string) => boolean;
|
||||
}
|
||||
|
||||
export default function PinPage(props: PinPageProps) {
|
||||
const { permission, handleValidation } = props;
|
||||
const [pin, setPin] = useState('');
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const isValid = handleValidation(pin);
|
||||
if (!isValid) {
|
||||
setFailed(true);
|
||||
setPin('');
|
||||
}
|
||||
}, [handleValidation, pin]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return;
|
||||
if (event.key === 'Enter') {
|
||||
validate();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, [validate]);
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
{`Ontime ${permission || ''}`}
|
||||
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
|
||||
<PinInput
|
||||
type='alphanumeric'
|
||||
size='lg'
|
||||
mask
|
||||
autoFocus
|
||||
value={pin}
|
||||
onChange={(value) => {
|
||||
setFailed(false);
|
||||
setPin(value);
|
||||
}}
|
||||
>
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
</PinInput>
|
||||
<IconButton aria-label='Enter' size='lg' isRound icon={<IoCheckmark />} onClick={validate} />
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { FiCheck } from '@react-icons/all-files/fi/FiCheck';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { AppContext } from '../../context/AppContext';
|
||||
|
||||
import style from './ProtectRoute.module.scss';
|
||||
|
||||
export default function ProtectRoute({ children }) {
|
||||
const isLocal =
|
||||
window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
const [pin, setPin] = useState('');
|
||||
const [failed, setFailed] = useState(false);
|
||||
const { auth, validate } = useContext(AppContext);
|
||||
|
||||
const handleValidation = useCallback(() => {
|
||||
const r = validate(pin);
|
||||
if (!r) {
|
||||
setFailed(true);
|
||||
setPin('');
|
||||
}
|
||||
}, [pin, validate]);
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
document.title = 'ontime';
|
||||
}, []);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyPress = useCallback(
|
||||
(e) => {
|
||||
// handle held key
|
||||
if (e.repeat) return;
|
||||
// Space bar
|
||||
if (e.keyCode === 13) {
|
||||
handleValidation();
|
||||
}
|
||||
},
|
||||
[handleValidation]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// attach the event listener
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
// remove the event listener
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, [handleKeyPress]);
|
||||
|
||||
if (isLocal || auth) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
ontime
|
||||
<HStack spacing='10px' className={failed ? style.pin__failed : style.pin}>
|
||||
<PinInput
|
||||
type='alphanumeric'
|
||||
size='lg'
|
||||
mask
|
||||
autoFocus
|
||||
value={pin}
|
||||
onChange={(value) => {
|
||||
setFailed(false);
|
||||
setPin(value);
|
||||
}}
|
||||
>
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
</PinInput>
|
||||
<IconButton
|
||||
aria-label='Enter'
|
||||
size='lg'
|
||||
isRound
|
||||
icon={<FiCheck />}
|
||||
onClick={() => handleValidation()}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ProtectRoute.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import { PropsWithChildren, useCallback, useContext } from 'react';
|
||||
|
||||
import { AppContext } from '../../context/AppContext';
|
||||
|
||||
import PinPage from './PinPage';
|
||||
|
||||
interface ProtectRouteProps {
|
||||
permission: 'editor' | 'operator';
|
||||
}
|
||||
|
||||
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
|
||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
const { editorAuth, operatorAuth, validate } = useContext(AppContext);
|
||||
|
||||
const handleValidation = useCallback(
|
||||
(pin: string) => {
|
||||
return validate(pin, permission);
|
||||
},
|
||||
[permission, validate],
|
||||
);
|
||||
|
||||
const hasRelevantAuth = () => {
|
||||
if (permission === 'editor') {
|
||||
return editorAuth;
|
||||
}
|
||||
if (permission === 'operator') {
|
||||
return operatorAuth;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isLocal || hasRelevantAuth()) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <PinPage permission={permission} handleValidation={handleValidation} />;
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-bottom: 8px;
|
||||
padding-bottom: clamp(16px, 1.5vw, 24px);
|
||||
}
|
||||
|
||||
&--past {
|
||||
|
||||
@@ -10,7 +10,7 @@ interface ScheduleProps {
|
||||
}
|
||||
|
||||
export default function Schedule({ className }: ScheduleProps) {
|
||||
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
||||
const { paginatedEvents, selectedEventId, isBackstage } = useSchedule();
|
||||
|
||||
if (paginatedEvents?.length < 1) {
|
||||
return <Empty text='No events to show' />;
|
||||
@@ -21,14 +21,10 @@ export default function Schedule({ className }: ScheduleProps) {
|
||||
return (
|
||||
<ul className={`schedule ${className}`}>
|
||||
{paginatedEvents.map((event) => {
|
||||
if (scheduleType === 'past' || scheduleType === 'future') {
|
||||
selectedState = scheduleType;
|
||||
} else {
|
||||
if (event.id === selectedEventId) {
|
||||
selectedState = 'now';
|
||||
} else if (selectedState === 'now') {
|
||||
selectedState = 'future';
|
||||
}
|
||||
if (event.id === selectedEventId) {
|
||||
selectedState = 'now';
|
||||
} else if (selectedState === 'now') {
|
||||
selectedState = 'future';
|
||||
}
|
||||
return (
|
||||
<ScheduleItem
|
||||
|
||||
@@ -6,8 +6,7 @@ import { useInterval } from '../../hooks/useInterval';
|
||||
interface ScheduleContextState {
|
||||
events: OntimeEvent[];
|
||||
paginatedEvents: OntimeEvent[];
|
||||
selectedEventId: string | null;
|
||||
scheduleType: 'past' | 'now' | 'future';
|
||||
selectedEventId: string;
|
||||
numPages: number;
|
||||
visiblePage: number;
|
||||
isBackstage: boolean;
|
||||
@@ -17,38 +16,28 @@ const ScheduleContext = createContext<ScheduleContextState | undefined>(undefine
|
||||
|
||||
interface ScheduleProviderProps {
|
||||
events: OntimeEvent[];
|
||||
selectedEventId: string | null;
|
||||
selectedEventId: string;
|
||||
isBackstage?: boolean;
|
||||
eventsPerPage?: number;
|
||||
time?: number;
|
||||
}
|
||||
|
||||
export const ScheduleProvider = ({
|
||||
children,
|
||||
events,
|
||||
selectedEventId,
|
||||
isBackstage = false,
|
||||
eventsPerPage = 7,
|
||||
time = 10,
|
||||
}: PropsWithChildren<ScheduleProviderProps>) => {
|
||||
export const ScheduleProvider = (
|
||||
{
|
||||
children,
|
||||
events,
|
||||
selectedEventId,
|
||||
isBackstage = false,
|
||||
eventsPerPage = 4,
|
||||
time = 10,
|
||||
}: PropsWithChildren<ScheduleProviderProps>) => {
|
||||
|
||||
const [visiblePage, setVisiblePage] = useState(0);
|
||||
|
||||
const numPages = Math.ceil(events.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 resolveScheduleType = () => {
|
||||
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
|
||||
return 'now';
|
||||
}
|
||||
if (selectedEventIndex > eventEnd) {
|
||||
return 'past';
|
||||
}
|
||||
return 'future';
|
||||
};
|
||||
const scheduleType = resolveScheduleType();
|
||||
|
||||
// every SCROLL_TIME go to the next array
|
||||
useInterval(() => {
|
||||
@@ -64,7 +53,6 @@ export const ScheduleProvider = ({
|
||||
events,
|
||||
paginatedEvents,
|
||||
selectedEventId,
|
||||
scheduleType,
|
||||
numPages,
|
||||
visiblePage,
|
||||
isBackstage,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
text-align: center;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
font-size: 3.5rem;
|
||||
font-size: 3.75em;
|
||||
|
||||
&--finished {
|
||||
color: $timer-finished-color;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
import { formatDisplay } from 'ontime-utils';
|
||||
|
||||
import { formatDisplay } from '../../utils/dateConfig';
|
||||
|
||||
import './TimerDisplay.scss';
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import './TitleCard.scss';
|
||||
|
||||
interface TitleCardProps {
|
||||
label: 'now' | 'next';
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
presenter: string | null;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
presenter: string;
|
||||
}
|
||||
|
||||
export default function TitleCard(props: TitleCardProps) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
.modalBody {
|
||||
min-height: 40vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.options {
|
||||
margin-bottom: 1.5em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.info {
|
||||
background-color: $bg-container-onlight;
|
||||
margin: 1em 0;
|
||||
padding: 0.5em;
|
||||
border-radius: 2px;
|
||||
color: $text-black;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.corner {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
}
|
||||
|
||||
.infoList {
|
||||
font-size: 0.9em;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.flexColumnLeft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Progress,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoCloseSharp } from '@react-icons/all-files/io5/IoCloseSharp';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { RUNDOWN_TABLE } from '../../api/apiConstants';
|
||||
import { uploadData } from '../../api/ontimeApi';
|
||||
import { useEmitLog } from '../../stores/logger';
|
||||
import TooltipActionBtn from '../buttons/TooltipActionBtn';
|
||||
|
||||
import { validateFile } from './utils';
|
||||
|
||||
import style from './UploadModal.module.scss';
|
||||
|
||||
interface UploadModalProps {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { emitError } = useEmitLog();
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const overrideOptionRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
const fileUploaded = event?.target?.files?.[0];
|
||||
if (!fileUploaded) return;
|
||||
|
||||
const validate = validateFile(fileUploaded);
|
||||
setErrors(validate.errors);
|
||||
|
||||
if (validate.isValid) {
|
||||
setFile(fileUploaded);
|
||||
} else {
|
||||
setFile(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (file) {
|
||||
try {
|
||||
await uploadData(file, setProgress, { onlyEvents: overrideOptionRef?.current?.checked });
|
||||
} catch (error) {
|
||||
emitError(`Failed uploading file: ${error}`);
|
||||
} finally {
|
||||
await queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
setFile(null);
|
||||
}
|
||||
}
|
||||
}, [emitError, file, queryClient]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
isOpen={isOpen}
|
||||
closeOnOverlayClick={false}
|
||||
motionPreset='slideInBottom'
|
||||
size='xl'
|
||||
scrollBehavior='inside'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>File upload</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody className={style.modalBody}>
|
||||
<FormControl isInvalid={errors.length > 0}>
|
||||
<FormLabel>Select file to upload</FormLabel>
|
||||
<Input type='file' onChange={handleFile} accept='.json, .xlsx' />
|
||||
{errors.length === 0 ? (
|
||||
<FormHelperText>.XLSX .JSON with max 1MB</FormHelperText>
|
||||
) : (
|
||||
<FormErrorMessage className={style.flexColumnLeft}>
|
||||
{errors.map((error) => (
|
||||
<span key={error}>{error}</span>
|
||||
))}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<div className={style.options}>
|
||||
<b>Options</b>
|
||||
<Checkbox ref={overrideOptionRef}>Import only events</Checkbox>
|
||||
<span className={style.notes}>This will prevent overriding user settings</span>
|
||||
</div>
|
||||
{file && (
|
||||
<div className={style.info}>
|
||||
<span>File ready to upload</span>
|
||||
<TooltipActionBtn
|
||||
clickHandler={() => setFile(null)}
|
||||
tooltip='Cancel'
|
||||
aria-label='Cancel'
|
||||
className={style.corner}
|
||||
size='sm'
|
||||
variant='ghosted'
|
||||
icon={<IoCloseSharp />}
|
||||
/>
|
||||
<ul className={style.infoList}>
|
||||
<li>{file.name}</li>
|
||||
<li>{`${(file.size / 1024).toFixed(2)}kb`}</li>
|
||||
<li>{file.type}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<Progress value={progress} />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
colorScheme='blue'
|
||||
disabled={!file || errors.length > 0}
|
||||
onClick={handleUpload}
|
||||
isLoading={progress < 0 && progress >= 100}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ type ValidationStatus = {
|
||||
};
|
||||
|
||||
export function validateFile(file: File): ValidationStatus {
|
||||
const status: ValidationStatus = { errors: [], isValid: true };
|
||||
const status:ValidationStatus = { errors: [], isValid: true };
|
||||
if (!file) {
|
||||
status.errors.push('No file to upload');
|
||||
status.isValid = false;
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Input, Select, Switch } from '@chakra-ui/react';
|
||||
|
||||
import { isStringBoolean } from '../../utils/viewUtils';
|
||||
|
||||
import { ParamField } from './types';
|
||||
|
||||
interface EditFormInputProps {
|
||||
paramField: ParamField;
|
||||
}
|
||||
|
||||
export default function ParamInput({ paramField }: EditFormInputProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { id, type } = paramField;
|
||||
|
||||
if (type === 'option') {
|
||||
const optionFromParams = searchParams.get(id);
|
||||
const defaultOptionValue = optionFromParams || undefined;
|
||||
|
||||
return (
|
||||
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}>
|
||||
{Object.entries(paramField.values).map(([key, value]) => (
|
||||
<option key={key} value={key}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) ?? false;
|
||||
|
||||
// checked value should be 'true', so it can be captured by the form event
|
||||
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
const defaultNumberValue = searchParams.get(id) ?? '';
|
||||
|
||||
return <Input type='number' step='any' variant='ontime-filled' name={id} defaultValue={defaultNumberValue} />;
|
||||
}
|
||||
|
||||
const defaultStringValue = searchParams.get(id) ?? '';
|
||||
|
||||
return <Input variant='ontime-filled' name={id} defaultValue={defaultStringValue} />;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
.drawerContent {
|
||||
background-color: $gray-1200;
|
||||
}
|
||||
|
||||
.drawerHeader {
|
||||
@extend .drawerContent;
|
||||
color: $section-white;
|
||||
}
|
||||
|
||||
.drawerFooter {
|
||||
@extend .drawerContent;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
gap: $element-spacing;
|
||||
|
||||
button[type='reset'] {
|
||||
padding: 0 2em;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
padding: 0 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.columnSection {
|
||||
display: flex;
|
||||
padding: $element-spacing;
|
||||
flex-direction: column;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
color: $modal-note-color;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { FormEvent, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import ParamInput from './ParamInput';
|
||||
import { ParamField } from './types';
|
||||
|
||||
import style from './ViewParamsEditor.module.scss';
|
||||
|
||||
interface EditFormDrawerProps {
|
||||
paramFields: ParamField[];
|
||||
}
|
||||
|
||||
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
|
||||
useEffect(() => {
|
||||
const isEditing = searchParams.get('edit');
|
||||
|
||||
if (isEditing === 'true') {
|
||||
return onOpen();
|
||||
}
|
||||
}, [searchParams, onOpen]);
|
||||
|
||||
const onEditDrawerClose = () => {
|
||||
onClose();
|
||||
|
||||
searchParams.delete('edit');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const clearParams = () => {
|
||||
setSearchParams();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||
formEvent.preventDefault();
|
||||
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = Object.entries(newParamsObject).reduce((newSearchParams, [id, value]) => {
|
||||
if (typeof value === 'string' && value.length) {
|
||||
newSearchParams.set(id, value);
|
||||
|
||||
return newSearchParams;
|
||||
}
|
||||
|
||||
return newSearchParams;
|
||||
}, new URLSearchParams());
|
||||
setSearchParams(newSearchParams);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer isOpen={isOpen} placement='right' onClose={onEditDrawerClose} size='lg'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerHeader className={style.drawerHeader}>
|
||||
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' />
|
||||
Customise
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody className={style.drawerContent}>
|
||||
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
|
||||
{paramFields.map((field) => (
|
||||
<div key={field.title} className={style.columnSection}>
|
||||
<label className={style.label}>
|
||||
<span className={style.title}>{field.title}</span>
|
||||
<span className={style.description}>{field.description}</span>
|
||||
<ParamInput key={field.title} paramField={field} />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
</DrawerBody>
|
||||
|
||||
<DrawerFooter className={style.drawerFooter}>
|
||||
<Button variant='ontime-ghosted' onClick={clearParams} type='reset'>
|
||||
Clear
|
||||
</Button>
|
||||
<Button variant='ontime-subtle' onClick={onEditDrawerClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
|
||||
Save
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { ParamField } from './types';
|
||||
|
||||
export const TIME_FORMAT_OPTION: ParamField = {
|
||||
id: 'format',
|
||||
title: '12 / 24 hour timer',
|
||||
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
|
||||
type: 'option',
|
||||
values: { '12': '12 hour AM/PM', '24': '24 hour' },
|
||||
};
|
||||
|
||||
export const CLOCK_OPTIONS: ParamField[] = [
|
||||
TIME_FORMAT_OPTION,
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Background colour in hexadecimal',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Text Colour',
|
||||
description: 'Text colour in hexadecimal',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'textbg',
|
||||
title: 'Text Background',
|
||||
description: 'Colour of text background in hexadecimal',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'font',
|
||||
title: 'Font',
|
||||
description: 'Font family, will use the fonts available in the system',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
title: 'Text Size',
|
||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
id: 'alignx',
|
||||
title: 'Align Horizontal',
|
||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
||||
type: 'option',
|
||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||
},
|
||||
{
|
||||
id: 'offsetx',
|
||||
title: 'Offset Horizontal',
|
||||
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
id: 'aligny',
|
||||
title: 'Align Vertical',
|
||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
||||
type: 'option',
|
||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||
},
|
||||
{
|
||||
id: 'offsety',
|
||||
title: 'Offset Vertical',
|
||||
description: 'Offsets the timer vertical position by a given amount in pixels',
|
||||
type: 'number',
|
||||
},
|
||||
];
|
||||
|
||||
export const TIMER_OPTIONS: ParamField[] = [TIME_FORMAT_OPTION];
|
||||
|
||||
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Background colour in hexadecimal',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Text Colour',
|
||||
description: 'Text colour in hexadecimal',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'textbg',
|
||||
title: 'Text Background',
|
||||
description: 'Colour of text background in hexadecimal',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'font',
|
||||
title: 'Font',
|
||||
description: 'Font family, will use the fonts available in the system',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
title: 'Text Size',
|
||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
id: 'alignx',
|
||||
title: 'Align Horizontal',
|
||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
||||
type: 'option',
|
||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||
},
|
||||
{
|
||||
id: 'offsetx',
|
||||
title: 'Offset Horizontal',
|
||||
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
id: 'aligny',
|
||||
title: 'Align Vertical',
|
||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
||||
type: 'option',
|
||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||
},
|
||||
{
|
||||
id: 'offsety',
|
||||
title: 'Offset Vertical',
|
||||
description: 'Offsets the timer vertical position by a given amount in pixels',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
id: 'hideovertime',
|
||||
title: 'Hide Overtime',
|
||||
description: 'Whether to supress overtime styles (red borders and red text)',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'hidemessages',
|
||||
title: 'Hide Message Overlay',
|
||||
description: 'Whether to hide the overlay from showing timer screen messages',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'hideendmessage',
|
||||
title: 'Hide End Message',
|
||||
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
|
||||
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
|
||||
{
|
||||
id: 'preset',
|
||||
title: 'Preset',
|
||||
description: 'Selects a style preset (0-1)',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
title: 'Size',
|
||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
id: 'transition',
|
||||
title: 'Transition',
|
||||
description: 'Transition in time in seconds (default 5)',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Text Colour',
|
||||
description: 'Text colour in hexadecimal',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'bg',
|
||||
title: 'Text Background',
|
||||
description: 'Text background colour in hexadecimal',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Screen background colour in hexadecimal',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'fadeout',
|
||||
title: 'Fadeout',
|
||||
description: 'Time (in seconds) the lower third displays before fading out',
|
||||
type: 'number',
|
||||
},
|
||||
];
|
||||
|
||||
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
||||
TIME_FORMAT_OPTION,
|
||||
{
|
||||
id: 'seconds',
|
||||
title: 'Show Seconds',
|
||||
description: 'Shows seconds in clock',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
@@ -1,12 +0,0 @@
|
||||
type BaseField = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type OptionsField = { type: 'option'; values: Record<string, string> };
|
||||
type StringField = { type: 'string' };
|
||||
type BooleanField = { type: 'boolean' };
|
||||
type NumberField = { type: 'number' };
|
||||
|
||||
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createContext, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import useSettings from '../hooks-query/useSettings';
|
||||
|
||||
export const AppContext = createContext({
|
||||
auth: false,
|
||||
data: {
|
||||
pinCode: null,
|
||||
},
|
||||
});
|
||||
|
||||
export const AppContextProvider = ({ children }) => {
|
||||
const [auth, setAuth] = useState(true);
|
||||
const { data } = useSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
const previousEntry = sessionStorage.getItem('ontime-entry');
|
||||
if (previousEntry) {
|
||||
if (previousEntry === data?.pinCode) {
|
||||
setAuth(true);
|
||||
} else {
|
||||
sessionStorage.removeItem('ontime-entry');
|
||||
}
|
||||
} else if (data?.pinCode == null || data?.pinCode === '') {
|
||||
setAuth(true);
|
||||
} else {
|
||||
setAuth(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
/**
|
||||
* Validates a pincode
|
||||
* @return boolean - whether the pin is valid
|
||||
*/
|
||||
const validate = useCallback(
|
||||
(pin) => {
|
||||
let correct;
|
||||
if (data?.pinCode == null || data?.pinCode === '') {
|
||||
correct = true;
|
||||
} else {
|
||||
correct = pin === data?.pinCode;
|
||||
}
|
||||
if (correct) {
|
||||
sessionStorage.setItem('ontime-entry', pin);
|
||||
}
|
||||
setAuth(correct);
|
||||
return correct;
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return <AppContext.Provider value={{ auth, validate }}>{children}</AppContext.Provider>;
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import useSettings from '../hooks-query/useSettings';
|
||||
|
||||
interface AppContextType {
|
||||
editorAuth: boolean;
|
||||
operatorAuth: boolean;
|
||||
validate: (pin: string, permission: 'editor' | 'operator') => boolean;
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppContextType>({
|
||||
editorAuth: false,
|
||||
operatorAuth: false,
|
||||
validate: () => false,
|
||||
});
|
||||
|
||||
const storageKeys = {
|
||||
editor: 'ontime-editor-entry',
|
||||
operator: 'ontime-operator-entry',
|
||||
};
|
||||
|
||||
export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
const { status, data } = useSettings();
|
||||
const [editorAuth, setEditorAuth] = useState(true);
|
||||
const [operatorAuth, setOperatorAuth] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'loading') return;
|
||||
if (!data) return;
|
||||
const previousEditor = sessionStorage.getItem(storageKeys.editor);
|
||||
|
||||
if (previousEditor && previousEditor === data.editorKey) {
|
||||
setEditorAuth(true);
|
||||
} else {
|
||||
setEditorAuth(data.editorKey == null || data.editorKey === '');
|
||||
}
|
||||
|
||||
const previousOperator = sessionStorage.getItem(storageKeys.operator);
|
||||
if (previousOperator && previousOperator === data.operatorKey) {
|
||||
setOperatorAuth(true);
|
||||
} else {
|
||||
setOperatorAuth(data.operatorKey == null || data.operatorKey === '');
|
||||
}
|
||||
}, [data, status]);
|
||||
|
||||
/**
|
||||
* Validates a pincode
|
||||
* @return boolean - whether the pin is valid
|
||||
*/
|
||||
const validate = useCallback(
|
||||
(pin: string, permission: 'editor' | 'operator'): boolean => {
|
||||
function isValid(pin: string, savedPin?: string | null): boolean {
|
||||
return savedPin == null || savedPin === '' || pin === savedPin;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (permission === 'editor') {
|
||||
const correct = isValid(pin, data.editorKey);
|
||||
if (correct) {
|
||||
sessionStorage.setItem(storageKeys.editor, pin);
|
||||
}
|
||||
setEditorAuth(correct);
|
||||
return correct;
|
||||
} else if (permission === 'operator') {
|
||||
const correct = isValid(pin, data.operatorKey);
|
||||
if (correct) {
|
||||
sessionStorage.setItem(storageKeys.operator, pin);
|
||||
}
|
||||
setOperatorAuth(correct);
|
||||
return correct;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return <AppContext.Provider value={{ editorAuth, operatorAuth, validate }}>{children}</AppContext.Provider>;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
.contextMenuButton {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contextMenuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { ALIASES } from '../api/apiConstants';
|
||||
import { getAliases } from '../api/ontimeApi';
|
||||
|
||||
export default function useAliases() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: ALIASES,
|
||||
queryFn: getAliases,
|
||||
placeholderData: [],
|
||||
@@ -15,5 +15,5 @@ export default function useAliases() {
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isFetching, isError, refetch };
|
||||
}
|
||||
return { data, status, isError, refetch };
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { EVENT_DATA } from '../api/apiConstants';
|
||||
import { EVENTDATA_TABLE } from '../api/apiConstants';
|
||||
import { fetchEventData } from '../api/eventDataApi';
|
||||
import { eventDataPlaceholder } from '../models/EventData';
|
||||
|
||||
export default function useEventData() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: EVENT_DATA,
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: EVENTDATA_TABLE,
|
||||
queryFn: fetchEventData,
|
||||
placeholderData: eventDataPlaceholder,
|
||||
retry: 5,
|
||||
@@ -16,5 +16,5 @@ export default function useEventData() {
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isFetching, isError, refetch };
|
||||
return { data, status, isError, refetch };
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@ 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 { getOSC, postOSC } from '../api/ontimeApi';
|
||||
import { oscPlaceholderSettings } from '../models/OscSettings';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
export default function useOscSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: OSC_SETTINGS,
|
||||
queryFn: getOSC,
|
||||
placeholderData: oscPlaceholderSettings,
|
||||
@@ -19,25 +18,14 @@ export default function useOscSettings() {
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
// we need to jump through some hoops because of the type op port
|
||||
return { data: data! as unknown as OSCSettings, status, isFetching, isError, refetch };
|
||||
return { data: data! as unknown as OSCSettings, status, isError, refetch };
|
||||
}
|
||||
|
||||
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 }),
|
||||
});
|
||||
return { isLoading, mutateAsync };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getSettings } from '../api/ontimeApi';
|
||||
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
|
||||
|
||||
export default function useSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: APP_SETTINGS,
|
||||
queryFn: getSettings,
|
||||
placeholderData: ontimePlaceholderSettings,
|
||||
@@ -16,5 +16,5 @@ export default function useSettings() {
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isFetching, isError, refetch };
|
||||
return { data, status, isError, refetch };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getUserFields } from '../api/ontimeApi';
|
||||
import { userFieldsPlaceholder } from '../models/UserFields';
|
||||
|
||||
export default function useUserFields() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: USERFIELDS,
|
||||
queryFn: getUserFields,
|
||||
placeholderData: userFieldsPlaceholder,
|
||||
@@ -16,5 +16,5 @@ export default function useUserFields() {
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isFetching, isError, refetch };
|
||||
return { data, status, isError, refetch };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getView } from '../api/ontimeApi';
|
||||
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
|
||||
|
||||
export default function useViewSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: VIEW_SETTINGS,
|
||||
queryFn: getView,
|
||||
placeholderData: viewsSettingsPlaceholder,
|
||||
@@ -16,5 +16,5 @@ export default function useViewSettings() {
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isError, refetch, isFetching };
|
||||
return { data, status, isError, refetch };
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { MouseEvent, useContext } from 'react';
|
||||
|
||||
import { ContextMenuContext, Option } from '../context/ContextMenuContext';
|
||||
|
||||
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 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 [localCreateContextMenu];
|
||||
};
|
||||