mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 22:24:11 +00:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b37494109a | |||
| aaa1fb9368 | |||
| cb198bc3fe | |||
| 6880f459b6 | |||
| 7b34c8f3b1 | |||
| c714727801 | |||
| 8fc70fa162 | |||
| 54c4a7f68c | |||
| 9820c71a2d | |||
| 2ff7d0f546 | |||
| f29ed1b05a | |||
| b1de7ffe5b | |||
| 46d17e0570 | |||
| 38e3ef7586 | |||
| 804e4ad33e | |||
| e8bcbb4291 | |||
| 111df94d6d | |||
| 5be60ff6c3 | |||
| bf8ebe942e | |||
| d32d8a547d | |||
| 5bc2c2f241 | |||
| 28533464d4 | |||
| 4f1fa2053f | |||
| b15b4b48a7 | |||
| bdd216d881 | |||
| 797a0edf57 | |||
| c4886a617c | |||
| a79ae7e0c4 | |||
| afa62a6e38 | |||
| 1b55dbc170 | |||
| ef2fa99673 | |||
| 85ee9de528 | |||
| e52a75db7d | |||
| 07ebb5a321 | |||
| 071cae4cc6 | |||
| 543e365f82 | |||
| a7c9ce876c | |||
| ae4e2ceafe | |||
| b77db4cbf6 | |||
| fe67e65a95 | |||
| 785695c01c | |||
| 65efebe180 | |||
| 52a9c85dbc | |||
| 6b2b6dc6dd | |||
| 31b46bbb34 | |||
| 38cf3a7b63 | |||
| 2316bbebac | |||
| 884ab0b67b | |||
| 81cf30daa0 | |||
| e9683c6cab | |||
| 58239af8bb | |||
| c59e070076 | |||
| fad8d2a933 | |||
| 54def8b820 | |||
| 370777326d | |||
| 309f622077 | |||
| 285b05bd65 | |||
| 05207189cf | |||
| d70bb2174a | |||
| 536e447eaa | |||
| 1ca3b9c134 | |||
| a41fe8806b | |||
| 140daef7e7 | |||
| cbed3e4fe4 | |||
| 9da7613450 | |||
| a7980ef2ab | |||
| 45fe669f0a | |||
| 0ce3449083 | |||
| de715b3d3e | |||
| 6012cb7bd1 | |||
| 2c47d90f34 | |||
| 943dd19c09 | |||
| 138be478ca | |||
| 98bd320cbd | |||
| f57faf1837 | |||
| 44f025b359 | |||
| 0555d0f400 | |||
| 1e90ce4c1d | |||
| 2f09d27813 | |||
| a01046b479 | |||
| 78d5d442cf | |||
| 7c234466f7 | |||
| 29e5c96d44 | |||
| 36b1effaaf | |||
| a84c0654e2 | |||
| b34fe71995 | |||
| 2f74293505 |
@@ -0,0 +1 @@
|
|||||||
|
"ONTIME_VERSION.js"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"root": true,
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": 2020
|
"ecmaVersion": 2020
|
||||||
},
|
},
|
||||||
@@ -6,8 +7,16 @@
|
|||||||
"es6": true,
|
"es6": true,
|
||||||
"jest": true
|
"jest": true
|
||||||
},
|
},
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
"extends": [
|
"extends": [
|
||||||
"eslint:recommended"
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"prettier",
|
||||||
|
"eslint-config-prettier"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"@typescript-eslint",
|
||||||
|
"prettier"
|
||||||
],
|
],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
@@ -21,6 +30,33 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-console": "warn"
|
"no-useless-concat": "warn",
|
||||||
|
"prefer-template": "warn",
|
||||||
|
"no-console": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"allow": [
|
||||||
|
"warn",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"caughtErrorsIgnorePattern": "^_",
|
||||||
|
"destructuredArrayIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prettier/prettier": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"endOfLine": "auto"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,17 +16,17 @@ jobs:
|
|||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Setup env
|
- name: Setup env
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Setup Node.js environment
|
- name: Setup Node.js environment
|
||||||
uses: actions/setup-node@v3.6.0
|
uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
version: 16.16.0
|
version: 16.16.0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v2.2.4
|
uses: pnpm/action-setup@v2
|
||||||
with:
|
with:
|
||||||
version: 7.26.3
|
version: 8
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
run: pnpm turbo build:docker
|
run: pnpm turbo build:docker
|
||||||
|
|
||||||
- name: Docker Login
|
- name: Docker Login
|
||||||
uses: docker/login-action@v2.1.0
|
uses: docker/login-action@v2.1.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ jobs:
|
|||||||
node-version: 16
|
node-version: 16
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v2.2.4
|
uses: pnpm/action-setup@v2
|
||||||
with:
|
with:
|
||||||
version: 7.26.3
|
version: 8
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -52,9 +52,9 @@ jobs:
|
|||||||
node-version: 16
|
node-version: 16
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v2.2.4
|
uses: pnpm/action-setup@v2
|
||||||
with:
|
with:
|
||||||
version: 7.26.3
|
version: 8
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -85,9 +85,9 @@ jobs:
|
|||||||
node-version: 16
|
node-version: 16
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v2.2.4
|
uses: pnpm/action-setup@v2
|
||||||
with:
|
with:
|
||||||
version: 7.26.3
|
version: 8
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -20,23 +20,47 @@ jobs:
|
|||||||
node-version: 16
|
node-version: 16
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v2.2.4
|
uses: pnpm/action-setup@v2
|
||||||
with:
|
with:
|
||||||
version: 7.26.3
|
version: 8
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Run code quality per package
|
||||||
|
- name: React - Run linter + TypeScript checks
|
||||||
|
if: always()
|
||||||
|
run: pnpm lint && tsc --noEmit
|
||||||
|
working-directory: ./apps/client
|
||||||
|
|
||||||
|
- name: Server - Run linter + TypeScript checks
|
||||||
|
if: always()
|
||||||
|
run: pnpm lint && tsc --noEmit
|
||||||
|
working-directory: ./apps/server
|
||||||
|
|
||||||
|
- name: Utils - Run linter + TypeScript checks
|
||||||
|
if: always()
|
||||||
|
run: pnpm lint && tsc --noEmit
|
||||||
|
working-directory: ./packages/utils
|
||||||
|
|
||||||
|
- name: Types - Run linter
|
||||||
|
if: always()
|
||||||
|
run: pnpm lint
|
||||||
|
working-directory: ./packages/types
|
||||||
|
|
||||||
# We choose to run tests separately
|
# We choose to run tests separately
|
||||||
- name: React - Run unit tests
|
- name: React - Run unit tests
|
||||||
|
if: always()
|
||||||
run: pnpm test:pipeline
|
run: pnpm test:pipeline
|
||||||
working-directory: ./apps/client
|
working-directory: ./apps/client
|
||||||
|
|
||||||
- name: Server - Run unit tests
|
- name: Server - Run unit tests
|
||||||
|
if: always()
|
||||||
run: pnpm test:pipeline
|
run: pnpm test:pipeline
|
||||||
working-directory: ./apps/server
|
working-directory: ./apps/server
|
||||||
|
|
||||||
- name: Utils - Run unit tests
|
- name: Utils - Run unit tests
|
||||||
|
if: always()
|
||||||
run: pnpm test:pipeline
|
run: pnpm test:pipeline
|
||||||
working-directory: ./packages/utils
|
working-directory: ./packages/utils
|
||||||
|
|
||||||
@@ -52,9 +76,9 @@ jobs:
|
|||||||
node-version: 16
|
node-version: 16
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v2.2.4
|
uses: pnpm/action-setup@v2
|
||||||
with:
|
with:
|
||||||
version: 7.26.3
|
version: 8
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
pnpm lint-staged
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
build
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
playwright-report
|
||||||
|
|
||||||
|
**/*.toml
|
||||||
|
**/*.yml
|
||||||
|
**/*.json
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"endOfLine": "lf",
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
|
|||||||
+3
-1
@@ -56,6 +56,8 @@ E2E tests are in a separate package. On running, [playwright](https://playwright
|
|||||||
webserver to test against
|
webserver to test against
|
||||||
These tests also run against a separate version of the DB (test-db)
|
These tests also run against a separate version of the DB (test-db)
|
||||||
|
|
||||||
|
Before running the E2E, you should first build the project with `pnpm build:local`.
|
||||||
|
|
||||||
You can run playwright tests from project root with `pnpm e2e`
|
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
|
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually
|
||||||
@@ -74,7 +76,7 @@ You can generate a distribution for your OS by running the following steps.
|
|||||||
From the project root, run the following commands
|
From the project root, run the following commands
|
||||||
|
|
||||||
- __Install the project dependencies__ by running `pnpm i`
|
- __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:electron`
|
||||||
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
|
- __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`
|
The build distribution assets will be at `.apps/electron/dist`
|
||||||
|
|||||||
@@ -14,10 +14,11 @@
|
|||||||
|
|
||||||
Ontime is an application for creating and managing event running order and timers.
|
Ontime is an application for creating and managing event running order and timers.
|
||||||
|
|
||||||
The user inputs a list of events along with scheduling and event information.
|
The user inputs a list of events along with scheduling and event information.
|
||||||
This will then populate a series of screens which are available to be rendered by any device in the Network.
|
This will then populate a series of screens which are available to be rendered by any device in the Network.
|
||||||
|
|
||||||
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
|
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video
|
||||||
|
outputs.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ This makes for a simple and cheap way to distribute over a venue using a network
|
|||||||
Once installed and running, Ontime starts a background server that is the heart of all processes.
|
Once installed and running, Ontime starts a background server that is the heart of all processes.
|
||||||
From the app, you can add / edit your running order and control the timer playback.
|
From the app, you can add / edit your running order and control the timer playback.
|
||||||
|
|
||||||
Any device with a browser in the same network can choose one of the supported views to render the available data.
|
Any device with a browser in the same network can choose one of the supported views to render the available data.
|
||||||
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
|
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
|
||||||
or `192.168.1.3:4001`
|
or `192.168.1.3:4001`
|
||||||
<br />
|
<br />
|
||||||
@@ -50,8 +51,9 @@ IP.ADDRESS:4001/public > Public / Foyer view
|
|||||||
IP.ADDRESS:4001/lower > Lower Thirds
|
IP.ADDRESS:4001/lower > Lower Thirds
|
||||||
IP.ADDRESS:4001/studio > Studio Clock
|
IP.ADDRESS:4001/studio > Studio Clock
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
For management views
|
For management views
|
||||||
-------------------------------------------------------------
|
-------------------------------------------------------------
|
||||||
IP.ADDRESS:4001/editor > the control interface, same as the app
|
IP.ADDRESS:4001/editor > the control interface, same as the app
|
||||||
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||||
@@ -63,14 +65,14 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
|
|||||||
|
|
||||||
- [x] Distribute data over network and render it in the browser
|
- [x] Distribute data over network and render it in the browser
|
||||||
- [x] Different screen types
|
- [x] Different screen types
|
||||||
- Stage Timer
|
- Stage Timer
|
||||||
- Minimal Timer
|
- Minimal Timer
|
||||||
- Clock
|
- Clock
|
||||||
- Backstage Info
|
- Backstage Info
|
||||||
- Public Info
|
- Public Info
|
||||||
- Studio Clock
|
- Studio Clock
|
||||||
- Countdown
|
- Countdown
|
||||||
- [Make your own?](#make-your-own-viewer)
|
- [Make your own?](#make-your-own-viewer)
|
||||||
- [x] Configurable Lower Thirds
|
- [x] Configurable Lower Thirds
|
||||||
- [x] Collaborative editing with the cuesheet view
|
- [x] Collaborative editing with the cuesheet view
|
||||||
- [x] Send live messages to different screen types
|
- [x] Send live messages to different screen types
|
||||||
@@ -83,17 +85,19 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
|
|||||||
- [x] Roll mode: run standalone using the system clock
|
- [x] Roll mode: run standalone using the system clock
|
||||||
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
|
- [x] [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://ontime.gitbook.io/v2/views/countdown): have
|
||||||
a countdown to any scheduled event
|
a countdown to any scheduled event
|
||||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||||
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
|
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
|
||||||
|
|
||||||
## Unopinionated
|
## Unopinionated
|
||||||
|
|
||||||
We want Ontime to be unique by targeting freelancers instead of roles.
|
We want Ontime to be unique by targeting freelancers instead of roles.
|
||||||
|
|
||||||
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your many environments and workflows.
|
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your
|
||||||
|
many environments and workflows.
|
||||||
|
|
||||||
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to work.
|
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to
|
||||||
|
work.
|
||||||
|
|
||||||
## Rich APIs for workflow integrations
|
## Rich APIs for workflow integrations
|
||||||
|
|
||||||
@@ -113,7 +117,8 @@ Taking advantage of the integrations, we currently use Ontime with:
|
|||||||
|
|
||||||
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
|
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
|
||||||
|
|
||||||
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language that can run in the browser).
|
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language
|
||||||
|
that can run in the browser).
|
||||||
<br />
|
<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-v2) with a small template on
|
||||||
how to get you started and read the docs about
|
how to get you started and read the docs about
|
||||||
@@ -121,26 +126,11 @@ the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-api
|
|||||||
|
|
||||||
### Headless run️
|
### Headless run️
|
||||||
|
|
||||||
You can self-host and run Ontime in a docker image. The run command will:
|
You can self-host and run Ontime in a docker image.
|
||||||
|
|
||||||
- expose the necessary ports (listed in the Dockerfile)
|
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
|
||||||
- mount a local file to persist your data (in the example: ````$(pwd)/local-data````)
|
|
||||||
- the image name __getontime/ontime__
|
|
||||||
|
|
||||||
The docker image is
|
If you want to run this image in a Raspberry Pi, please see [the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
|
||||||
in [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull getontime/ontime
|
|
||||||
```
|
|
||||||
|
|
||||||
and use the included docker compose to get started
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
Related information available [in the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -148,32 +138,32 @@ Related information available [in the docs](https://ontime.gitbook.io/v2/additio
|
|||||||
|
|
||||||
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
|
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
|
||||||
<br />
|
<br />
|
||||||
Have an idea? Reach out via [email](mail@getontime.no) or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
Have an idea? Reach out via [email](mail@getontime.no)
|
||||||
|
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||||
|
|
||||||
### Issues
|
### Issues
|
||||||
|
|
||||||
We use Github's issue tracking for bug reporting and feature requests. <br />
|
We use Github's issue tracking for bug reporting and feature requests. <br />
|
||||||
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
||||||
|
|
||||||
#### Unsigned App
|
#### Unsigned App
|
||||||
|
|
||||||
When installing the app you would see warning screens from the Operating System like:
|
When installing the app you would see warning screens from the Operating System like:
|
||||||
|
|
||||||
```Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.```
|
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
|
||||||
|
|
||||||
or
|
or
|
||||||
|
|
||||||
```Ontime can't be opened because it is from an unidentified developer```
|
`Ontime can't be opened because it is from an unidentified developer`
|
||||||
|
|
||||||
or in Linux
|
or in Linux
|
||||||
|
|
||||||
```Could Not Display "ontime-linux.AppImage```
|
`Could Not Display "ontime-linux.AppImage`
|
||||||
|
|
||||||
You can circumvent this by allowing the execution of the app manually.
|
You can circumvent this by allowing the execution of the app manually.
|
||||||
|
|
||||||
- In Windows: click more and select "Run Anyway"
|
- In Windows: click more and select "Run Anyway"
|
||||||
- in macOS: after attempting to run the installer, navigate to System Preferences -> Security &
|
- in macOS: the solution in macOS is different across versions, please refer to the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
|
||||||
Privacy and allow the execution of the app
|
|
||||||
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
|
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
|
||||||
File as a Program
|
File as a Program
|
||||||
|
|
||||||
@@ -185,6 +175,7 @@ please [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
|||||||
#### Safari
|
#### Safari
|
||||||
|
|
||||||
There are known issues with Safari versions lower than 13:
|
There are known issues with Safari versions lower than 13:
|
||||||
|
|
||||||
- Spacing and text styles might have small inconsistencies
|
- Spacing and text styles might have small inconsistencies
|
||||||
- Table view does not work
|
- Table view does not work
|
||||||
|
|
||||||
@@ -194,7 +185,8 @@ There is no plan for any further work on this.
|
|||||||
|
|
||||||
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
||||||
<br /><br />
|
<br /><br />
|
||||||
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a Pull Request.
|
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a
|
||||||
|
Pull Request.
|
||||||
<br />
|
<br />
|
||||||
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
||||||
|
|
||||||
|
|||||||
+2
-18
@@ -8,34 +8,18 @@
|
|||||||
"browser": true,
|
"browser": true,
|
||||||
"node": true
|
"node": true
|
||||||
},
|
},
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"extends": [
|
"extends": [
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:react/recommended",
|
"plugin:react/recommended",
|
||||||
"plugin:react-hooks/recommended",
|
"plugin:react-hooks/recommended",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@tanstack/eslint-plugin-query/recommended"
|
||||||
"eslint-config-prettier",
|
|
||||||
"plugin:@tanstack/eslint-plugin-query/recommended",
|
|
||||||
"prettier"
|
|
||||||
],
|
],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"react",
|
"react",
|
||||||
"testing-library",
|
"testing-library",
|
||||||
"simple-import-sort",
|
"simple-import-sort",
|
||||||
"@tanstack/query",
|
"@tanstack/query"
|
||||||
"@typescript-eslint",
|
|
||||||
"prettier"
|
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
|
||||||
"prettier/prettier": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"endOfLine": "auto"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"no-useless-concat": "warn",
|
|
||||||
"prefer-template": "warn",
|
|
||||||
"react/jsx-no-bind": [
|
"react/jsx-no-bind": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"endOfLine": "lf",
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
|
|||||||
+15
-14
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "2.9.0",
|
"version": "2.16.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/react": "^2.7.0",
|
"@chakra-ui/react": "^2.7.0",
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
"@sentry/react": "^7.46.0",
|
"@sentry/react": "^7.46.0",
|
||||||
"@sentry/tracing": "^7.46.0",
|
"@sentry/tracing": "^7.46.0",
|
||||||
"@tanstack/react-query": "^4.28.0",
|
"@tanstack/react-query": "^5.8.4",
|
||||||
"@tanstack/react-query-devtools": "^4.29.0",
|
"@tanstack/react-query-devtools": "^5.8.4",
|
||||||
"@tanstack/react-table": "^8.9.2",
|
"@tanstack/react-table": "^8.9.2",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.2.0",
|
"axios": "^1.2.0",
|
||||||
@@ -38,8 +38,10 @@
|
|||||||
"dev": "cross-env BROWSER=none vite",
|
"dev": "cross-env BROWSER=none vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:local": "cross-env NODE_ENV=local vite build",
|
"build:local": "cross-env NODE_ENV=local vite build",
|
||||||
|
"build:electron": "cross-env NODE_ENV=local vite build",
|
||||||
"build:docker": "vite build",
|
"build:docker": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint . --quiet",
|
||||||
|
"lint-staged": "eslint",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:pipeline": "vitest run",
|
"test:pipeline": "vitest run",
|
||||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
|
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
|
||||||
@@ -58,22 +60,21 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sentry/vite-plugin": "^0.4.0",
|
"@sentry/vite-plugin": "^0.4.0",
|
||||||
"@tanstack/eslint-plugin-query": "^4.26.2",
|
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
"@testing-library/react": "^13.1.1",
|
"@testing-library/react": "^13.1.1",
|
||||||
"@testing-library/user-event": "^14.1.1",
|
"@testing-library/user-event": "^14.1.1",
|
||||||
"@types/color": "^3.0.3",
|
"@types/color": "^3.0.3",
|
||||||
"@types/luxon": "^3.2.0",
|
|
||||||
"@types/react": "^18.0.26",
|
"@types/react": "^18.0.26",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
"@types/testing-library__jest-dom": "^5.14.5",
|
"@types/testing-library__jest-dom": "^5.14.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
"@typescript-eslint/parser": "^5.48.1",
|
"@typescript-eslint/parser": "^6.10.0",
|
||||||
"@vitejs/plugin-react": "^3.0.1",
|
"@vitejs/plugin-react": "^3.0.1",
|
||||||
"eslint": "^8.31.0",
|
"eslint": "^8.53.0",
|
||||||
"eslint-config-prettier": "^8.6.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-jest": "^27.1.7",
|
"eslint-plugin-jest": "^27.6.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^5.0.1",
|
||||||
"eslint-plugin-react": "^7.32.0",
|
"eslint-plugin-react": "^7.32.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||||
@@ -81,9 +82,9 @@
|
|||||||
"jsdom": "^21.1.0",
|
"jsdom": "^21.1.0",
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"prettier": "^2.8.3",
|
"prettier": "^3.0.3",
|
||||||
"sass": "^1.57.1",
|
"sass": "^1.57.1",
|
||||||
"typescript": "^4.9.4",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^4.3.1",
|
"vite": "^4.3.1",
|
||||||
"vite-plugin-compression2": "^0.9.0",
|
"vite-plugin-compression2": "^0.9.0",
|
||||||
"vite-plugin-svgr": "^2.4.0",
|
"vite-plugin-svgr": "^2.4.0",
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
export const PROJECT_DATA = ['project'];
|
export const PROJECT_DATA = ['project'];
|
||||||
export const ALIASES = ['aliases'];
|
export const ALIASES = ['aliases'];
|
||||||
export const USERFIELDS = ['userFields'];
|
export const USERFIELDS = ['userFields'];
|
||||||
export const RUNDOWN_TABLE_KEY = 'rundown';
|
export const RUNDOWN = ['rundown'];
|
||||||
export const RUNDOWN_TABLE = [RUNDOWN_TABLE_KEY];
|
|
||||||
export const APP_INFO = ['appinfo'];
|
export const APP_INFO = ['appinfo'];
|
||||||
export const OSC_SETTINGS = ['oscSettings'];
|
export const OSC_SETTINGS = ['oscSettings'];
|
||||||
export const APP_SETTINGS = ['appSettings'];
|
export const APP_SETTINGS = ['appSettings'];
|
||||||
|
|||||||
@@ -2,18 +2,32 @@ import axios, { AxiosError } from 'axios';
|
|||||||
import { LogLevel } from 'ontime-types';
|
import { LogLevel } from 'ontime-types';
|
||||||
import { generateId, millisToString } from 'ontime-utils';
|
import { generateId, millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
import { addLog } from '../stores/logger';
|
import { addLog } from '../stores/logger';
|
||||||
import { nowInMillis } from '../utils/time';
|
import { nowInMillis } from '../utils/time';
|
||||||
|
|
||||||
export function logAxiosError(prepend: string, error: unknown) {
|
export function maybeAxiosError(error: unknown) {
|
||||||
let message;
|
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const statusText = (error as AxiosError).response?.statusText ?? '';
|
const statusText = (error as AxiosError).response?.statusText ?? '';
|
||||||
const data = (error as AxiosError).response?.data ?? '';
|
let data = (error as AxiosError).response?.data ?? '';
|
||||||
message = `${prepend} ${statusText}: ${data}`;
|
if (typeof data === 'object') {
|
||||||
|
if ('message' in data) {
|
||||||
|
data = JSON.stringify(data.message);
|
||||||
|
} else {
|
||||||
|
data = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${statusText}: ${data}`;
|
||||||
} else {
|
} else {
|
||||||
message = `${prepend}: ${error}`;
|
if (typeof error !== 'string') {
|
||||||
|
return JSON.stringify(error);
|
||||||
|
}
|
||||||
|
return error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logAxiosError(prepend: string, error: unknown) {
|
||||||
|
const message = `${prepend}: ${maybeAxiosError(error)}`;
|
||||||
|
|
||||||
addLog({
|
addLog({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -23,3 +37,17 @@ export function logAxiosError(prepend: string, error: unknown) {
|
|||||||
text: message,
|
text: message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function invalidates react-query caches
|
||||||
|
*/
|
||||||
|
export async function invalidateAllCaches() {
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] });
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] });
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] });
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] });
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] });
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] });
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] });
|
||||||
|
await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
import { GetRundownCached, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { rundownURL } from './apiConstants';
|
import { rundownURL } from './apiConstants';
|
||||||
|
|
||||||
@@ -7,6 +7,16 @@ import { rundownURL } from './apiConstants';
|
|||||||
* @description HTTP request to fetch all events
|
* @description HTTP request to fetch all events
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
|
export async function fetchCachedRundown(): Promise<GetRundownCached> {
|
||||||
|
const res = await axios.get(`${rundownURL}/cached`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use fetchCachedRundown instead
|
||||||
|
* @description HTTP request to fetch all events
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
export async function fetchRundown(): Promise<OntimeRundown> {
|
export async function fetchRundown(): Promise<OntimeRundown> {
|
||||||
const res = await axios.get(rundownURL);
|
const res = await axios.get(rundownURL);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import axios from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types';
|
import {
|
||||||
|
Alias,
|
||||||
|
DatabaseModel,
|
||||||
|
GetInfo,
|
||||||
|
GoogleSheet,
|
||||||
|
GoogleSheetState,
|
||||||
|
OntimeRundown,
|
||||||
|
OSCSettings,
|
||||||
|
OscSubscription,
|
||||||
|
ProjectData,
|
||||||
|
Settings,
|
||||||
|
UserFields,
|
||||||
|
ViewSettings,
|
||||||
|
} from 'ontime-types';
|
||||||
|
import { ExcelImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { apiRepoLatest } from '../../externals';
|
import { apiRepoLatest } from '../../externals';
|
||||||
import { InfoType } from '../models/Info';
|
import fileDownload from '../utils/fileDownload';
|
||||||
|
|
||||||
import { ontimeURL } from './apiConstants';
|
import { ontimeURL } from './apiConstants';
|
||||||
|
|
||||||
@@ -27,7 +41,7 @@ export async function postSettings(data: Settings) {
|
|||||||
* @description HTTP request to retrieve application info
|
* @description HTTP request to retrieve application info
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
export async function getInfo(): Promise<InfoType> {
|
export async function getInfo(): Promise<GetInfo> {
|
||||||
const res = await axios.get(`${ontimeURL}/info`);
|
const res = await axios.get(`${ontimeURL}/info`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
@@ -109,45 +123,38 @@ export async function postOscSubscriptions(data: OscSubscription) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description HTTP request to download db
|
* @description HTTP request to download db in CSV format
|
||||||
* @return {Promise}
|
|
||||||
*/
|
*/
|
||||||
export const downloadRundown = async () => {
|
export const downloadCSV = () => {
|
||||||
await axios({
|
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
|
||||||
url: `${ontimeURL}/db`,
|
};
|
||||||
method: 'GET',
|
|
||||||
responseType: 'blob', // important
|
|
||||||
}).then((response) => {
|
|
||||||
const headerLine = response.headers['Content-Disposition'];
|
|
||||||
let filename = 'rundown.json';
|
|
||||||
|
|
||||||
// try and get the filename from the response
|
/**
|
||||||
if (headerLine != null) {
|
* @description HTTP request to download db in JSON format
|
||||||
const startFileNameIndex = headerLine.indexOf('"') + 1;
|
*/
|
||||||
const endFileNameIndex = headerLine.lastIndexOf('"');
|
export const downloadRundown = () => {
|
||||||
filename = headerLine.substring(startFileNameIndex, endFileNameIndex);
|
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' });
|
||||||
}
|
};
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/json' }));
|
// TODO: should this be extracted to shared code?
|
||||||
const link = document.createElement('a');
|
export type ProjectFileImportOptions = {
|
||||||
link.href = url;
|
onlyRundown: boolean;
|
||||||
link.setAttribute('download', filename);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description HTTP request to upload events db
|
* @description HTTP request to upload events db
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
type UploadDataOptions = {
|
export const uploadProjectFile = async (
|
||||||
onlyRundown?: boolean;
|
file: File,
|
||||||
};
|
setProgress: (value: number) => void,
|
||||||
export const uploadData = async (file: File, setProgress: (value: number) => void, options?: UploadDataOptions) => {
|
options?: Partial<ProjectFileImportOptions>,
|
||||||
|
) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('userFile', file);
|
formData.append('userFile', file);
|
||||||
const onlyRundown = options?.onlyRundown || 'false';
|
|
||||||
|
const onlyRundown = Boolean(options?.onlyRundown);
|
||||||
|
|
||||||
await axios
|
await axios
|
||||||
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
|
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -161,6 +168,47 @@ export const uploadData = async (file: File, setProgress: (value: number) => voi
|
|||||||
.then((response) => response.data.id);
|
.then((response) => response.data.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Make patch changes to the objects in the db
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
export async function patchData(patchDb: Partial<DatabaseModel>) {
|
||||||
|
const response = await axios.patch(`${ontimeURL}/db`, patchDb);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostPreviewExcelResponse = {
|
||||||
|
rundown: OntimeRundown;
|
||||||
|
project: ProjectData;
|
||||||
|
userFields: UserFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Make patch changes to the objects in the db
|
||||||
|
* @return {Promise} - returns parsed rundown and userfields
|
||||||
|
*/
|
||||||
|
export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: ExcelImportMap) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('userFile', file);
|
||||||
|
formData.append('options', JSON.stringify(options));
|
||||||
|
|
||||||
|
const response: AxiosResponse<PostPreviewExcelResponse> = await axios.post(
|
||||||
|
`${ontimeURL}/preview-spreadsheet`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
|
||||||
|
setProgress(complete);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
export type HasUpdate = {
|
export type HasUpdate = {
|
||||||
url: string;
|
url: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -181,3 +229,62 @@ export async function getLatestVersion(): Promise<HasUpdate> {
|
|||||||
export async function postNew(initialData: Partial<ProjectData>) {
|
export async function postNew(initialData: Partial<ProjectData>) {
|
||||||
return axios.post(`${ontimeURL}/new`, initialData);
|
return axios.post(`${ontimeURL}/new`, initialData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description sheet Client File
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
export const uploadSheetClientFile = async (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('userFile', file);
|
||||||
|
await axios
|
||||||
|
.post(`${ontimeURL}/sheet-clientsecrect`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => response.data.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSheetsAuthUrl = async () => {
|
||||||
|
const res = await axios.get(`${ontimeURL}/sheet-authurl`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postPreviewSheet = async () => {
|
||||||
|
const response = await axios.post(`${ontimeURL}/sheet-preview`);
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postPushSheet = async () => {
|
||||||
|
const response = await axios.post(`${ontimeURL}/sheet-push`);
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to retrieve google sheets settings
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
export async function getSheetSettings(): Promise<GoogleSheet> {
|
||||||
|
const res = await axios.get(`${ontimeURL}/sheet-settings`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to mutate google sheets settings
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
export async function postSheetSettings(data: GoogleSheet): Promise<GoogleSheet> {
|
||||||
|
const res = await axios.post(`${ontimeURL}/sheet-settings`, data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to retrieve google sheets state
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
export async function getSheetstate(): Promise<GoogleSheetState> {
|
||||||
|
const res = await axios.get(`${ontimeURL}/sheet-state`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
|
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
|
||||||
|
|
||||||
import Swatch from './Swatch';
|
import Swatch from './Swatch';
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ import style from './SwatchSelect.module.scss';
|
|||||||
|
|
||||||
interface ColourInputProps {
|
interface ColourInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
name: TitleActions;
|
name: EditorUpdateFields;
|
||||||
handleChange: (newValue: TitleActions, name: string) => void;
|
handleChange: (newValue: EditorUpdateFields, name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const colours = [
|
const colours = [
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ interface TextInputProps extends BaseProps {
|
|||||||
isTextArea?: false;
|
isTextArea?: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResizeOptions = 'horizontal' | 'vertical' | 'none';
|
||||||
|
|
||||||
interface TextAreaProps extends BaseProps {
|
interface TextAreaProps extends BaseProps {
|
||||||
isTextArea: true;
|
isTextArea: true;
|
||||||
resize?: 'horizontal' | 'vertical' | 'none';
|
resize?: ResizeOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
type InputProps = TextInputProps | TextAreaProps;
|
type InputProps = TextInputProps | TextAreaProps;
|
||||||
@@ -35,7 +37,7 @@ export default function TextInput(props: InputProps) {
|
|||||||
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
|
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
|
||||||
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
|
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
|
||||||
|
|
||||||
let resize = 'none';
|
let resize: ResizeOptions = 'none';
|
||||||
if (isTextArea) {
|
if (isTextArea) {
|
||||||
resize = (props as TextAreaProps)?.resize ?? 'none';
|
resize = (props as TextAreaProps)?.resize ?? 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ $progress-bar-br: 3px;
|
|||||||
border-radius: $progress-bar-br;
|
border-radius: $progress-bar-br;
|
||||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&--hidden {
|
&--hidden {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -31,7 +32,6 @@ $progress-bar-br: 3px;
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
height: inherit;
|
height: inherit;
|
||||||
right: 0;
|
right: 0;
|
||||||
border-radius: $progress-bar-br;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,12 +39,10 @@ $progress-bar-br: 3px;
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
height: inherit;
|
height: inherit;
|
||||||
right: 0;
|
right: 0;
|
||||||
border-radius: 0 $progress-bar-br $progress-bar-br 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiprogress-bar__bg-danger {
|
.multiprogress-bar__bg-danger {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: inherit;
|
height: inherit;
|
||||||
right: 0;
|
right: 0;
|
||||||
border-radius: 0 $progress-bar-br $progress-bar-br 0;
|
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import { clamp } from '../../utils/math';
|
|||||||
import './MultiPartProgressBar.scss';
|
import './MultiPartProgressBar.scss';
|
||||||
|
|
||||||
interface MultiPartProgressBar {
|
interface MultiPartProgressBar {
|
||||||
now: number;
|
now: number | null;
|
||||||
complete: number;
|
complete: number;
|
||||||
normalColor: string;
|
normalColor: string;
|
||||||
warning: number;
|
warning: number;
|
||||||
@@ -17,17 +17,27 @@ interface MultiPartProgressBar {
|
|||||||
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||||
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
|
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 percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
|
||||||
|
|
||||||
const dangerWidth = clamp((danger / complete) * 100, 0, 100);
|
const dangerWidth = clamp((danger / complete) * 100, 0, 100);
|
||||||
const warningWidth = clamp((warning / complete) * 100, 0, 100);
|
const warningWidth = clamp((warning / complete) * 100, 0, 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
|
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
|
||||||
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
{now !== null && (
|
||||||
<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__bg-normal' style={{ backgroundColor: normalColor }} />
|
||||||
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import Empty from '../state/Empty';
|
|
||||||
|
|
||||||
import { useSchedule } from './ScheduleContext';
|
import { useSchedule } from './ScheduleContext';
|
||||||
import ScheduleItem from './ScheduleItem';
|
import ScheduleItem from './ScheduleItem';
|
||||||
|
|
||||||
@@ -13,8 +11,9 @@ interface ScheduleProps {
|
|||||||
export default function Schedule({ isProduction, className }: ScheduleProps) {
|
export default function Schedule({ isProduction, className }: ScheduleProps) {
|
||||||
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
||||||
|
|
||||||
|
// TODO: design a nice placeholder for empty schedules
|
||||||
if (paginatedEvents?.length < 1) {
|
if (paginatedEvents?.length < 1) {
|
||||||
return <Empty text='No events to show' />;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedState: 'past' | 'now' | 'future' = 'past';
|
let selectedState: 'past' | 'now' | 'future' = 'past';
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createContext, PropsWithChildren, useContext, useState } from 'react';
|
import { createContext, PropsWithChildren, useContext, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { OntimeEvent } from 'ontime-types';
|
import { OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { useInterval } from '../../hooks/useInterval';
|
import { useInterval } from '../../hooks/useInterval';
|
||||||
|
import { isStringBoolean } from '../../utils/viewUtils';
|
||||||
|
|
||||||
interface ScheduleContextState {
|
interface ScheduleContextState {
|
||||||
events: OntimeEvent[];
|
events: OntimeEvent[];
|
||||||
@@ -19,7 +21,6 @@ interface ScheduleProviderProps {
|
|||||||
events: OntimeEvent[];
|
events: OntimeEvent[];
|
||||||
selectedEventId: string | null;
|
selectedEventId: string | null;
|
||||||
isBackstage?: boolean;
|
isBackstage?: boolean;
|
||||||
eventsPerPage?: number;
|
|
||||||
time?: number;
|
time?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,16 +29,29 @@ export const ScheduleProvider = ({
|
|||||||
events,
|
events,
|
||||||
selectedEventId,
|
selectedEventId,
|
||||||
isBackstage = false,
|
isBackstage = false,
|
||||||
eventsPerPage = 7,
|
|
||||||
time = 10,
|
time = 10,
|
||||||
}: PropsWithChildren<ScheduleProviderProps>) => {
|
}: PropsWithChildren<ScheduleProviderProps>) => {
|
||||||
const [visiblePage, setVisiblePage] = useState(0);
|
const [visiblePage, setVisiblePage] = useState(0);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const numPages = Math.ceil(events.length / eventsPerPage);
|
// look for overrides from views
|
||||||
|
const hidePast = isStringBoolean(searchParams.get('hidePast'));
|
||||||
|
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
|
||||||
|
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? 7);
|
||||||
|
|
||||||
|
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||||
|
|
||||||
|
const viewEvents = [...events];
|
||||||
|
if (hidePast) {
|
||||||
|
// we want to show the event after the next
|
||||||
|
viewEvents.splice(0, selectedEventIndex + 2);
|
||||||
|
selectedEventIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
|
||||||
const eventStart = eventsPerPage * visiblePage;
|
const eventStart = eventsPerPage * visiblePage;
|
||||||
const eventEnd = eventsPerPage * (visiblePage + 1);
|
const eventEnd = eventsPerPage * (visiblePage + 1);
|
||||||
const paginatedEvents = events.slice(eventStart, eventEnd);
|
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
|
||||||
const selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
|
||||||
|
|
||||||
const resolveScheduleType = () => {
|
const resolveScheduleType = () => {
|
||||||
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
|
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
|
||||||
@@ -52,7 +66,9 @@ export const ScheduleProvider = ({
|
|||||||
|
|
||||||
// every SCROLL_TIME go to the next array
|
// every SCROLL_TIME go to the next array
|
||||||
useInterval(() => {
|
useInterval(() => {
|
||||||
if (events.length > eventsPerPage) {
|
if (stopCycle) {
|
||||||
|
setVisiblePage(0);
|
||||||
|
} else if (events.length > eventsPerPage) {
|
||||||
const next = (visiblePage + 1) % numPages;
|
const next = (visiblePage + 1) % numPages;
|
||||||
setVisiblePage(next);
|
setVisiblePage(next);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||||
import { formatTime } from '../../utils/time';
|
import { formatTime } from '../../utils/time';
|
||||||
|
|
||||||
import './Schedule.scss';
|
import './Schedule.scss';
|
||||||
|
|
||||||
|
const formatOptions = {
|
||||||
|
format: 'hh:mm a',
|
||||||
|
};
|
||||||
|
|
||||||
interface ScheduleItemProps {
|
interface ScheduleItemProps {
|
||||||
selected: 'past' | 'now' | 'future';
|
selected: 'past' | 'now' | 'future';
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
@@ -14,19 +19,10 @@ interface ScheduleItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ScheduleItem(props: ScheduleItemProps) {
|
export default function ScheduleItem(props: ScheduleItemProps) {
|
||||||
const {
|
const { selected, timeStart, timeEnd, title, presenter, backstageEvent, colour, skip } = props;
|
||||||
selected,
|
|
||||||
timeStart,
|
|
||||||
timeEnd,
|
|
||||||
title,
|
|
||||||
presenter,
|
|
||||||
backstageEvent,
|
|
||||||
colour,
|
|
||||||
skip,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const start = formatTime(timeStart, { format: 'hh:mm' });
|
const start = formatTime(timeStart, formatOptions);
|
||||||
const end = formatTime(timeEnd, { format: 'hh:mm' });
|
const end = formatTime(timeEnd, formatOptions);
|
||||||
const userColour = colour !== '' ? colour : '';
|
const userColour = colour !== '' ? colour : '';
|
||||||
const selectStyle = `entry--${selected}`;
|
const selectStyle = `entry--${selected}`;
|
||||||
|
|
||||||
@@ -34,12 +30,15 @@ export default function ScheduleItem(props: ScheduleItemProps) {
|
|||||||
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
|
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
|
||||||
<div className='entry-times'>
|
<div className='entry-times'>
|
||||||
<span className='entry-colour' style={{ backgroundColor: userColour }} />
|
<span className='entry-colour' style={{ backgroundColor: userColour }} />
|
||||||
{`${start} → ${end} ${backstageEvent ? '*' : ''}`}
|
<div style={{ display: 'flex' }}>
|
||||||
|
<SuperscriptTime time={start} />
|
||||||
|
{' → '}
|
||||||
|
<SuperscriptTime time={end} />
|
||||||
|
{backstageEvent ? '*' : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='entry-title'>{title}</div>
|
<div className='entry-title'>{title}</div>
|
||||||
{presenter && (
|
{presenter && <div className='entry-presenter'>{presenter}</div>}
|
||||||
<div className='entry-presenter'>{presenter}</div>
|
|
||||||
)}
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ export default function ScheduleNav({ className }: ScheduleNavProps) {
|
|||||||
<div className={`schedule-nav ${className}`}>
|
<div className={`schedule-nav ${className}`}>
|
||||||
{numPages > 1 &&
|
{numPages > 1 &&
|
||||||
[...Array(numPages).keys()].map((i) => (
|
[...Array(numPages).keys()].map((i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
|
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
|
||||||
/>
|
/>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FormEvent, useEffect } from 'react';
|
import { FormEvent, useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -12,11 +12,27 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
|
||||||
|
|
||||||
import ParamInput from './ParamInput';
|
import ParamInput from './ParamInput';
|
||||||
import { ParamField } from './types';
|
import { ParamField } from './types';
|
||||||
|
|
||||||
import style from './ViewParamsEditor.module.scss';
|
import style from './ViewParamsEditor.module.scss';
|
||||||
|
|
||||||
|
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||||
|
type SavedViewParams = Record<string, ViewParamsObj>;
|
||||||
|
|
||||||
|
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj) =>
|
||||||
|
Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
|
||||||
|
if (typeof value === 'string' && value.length) {
|
||||||
|
newSearchParams.set(id, value);
|
||||||
|
|
||||||
|
return newSearchParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSearchParams;
|
||||||
|
}, new URLSearchParams());
|
||||||
|
|
||||||
interface EditFormDrawerProps {
|
interface EditFormDrawerProps {
|
||||||
paramFields: ParamField[];
|
paramFields: ParamField[];
|
||||||
}
|
}
|
||||||
@@ -24,6 +40,8 @@ interface EditFormDrawerProps {
|
|||||||
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [storedViewParams, setStoredViewParams] = useLocalStorage<SavedViewParams>('ontime-views', {});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isEditing = searchParams.get('edit');
|
const isEditing = searchParams.get('edit');
|
||||||
@@ -33,6 +51,27 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
|||||||
}
|
}
|
||||||
}, [searchParams, onOpen]);
|
}, [searchParams, onOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* disabling this for now, this feature needs more testing
|
||||||
|
* - we seem to have a bug where this is conflicting with the aliases
|
||||||
|
* - I wonder if the logic below needs to be inside an effect,
|
||||||
|
* both localStorage and searchParams should trigger a component update when they change
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const viewParamsObjFromLocalStorage = storedViewParams[pathname];
|
||||||
|
|
||||||
|
if (viewParamsObjFromLocalStorage !== undefined) {
|
||||||
|
const defaultSearchParams = getURLSearchParamsFromObj(viewParamsObjFromLocalStorage);
|
||||||
|
setSearchParams(defaultSearchParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
// linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps
|
||||||
|
// rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
const onEditDrawerClose = () => {
|
const onEditDrawerClose = () => {
|
||||||
onClose();
|
onClose();
|
||||||
|
|
||||||
@@ -41,6 +80,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearParams = () => {
|
const clearParams = () => {
|
||||||
|
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
|
||||||
setSearchParams();
|
setSearchParams();
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -49,15 +89,9 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
|||||||
formEvent.preventDefault();
|
formEvent.preventDefault();
|
||||||
|
|
||||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||||
const newSearchParams = Object.entries(newParamsObject).reduce((newSearchParams, [id, value]) => {
|
const newSearchParams = getURLSearchParamsFromObj(newParamsObject);
|
||||||
if (typeof value === 'string' && value.length) {
|
|
||||||
newSearchParams.set(id, value);
|
|
||||||
|
|
||||||
return newSearchParams;
|
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
|
||||||
}
|
|
||||||
|
|
||||||
return newSearchParams;
|
|
||||||
}, new URLSearchParams());
|
|
||||||
setSearchParams(newSearchParams);
|
setSearchParams(newSearchParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,39 @@ export const CLOCK_OPTIONS: ParamField[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const TIMER_OPTIONS: ParamField[] = [TIME_FORMAT_OPTION];
|
export const TIMER_OPTIONS: ParamField[] = [
|
||||||
|
TIME_FORMAT_OPTION,
|
||||||
|
{
|
||||||
|
id: 'hideClock',
|
||||||
|
title: 'Hide Time Now',
|
||||||
|
description: 'Hides the Time Now field',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideCards',
|
||||||
|
title: 'Hide Cards',
|
||||||
|
description: 'Hides the Now and Next cards',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideProgress',
|
||||||
|
title: 'Hide progress bar',
|
||||||
|
description: 'Hides the progress bar',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideMessage',
|
||||||
|
title: 'Hide Presenter Message',
|
||||||
|
description: 'Prevents the screen from displaying messages from the presenter',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideExternal',
|
||||||
|
title: 'Hide External',
|
||||||
|
description: 'Prevents the screen from displaying the external field',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||||
{
|
{
|
||||||
@@ -132,7 +164,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
|||||||
{
|
{
|
||||||
id: 'hideovertime',
|
id: 'hideovertime',
|
||||||
title: 'Hide Overtime',
|
title: 'Hide Overtime',
|
||||||
description: 'Whether to supress overtime styles (red borders and red text)',
|
description: 'Whether to suppress overtime styles (red borders and red text)',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -194,6 +226,49 @@ export const LOWER_THIRDS_OPTIONS: ParamField[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const BACKSTAGE_OPTIONS: ParamField[] = [
|
||||||
|
TIME_FORMAT_OPTION,
|
||||||
|
{
|
||||||
|
id: 'hidePast',
|
||||||
|
title: 'Hide past events',
|
||||||
|
description: 'Scheduler will only show upcoming events',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stopCycle',
|
||||||
|
title: 'Stop cycling through event pages',
|
||||||
|
description: 'Schedule will not auto-cycle through events',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'eventsPerPage',
|
||||||
|
title: 'Events per page',
|
||||||
|
description: 'Sets the number of events on the page, can cause overlow',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PUBLIC_OPTIONS: ParamField[] = [
|
||||||
|
TIME_FORMAT_OPTION,
|
||||||
|
{
|
||||||
|
id: 'hidePast',
|
||||||
|
title: 'Hide past events',
|
||||||
|
description: 'Scheduler will only show upcoming events',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stopCycle',
|
||||||
|
title: 'Stop cycling through event pages',
|
||||||
|
description: 'Schedule will not auto-cycle through events',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'eventsPerPage',
|
||||||
|
title: 'Events per page',
|
||||||
|
description: 'Sets the number of events on the page, can cause overlow',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
];
|
||||||
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
||||||
TIME_FORMAT_OPTION,
|
TIME_FORMAT_OPTION,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const [operatorAuth, setOperatorAuth] = useState(true);
|
const [operatorAuth, setOperatorAuth] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'loading') return;
|
if (status === 'pending') return;
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const previousEditor = sessionStorage.getItem(storageKeys.editor);
|
const previousEditor = sessionStorage.getItem(storageKeys.editor);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { GetInfo } from 'ontime-types';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { APP_INFO } from '../api/apiConstants';
|
import { APP_INFO } from '../api/apiConstants';
|
||||||
@@ -6,7 +7,7 @@ import { getInfo } from '../api/ontimeApi';
|
|||||||
import { ontimePlaceholderInfo } from '../models/Info';
|
import { ontimePlaceholderInfo } from '../models/Info';
|
||||||
|
|
||||||
export default function useInfo() {
|
export default function useInfo() {
|
||||||
const { data, status, isError, refetch } = useQuery({
|
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
|
||||||
queryKey: APP_INFO,
|
queryKey: APP_INFO,
|
||||||
queryFn: getInfo,
|
queryFn: getInfo,
|
||||||
placeholderData: ontimePlaceholderInfo,
|
placeholderData: ontimePlaceholderInfo,
|
||||||
@@ -16,5 +17,5 @@ export default function useInfo() {
|
|||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data, status, isError, refetch };
|
return { data, status, isError, refetch, isFetching };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-nocheck -- working on it
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { OSCSettings } from 'ontime-types';
|
import { OSCSettings } from 'ontime-types';
|
||||||
|
|
||||||
@@ -24,20 +26,20 @@ export default function useOscSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useOscSettingsMutation() {
|
export function useOscSettingsMutation() {
|
||||||
const { isLoading, mutateAsync } = useMutation({
|
const { isPending, mutateAsync } = useMutation({
|
||||||
mutationFn: postOSC,
|
mutationFn: postOSC,
|
||||||
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
||||||
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
|
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
|
||||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
||||||
});
|
});
|
||||||
return { isLoading, mutateAsync };
|
return { isPending, mutateAsync };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePostOscSubscriptions() {
|
export function usePostOscSubscriptions() {
|
||||||
const { isLoading, mutateAsync } = useMutation({
|
const { isPending, mutateAsync } = useMutation({
|
||||||
mutationFn: postOscSubscriptions,
|
mutationFn: postOscSubscriptions,
|
||||||
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
||||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
||||||
});
|
});
|
||||||
return { isLoading, mutateAsync };
|
return { isPending, mutateAsync };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { GetRundownCached } from 'ontime-types';
|
||||||
|
|
||||||
import { queryRefetchInterval } from '../../ontimeConfig';
|
import { queryRefetchInterval } from '../../ontimeConfig';
|
||||||
import { RUNDOWN_TABLE } from '../api/apiConstants';
|
import { RUNDOWN } from '../api/apiConstants';
|
||||||
import { fetchRundown } from '../api/eventsApi';
|
import { fetchCachedRundown } from '../api/eventsApi';
|
||||||
|
|
||||||
|
const cachedRundownPlaceholder = { rundown: [], revision: -1 };
|
||||||
|
|
||||||
|
// TODO: can we leverage structural sharing to see if data has changed?
|
||||||
export default function useRundown() {
|
export default function useRundown() {
|
||||||
const { data, status, isError, refetch } = useQuery({
|
const { data, status, isError, refetch, isFetching } = useQuery<GetRundownCached>({
|
||||||
queryKey: RUNDOWN_TABLE,
|
queryKey: RUNDOWN,
|
||||||
queryFn: fetchRundown,
|
queryFn: fetchCachedRundown,
|
||||||
placeholderData: [],
|
placeholderData: cachedRundownPlaceholder,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
refetchInterval: queryRefetchInterval,
|
refetchInterval: queryRefetchInterval,
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
|
// structuralSharing: (oldData: GetRundownCached | undefined, newData: GetRundownCached) => {
|
||||||
|
// if (oldData === undefined) {
|
||||||
|
// return cachedRundownPlaceholder;
|
||||||
|
// }
|
||||||
|
// const hasDataChanged = oldData?.revision === newData.revision;
|
||||||
|
// return hasDataChanged ? oldData : newData;
|
||||||
|
// },
|
||||||
});
|
});
|
||||||
|
return { data: data?.rundown ?? [], status, isError, refetch, isFetching };
|
||||||
return { data, status, isError, refetch };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export default function useClickOutside<T extends HTMLElement = HTMLElement>(
|
|||||||
ref: RefObject<T>,
|
ref: RefObject<T>,
|
||||||
callback: ClickOutsideEventHandler,
|
callback: ClickOutsideEventHandler,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClick(event: MouseEvent) {
|
function handleClick(event: MouseEvent) {
|
||||||
const element = ref?.current;
|
const element = ref?.current;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export default function useElectronEvent() {
|
export default function useElectronEvent() {
|
||||||
const isElectron = window?.process?.type === 'renderer';
|
const isElectron = window?.process?.type === 'renderer';
|
||||||
|
|
||||||
const sendToElectron = (channel: string, args?: string | Record<string, any>) => {
|
const sendToElectron = (channel: string, args?: string | Record<string, unknown>) => {
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
window?.ipcRenderer.send(channel, args);
|
window?.ipcRenderer.send(channel, args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||||
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
|
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
|
||||||
|
|
||||||
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
|
import { RUNDOWN } from '../api/apiConstants';
|
||||||
import { logAxiosError } from '../api/apiUtils';
|
import { logAxiosError } from '../api/apiUtils';
|
||||||
import {
|
import {
|
||||||
ReorderEntry,
|
ReorderEntry,
|
||||||
@@ -36,7 +36,7 @@ export const useEventAction = () => {
|
|||||||
// Fetch anyway, just to be sure
|
// Fetch anyway, just to be sure
|
||||||
mutationFn: requestPostEvent,
|
mutationFn: requestPostEvent,
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
},
|
},
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
@@ -67,8 +67,10 @@ export const useEventAction = () => {
|
|||||||
after: options?.after,
|
after: options?.after,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
|
||||||
|
|
||||||
if (newEvent?.cue === undefined) {
|
if (newEvent?.cue === undefined) {
|
||||||
newEvent.cue = getCueCandidate(queryClient.getQueryData(RUNDOWN_TABLE) || [], options?.after);
|
newEvent.cue = getCueCandidate(rundown, options?.after);
|
||||||
}
|
}
|
||||||
|
|
||||||
// hard coding duration value to be as expected for now
|
// hard coding duration value to be as expected for now
|
||||||
@@ -78,7 +80,6 @@ export const useEventAction = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
|
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
|
||||||
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
|
|
||||||
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
|
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
|
||||||
if (previousEvent !== undefined && previousEvent.type === 'event') {
|
if (previousEvent !== undefined && previousEvent.type === 'event') {
|
||||||
newEvent.timeStart = previousEvent.timeEnd;
|
newEvent.timeStart = previousEvent.timeEnd;
|
||||||
@@ -100,7 +101,7 @@ export const useEventAction = () => {
|
|||||||
// @ts-expect-error -- we know that the object is well formed now
|
// @ts-expect-error -- we know that the object is well formed now
|
||||||
await _addEventMutation.mutateAsync(newEvent);
|
await _addEventMutation.mutateAsync(newEvent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error fetching data', error);
|
logAxiosError('Failed adding event', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
|
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
|
||||||
@@ -115,25 +116,35 @@ export const useEventAction = () => {
|
|||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async (newEvent) => {
|
onMutate: async (newEvent) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, newEvent.id]);
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
// Snapshot the previous value
|
// Snapshot the previous value
|
||||||
const previousEvent = queryClient.getQueryData([RUNDOWN_TABLE_KEY, newEvent.id]);
|
|
||||||
|
|
||||||
// optimistically update object
|
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||||
queryClient.setQueryData([RUNDOWN_TABLE_KEY, newEvent.id], newEvent);
|
|
||||||
|
if (previousData) {
|
||||||
|
// optimistically update object
|
||||||
|
const optimisticRundown = [...previousData.rundown];
|
||||||
|
const index = optimisticRundown.findIndex((event) => event.id === newEvent.id);
|
||||||
|
if (index > -1) {
|
||||||
|
// @ts-expect-error -- we expect the event types to match
|
||||||
|
optimisticRundown[index] = { ...optimisticRundown[index], ...newEvent };
|
||||||
|
|
||||||
|
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return a context with the previous and new events
|
// Return a context with the previous and new events
|
||||||
return { previousEvent, newEvent };
|
return { previousData, newEvent };
|
||||||
},
|
},
|
||||||
// Mutation fails, rollback undoes optimist update
|
// Mutation fails, rollback undoes optimist update
|
||||||
onError: (_error, _newEvent, context) => {
|
onError: (_error, _newEvent, context) => {
|
||||||
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
|
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
||||||
},
|
},
|
||||||
// Mutation finished, failed or successful
|
// Mutation finished, failed or successful
|
||||||
// Fetch anyway, just to be sure
|
// Fetch anyway, just to be sure
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await queryClient.invalidateQueries([RUNDOWN_TABLE_KEY]);
|
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
},
|
},
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
@@ -161,28 +172,37 @@ export const useEventAction = () => {
|
|||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async (eventId) => {
|
onMutate: async (eventId) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, eventId]);
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
// Snapshot the previous value
|
// Snapshot the previous value
|
||||||
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
|
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||||
|
|
||||||
const filtered = [...(previousEvents as OntimeRundown)].filter((e) => e.id !== eventId);
|
if (previousData) {
|
||||||
|
// optimistically update object
|
||||||
|
const optimisticRundown = [...previousData.rundown];
|
||||||
|
const index = optimisticRundown.findIndex((event) => event.id === eventId);
|
||||||
|
if (index > -1) {
|
||||||
|
optimisticRundown.splice(index, 1);
|
||||||
|
|
||||||
// optimistically update object
|
queryClient.setQueryData(RUNDOWN, {
|
||||||
queryClient.setQueryData(RUNDOWN_TABLE, filtered);
|
rundown: optimisticRundown,
|
||||||
|
revision: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return a context with the previous and new events
|
// Return a context with the previous and new events
|
||||||
return { previousEvents };
|
return { previousData };
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mutation fails, rollback undoes optimist update
|
// Mutation fails, rollback undoes optimist update
|
||||||
onError: (_error, _eventId, context) => {
|
onError: (_error, _eventId, context) => {
|
||||||
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
|
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
||||||
},
|
},
|
||||||
// Mutation finished, failed or successful
|
// Mutation finished, failed or successful
|
||||||
// Fetch anyway, just to be sure
|
// Fetch anyway, just to be sure
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
},
|
},
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
@@ -210,26 +230,26 @@ export const useEventAction = () => {
|
|||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async () => {
|
onMutate: async () => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
// Snapshot the previous value
|
// Snapshot the previous value
|
||||||
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
|
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||||
|
|
||||||
// optimistically update object
|
// optimistically update object
|
||||||
queryClient.setQueryData(RUNDOWN_TABLE, []);
|
queryClient.setQueryData(RUNDOWN, { rundown: [], revision: -1 });
|
||||||
|
|
||||||
// Return a context with the previous and new events
|
// Return a context with the previous and new events
|
||||||
return { previousEvents };
|
return { previousData };
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mutation fails, rollback undos optimist update
|
// Mutation fails, rollback undos optimist update
|
||||||
onError: (_error, _eventId, context) => {
|
onError: (_error, _eventId, context) => {
|
||||||
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
|
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
||||||
},
|
},
|
||||||
// Mutation finished, failed or successful
|
// Mutation finished, failed or successful
|
||||||
// Fetch anyway, just to be sure
|
// Fetch anyway, just to be sure
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
},
|
},
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
@@ -253,7 +273,7 @@ export const useEventAction = () => {
|
|||||||
mutationFn: requestApplyDelay,
|
mutationFn: requestApplyDelay,
|
||||||
// Mutation finished, failed or successful
|
// Mutation finished, failed or successful
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
},
|
},
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
@@ -281,30 +301,32 @@ export const useEventAction = () => {
|
|||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async (data) => {
|
onMutate: async (data) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
// Snapshot the previous value
|
// Snapshot the previous value
|
||||||
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
|
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||||
|
|
||||||
const e = [...(previousEvents as OntimeRundown)];
|
if (previousData) {
|
||||||
const [reorderedItem] = e.splice(data.from, 1);
|
// optimistically update object
|
||||||
e.splice(data.to, 0, reorderedItem);
|
const optimisticRundown = [...previousData.rundown];
|
||||||
|
const [reorderedItem] = optimisticRundown.splice(data.from, 1);
|
||||||
|
optimisticRundown.splice(data.to, 0, reorderedItem);
|
||||||
|
|
||||||
// optimistically update object
|
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
|
||||||
queryClient.setQueryData(RUNDOWN_TABLE, e);
|
}
|
||||||
|
|
||||||
// Return a context with the previous and new events
|
// Return a context with the previous and new events
|
||||||
return { previousEvents };
|
return { previousData };
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mutation fails, rollback undoes optimist update
|
// Mutation fails, rollback undoes optimist update
|
||||||
onError: (_error, _eventId, context) => {
|
onError: (_error, _eventId, context) => {
|
||||||
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
|
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
||||||
},
|
},
|
||||||
// Mutation finished, failed or successful
|
// Mutation finished, failed or successful
|
||||||
// Fetch anyway, just to be sure
|
// Fetch anyway, just to be sure
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
},
|
},
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
@@ -337,31 +359,32 @@ export const useEventAction = () => {
|
|||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async ({ from, to }) => {
|
onMutate: async ({ from, to }) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
// Snapshot the previous value
|
// Snapshot the previous value
|
||||||
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
|
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
|
||||||
|
if (previousData) {
|
||||||
|
// optimistically update object
|
||||||
|
const fromEventIndex = previousData.rundown.findIndex((event) => event.id === from);
|
||||||
|
const toEventIndex = previousData.rundown.findIndex((event) => event.id === to);
|
||||||
|
|
||||||
const fromEventIndex = rundown.findIndex((event) => event.id === from);
|
const optimisticRundown = swapOntimeEvents(previousData.rundown, fromEventIndex, toEventIndex);
|
||||||
const toEventIndex = rundown.findIndex((event) => event.id === to);
|
|
||||||
|
|
||||||
const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
|
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
|
||||||
|
}
|
||||||
// optimistically update object
|
|
||||||
queryClient.setQueryData(RUNDOWN_TABLE, previousEvents);
|
|
||||||
|
|
||||||
// Return a context with the previous events
|
// Return a context with the previous events
|
||||||
return { previousEvents };
|
return { previousData };
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mutation fails, rollback undoes optimist update
|
// Mutation fails, rollback undoes optimist update
|
||||||
onError: (_error, _eventId, context) => {
|
onError: (_error, _eventId, context) => {
|
||||||
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
|
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
||||||
},
|
},
|
||||||
// Mutation finished, failed or successful
|
// Mutation finished, failed or successful
|
||||||
// Fetch anyway, just to be sure
|
// Fetch anyway, just to be sure
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries(RUNDOWN_TABLE);
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
},
|
},
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
export type TLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';
|
export type TLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';
|
||||||
|
|
||||||
@@ -26,13 +20,13 @@ const LOG_LEVEL: Record<TLogLevel, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const useFitText = ({
|
const useFitText = ({
|
||||||
logLevel: logLevelOption = 'info',
|
logLevel: logLevelOption = 'info',
|
||||||
maxFontSize = 100,
|
maxFontSize = 100,
|
||||||
minFontSize = 20,
|
minFontSize = 20,
|
||||||
onFinish,
|
onFinish,
|
||||||
onStart,
|
onStart,
|
||||||
resolution = 5,
|
resolution = 5,
|
||||||
}: TOptions = {}) => {
|
}: TOptions = {}) => {
|
||||||
const logLevel = LOG_LEVEL[logLevelOption];
|
const logLevel = LOG_LEVEL[logLevelOption];
|
||||||
|
|
||||||
const initState = useCallback(() => {
|
const initState = useCallback(() => {
|
||||||
@@ -112,8 +106,7 @@ const useFitText = ({
|
|||||||
const isWithinResolution = Math.abs(fontSize - fontSizePrev) <= resolution;
|
const isWithinResolution = Math.abs(fontSize - fontSizePrev) <= resolution;
|
||||||
const isOverflow =
|
const isOverflow =
|
||||||
!!ref.current &&
|
!!ref.current &&
|
||||||
(ref.current.scrollHeight > ref.current.offsetHeight ||
|
(ref.current.scrollHeight > ref.current.offsetHeight || ref.current.scrollWidth > ref.current.offsetWidth);
|
||||||
ref.current.scrollWidth > ref.current.offsetWidth);
|
|
||||||
const isFailed = isOverflow && fontSize === fontSizePrev;
|
const isFailed = isOverflow && fontSize === fontSizePrev;
|
||||||
const isAsc = fontSize > fontSizePrev;
|
const isAsc = fontSize > fontSizePrev;
|
||||||
|
|
||||||
@@ -123,9 +116,7 @@ const useFitText = ({
|
|||||||
if (isFailed) {
|
if (isFailed) {
|
||||||
isCalculatingRef.current = false;
|
isCalculatingRef.current = false;
|
||||||
if (logLevel <= LOG_LEVEL.info) {
|
if (logLevel <= LOG_LEVEL.info) {
|
||||||
console.info(
|
console.info(`[use-fit-text] reached \`minFontSize = ${minFontSize}\` without fitting text`);
|
||||||
`[use-fit-text] reached \`minFontSize = ${minFontSize}\` without fitting text`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (isOverflow) {
|
} else if (isOverflow) {
|
||||||
setState({
|
setState({
|
||||||
@@ -160,16 +151,7 @@ const useFitText = ({
|
|||||||
fontSizeMin: newMin,
|
fontSizeMin: newMin,
|
||||||
fontSizePrev: fontSize,
|
fontSizePrev: fontSize,
|
||||||
});
|
});
|
||||||
}, [
|
}, [calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev, onFinish, ref, resolution]);
|
||||||
calcKey,
|
|
||||||
fontSize,
|
|
||||||
fontSizeMax,
|
|
||||||
fontSizeMin,
|
|
||||||
fontSizePrev,
|
|
||||||
onFinish,
|
|
||||||
ref,
|
|
||||||
resolution,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { fontSize: `${fontSize}%`, ref };
|
return { fontSize: `${fontSize}%`, ref };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ interface UseFollowComponentProps {
|
|||||||
scrollRef: MutableRefObject<HTMLElement | null>;
|
scrollRef: MutableRefObject<HTMLElement | null>;
|
||||||
doFollow: boolean;
|
doFollow: boolean;
|
||||||
topOffset?: number;
|
topOffset?: number;
|
||||||
setScrollFlag?: () => void;
|
setScrollFlag?: (newValue: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useFollowComponent(props: UseFollowComponentProps) {
|
export default function useFollowComponent(props: UseFollowComponentProps) {
|
||||||
@@ -34,14 +34,15 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (followRef.current && scrollRef.current) {
|
if (followRef.current && scrollRef.current) {
|
||||||
|
setScrollFlag?.(true);
|
||||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
setScrollFlag?.();
|
|
||||||
scrollToComponent(
|
scrollToComponent(
|
||||||
followRef as MutableRefObject<HTMLElement>,
|
followRef as MutableRefObject<HTMLElement>,
|
||||||
scrollRef as MutableRefObject<HTMLElement>,
|
scrollRef as MutableRefObject<HTMLElement>,
|
||||||
topOffset,
|
topOffset,
|
||||||
);
|
);
|
||||||
|
setScrollFlag?.(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-nocheck -- working on it
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface WebkitDocument extends Document {
|
interface WebkitDocument extends Document {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description utility hook to around setInterval
|
* @description utility hook to around setInterval
|
||||||
|
|||||||
@@ -1,53 +1,46 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
/**
|
const STORAGE_EVENT = 'ontime-storage';
|
||||||
* @description utility hook to handle state in local storage
|
|
||||||
* @param key
|
|
||||||
* @param initialValue
|
|
||||||
*/
|
|
||||||
export const useLocalStorage = <T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] => {
|
|
||||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
||||||
try {
|
|
||||||
const item = window.localStorage.getItem(`ontime-${key}`);
|
|
||||||
return item ? JSON.parse(item) : initialValue;
|
|
||||||
} catch (error) {
|
|
||||||
return initialValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
function getSnapshot(key: string): string | null {
|
||||||
const handleStorageChange = (event: StorageEvent) => {
|
try {
|
||||||
if (event.storageArea === window.localStorage && event.key === key) {
|
return window.localStorage.getItem(`ontime-${key}`);
|
||||||
try {
|
} catch {
|
||||||
const newValue = event.newValue ? JSON.parse(event.newValue) : initialValue;
|
return null;
|
||||||
setStoredValue(newValue);
|
}
|
||||||
} catch (_) {
|
}
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('storage', handleStorageChange);
|
function getParsedJson<T>(localStorageValue: string | null, initialValue: T): T {
|
||||||
|
try {
|
||||||
|
return localStorageValue ? JSON.parse(localStorageValue) : initialValue;
|
||||||
|
} catch {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
export const useLocalStorage = <T>(key: string, initialValue: T) => {
|
||||||
window.removeEventListener('storage', handleStorageChange);
|
const localStorageValue = useSyncExternalStore(subscribe, () => getSnapshot(key));
|
||||||
};
|
const parsedLocalStorageValue = getParsedJson(localStorageValue, initialValue);
|
||||||
}, [initialValue, key]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Set value to local storage
|
* @description Set value to local storage
|
||||||
* @param value
|
* @param value
|
||||||
*/
|
*/
|
||||||
const setValue = (value: T | ((val: T) => T)) => {
|
const setLocalStorageValue = (value: T | ((val: T) => T)) => {
|
||||||
try {
|
// Allow value to be a function so we have same API as useState
|
||||||
// Allow value to be a function so we have same API as useState
|
const valueToStore = value instanceof Function ? value(parsedLocalStorageValue) : value;
|
||||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
|
||||||
|
|
||||||
setStoredValue(valueToStore);
|
localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
|
||||||
window.localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
|
window.dispatchEvent(new StorageEvent(STORAGE_EVENT));
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
return [storedValue, setValue];
|
|
||||||
|
return [parsedLocalStorageValue, setLocalStorageValue] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function subscribe(callback: () => void) {
|
||||||
|
window.addEventListener(STORAGE_EVENT, callback);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(STORAGE_EVENT, callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { MouseEvent, SyntheticEvent, TouchEvent, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
type LongPressOptions = {
|
||||||
|
threshold?: number;
|
||||||
|
onStart?: (e: SyntheticEvent) => void;
|
||||||
|
onFinish?: (e: SyntheticEvent) => void;
|
||||||
|
onCancel?: (e: SyntheticEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LongPressFns = {
|
||||||
|
onMouseDown: (e: MouseEvent) => void;
|
||||||
|
onMouseUp: (e: MouseEvent) => void;
|
||||||
|
onMouseLeave: (e: MouseEvent) => void;
|
||||||
|
onTouchStart: (e: TouchEvent) => void;
|
||||||
|
onTouchEnd: (e: TouchEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useLongPress(callback: () => void, options: LongPressOptions = {}): LongPressFns {
|
||||||
|
const { threshold = 400, onStart, onFinish, onCancel } = options;
|
||||||
|
const isLongPressActive = useRef(false);
|
||||||
|
const isPressed = useRef(false);
|
||||||
|
const timerId = useRef<NodeJS.Timer>();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const start = (event: SyntheticEvent) => {
|
||||||
|
if (onStart) {
|
||||||
|
onStart(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
isPressed.current = true;
|
||||||
|
timerId.current = setTimeout(() => {
|
||||||
|
callback();
|
||||||
|
isLongPressActive.current = true;
|
||||||
|
}, threshold);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = (event: SyntheticEvent) => {
|
||||||
|
if (isLongPressActive.current) {
|
||||||
|
if (onFinish) {
|
||||||
|
onFinish(event);
|
||||||
|
}
|
||||||
|
} else if (isPressed.current) {
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLongPressActive.current = false;
|
||||||
|
isPressed.current = false;
|
||||||
|
|
||||||
|
if (timerId.current) {
|
||||||
|
clearTimeout(timerId.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onMouseDown: start,
|
||||||
|
onMouseUp: cancel,
|
||||||
|
onMouseLeave: cancel,
|
||||||
|
onTouchStart: start,
|
||||||
|
onTouchEnd: cancel,
|
||||||
|
};
|
||||||
|
}, [callback, threshold, onCancel, onFinish, onStart]);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export const useMessageControl = () => {
|
|||||||
timerMessage: state.timerMessage,
|
timerMessage: state.timerMessage,
|
||||||
publicMessage: state.publicMessage,
|
publicMessage: state.publicMessage,
|
||||||
lowerMessage: state.lowerMessage,
|
lowerMessage: state.lowerMessage,
|
||||||
|
externalMessage: state.externalMessage,
|
||||||
onAir: state.onAir,
|
onAir: state.onAir,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,6 +41,8 @@ export const setMessage = {
|
|||||||
publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload),
|
publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload),
|
||||||
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload),
|
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload),
|
||||||
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload),
|
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload),
|
||||||
|
externalText: (payload: string) => socketSendJson('set-external-message-text', payload),
|
||||||
|
externalVisible: (payload: boolean) => socketSendJson('set-external-message-visible', payload),
|
||||||
onAir: (payload: boolean) => socketSendJson('set-onAir', payload),
|
onAir: (payload: boolean) => socketSendJson('set-onAir', payload),
|
||||||
timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload),
|
timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload),
|
||||||
timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload),
|
timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload),
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { Settings } from 'ontime-types';
|
import { GetInfo, OSCSettings } from 'ontime-types';
|
||||||
|
|
||||||
type NetworkInterfaceType = {
|
export const oscPlaceholderSettings: OSCSettings = {
|
||||||
name: string;
|
portIn: 0,
|
||||||
address: string;
|
portOut: 0,
|
||||||
};
|
targetIP: '',
|
||||||
|
enabledIn: false,
|
||||||
export type InfoType = {
|
enabledOut: false,
|
||||||
networkInterfaces: NetworkInterfaceType[];
|
subscriptions: {
|
||||||
settings: Pick<Settings, 'version' | 'serverPort'>;
|
onLoad: [],
|
||||||
};
|
onStart: [],
|
||||||
|
onPause: [],
|
||||||
export const ontimePlaceholderInfo: InfoType = {
|
onStop: [],
|
||||||
networkInterfaces: [],
|
onUpdate: [],
|
||||||
settings: {
|
onFinish: [],
|
||||||
version: 2,
|
|
||||||
serverPort: 4001,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ontimePlaceholderInfo: GetInfo = {
|
||||||
|
networkInterfaces: [],
|
||||||
|
version: '2.0.0',
|
||||||
|
serverPort: 4001,
|
||||||
|
osc: oscPlaceholderSettings,
|
||||||
|
cssOverride: '',
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Settings } from 'ontime-types';
|
|||||||
|
|
||||||
export const ontimePlaceholderSettings: Settings = {
|
export const ontimePlaceholderSettings: Settings = {
|
||||||
app: 'ontime',
|
app: 'ontime',
|
||||||
version: 2,
|
version: '2.0.0',
|
||||||
serverPort: 4001,
|
serverPort: 4001,
|
||||||
editorKey: null,
|
editorKey: null,
|
||||||
operatorKey: null,
|
operatorKey: null,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { QueryClient } from '@tanstack/react-query';
|
|||||||
export const ontimeQueryClient = new QueryClient({
|
export const ontimeQueryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
cacheTime: 1000 * 60 * 10, // 10 min
|
gcTime: 1000 * 60 * 10, // 10 min
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Playback, RuntimeStore } from 'ontime-types';
|
|||||||
import { useStore } from 'zustand';
|
import { useStore } from 'zustand';
|
||||||
import { createStore } from 'zustand/vanilla';
|
import { createStore } from 'zustand/vanilla';
|
||||||
|
|
||||||
export const runtimeStorePlaceholder = {
|
export const runtimeStorePlaceholder: RuntimeStore = {
|
||||||
timer: {
|
timer: {
|
||||||
clock: 0,
|
clock: 0,
|
||||||
current: null,
|
current: null,
|
||||||
@@ -33,6 +33,10 @@ export const runtimeStorePlaceholder = {
|
|||||||
text: '',
|
text: '',
|
||||||
visible: false,
|
visible: false,
|
||||||
},
|
},
|
||||||
|
externalMessage: {
|
||||||
|
text: '',
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
onAir: false,
|
onAir: false,
|
||||||
loaded: {
|
loaded: {
|
||||||
numEvents: 0,
|
numEvents: 0,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { resolvePath } from 'react-router-dom';
|
import { resolvePath } from 'react-router-dom';
|
||||||
import { validateAlias, generateURLFromAlias, getAliasRoute } from '../aliases';
|
|
||||||
|
import { generateURLFromAlias, getAliasRoute, validateAlias } from '../aliases';
|
||||||
|
|
||||||
describe('An alias fails if incorrect', () => {
|
describe('An alias fails if incorrect', () => {
|
||||||
const testsToFail = [
|
const testsToFail = [
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test('Clamps a set of numbers correctly', () => {
|
|||||||
{ num: -50, min: 0, max: 0, result: 0 },
|
{ num: -50, min: 0, max: 0, result: 0 },
|
||||||
{ num: 50.5, min: 0, max: 100, result: 50.5 },
|
{ num: 50.5, min: 0, max: 100, result: 50.5 },
|
||||||
{ num: 50, min: 0, max: 20.32, result: 20.32 },
|
{ num: 50, min: 0, max: 20.32, result: 20.32 },
|
||||||
{ num: 10, min: 20.32, max: 40, result: 20.32 }
|
{ num: 10, min: 20.32, max: 40, result: 20.32 },
|
||||||
];
|
];
|
||||||
|
|
||||||
testCases.forEach((t) => expect(clamp(t.num, t.min, t.max)).toBe(t.result));
|
testCases.forEach((t) => expect(clamp(t.num, t.min, t.max)).toBe(t.result));
|
||||||
|
|||||||
+10
@@ -26,4 +26,14 @@ describe('formatTime()', () => {
|
|||||||
const time = formatTime(ms);
|
const time = formatTime(ms);
|
||||||
expect(time).toStrictEqual('...');
|
expect(time).toStrictEqual('...');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows 12h format without times', () => {
|
||||||
|
const ms = 13 * 60 * 60 * 1000;
|
||||||
|
const options = {
|
||||||
|
showSeconds: false,
|
||||||
|
format: 'hh:mm a',
|
||||||
|
};
|
||||||
|
const time = formatTime(ms, options, () => '12');
|
||||||
|
expect(time).toStrictEqual('01:00 PM');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -47,7 +47,7 @@ export const getAliasRoute = (location: Location, data: Alias[], searchParams: U
|
|||||||
const aliasOnPage = searchParams.get('alias');
|
const aliasOnPage = searchParams.get('alias');
|
||||||
for (const d of data) {
|
for (const d of data) {
|
||||||
if (aliasOnPage) {
|
if (aliasOnPage) {
|
||||||
// if the alias fits the alias on this page, but the URL is diferent, we redirect user to the new URL
|
// if the alias fits the alias on this page, but the URL is different, we redirect user to the new URL
|
||||||
// if we have the same alias and its enabled and its not empty
|
// if we have the same alias and its enabled and its not empty
|
||||||
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
|
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
|
||||||
const newAliasPath = resolvePath(d.pathAndParams);
|
const newAliasPath = resolvePath(d.pathAndParams);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||||
|
|
||||||
|
type FileOptions = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlobOptions = {
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
|
||||||
|
const response = await axios({
|
||||||
|
url: `${url}/db`,
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerLine = response.headers['Content-Disposition'];
|
||||||
|
let { name: fileName } = fileOptions;
|
||||||
|
const { type: fileType } = fileOptions;
|
||||||
|
const { project, rundown, userFields } = response.data;
|
||||||
|
|
||||||
|
// try and get the filename from the response
|
||||||
|
if (headerLine != null) {
|
||||||
|
const startFileNameIndex = headerLine.indexOf('"') + 1;
|
||||||
|
const endFileNameIndex = headerLine.lastIndexOf('"');
|
||||||
|
fileName = headerLine.substring(startFileNameIndex, endFileNameIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileContent = '';
|
||||||
|
|
||||||
|
if (fileType === 'json') {
|
||||||
|
fileContent = JSON.stringify(response.data);
|
||||||
|
fileName += '.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileType === 'csv') {
|
||||||
|
const sheetData = makeTable(project, rundown, userFields);
|
||||||
|
fileContent = makeCSV(sheetData);
|
||||||
|
fileName += '.csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([fileContent], { type: blobOptions.type });
|
||||||
|
const downloadUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.setAttribute('href', downloadUrl);
|
||||||
|
link.setAttribute('download', fileName);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
// Clean up the URL.createObjectURL to release resources
|
||||||
|
URL.revokeObjectURL(downloadUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
@@ -129,7 +129,7 @@ export const socketSend = (message: any) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const socketSendJson = (type: string, payload?: any) => {
|
export const socketSendJson = (type: string, payload?: unknown) => {
|
||||||
socketSend(
|
socketSend(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type,
|
type,
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ type ColourCombination = {
|
|||||||
* @param bgColour
|
* @param bgColour
|
||||||
* @return {{backgroundColor, color: string}}
|
* @return {{backgroundColor, color: string}}
|
||||||
*/
|
*/
|
||||||
export const getAccessibleColour = (bgColour: string): ColourCombination => {
|
export const getAccessibleColour = (bgColour?: string): ColourCombination => {
|
||||||
if (bgColour) {
|
if (bgColour) {
|
||||||
try {
|
try {
|
||||||
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
|
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
|
||||||
return { backgroundColor: bgColour, color: textColor };
|
return { backgroundColor: bgColour, color: textColor };
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.log(`Unable to parse colour: ${bgColour}`);
|
/* we do not handle errors here */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { backgroundColor: '#000', color: '#fffffa' };
|
return { backgroundColor: '#000', color: '#fffffa' };
|
||||||
|
|||||||
+2
-3
@@ -13,9 +13,8 @@ declare global {
|
|||||||
};
|
};
|
||||||
process: {
|
process: {
|
||||||
type: string;
|
type: string;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-anonymous-default-export
|
export default {};
|
||||||
export default {}
|
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,6 @@ import 'vitest';
|
|||||||
// https://github.com/testing-library/jest-dom/issues/123
|
// https://github.com/testing-library/jest-dom/issues/123
|
||||||
declare global {
|
declare global {
|
||||||
namespace Vi {
|
namespace Vi {
|
||||||
interface Assertion<T = any> extends TestingLibraryMatchers<T, void> {}
|
type Assertion<T = any> = TestingLibraryMatchers<T, void>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const withAlias = <P extends object>(Component: ComponentType<P>) => {
|
|||||||
}
|
}
|
||||||
}, [data, searchParams, navigate, location]);
|
}, [data, searchParams, navigate, location]);
|
||||||
|
|
||||||
return <Component {...props} />;
|
return <Component {...(props as P)} />;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Input } from '@chakra-ui/react';
|
import { IconButton, Input } from '@chakra-ui/react';
|
||||||
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
import { IoEye } from '@react-icons/all-files/io5/IoEye';
|
||||||
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
|
||||||
|
|
||||||
@@ -13,13 +13,14 @@ interface InputRowProps {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
text: string;
|
text: string;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
actionHandler: (action: string, payload: object) => void;
|
actionHandler: (action: string, payload: object) => void;
|
||||||
changeHandler: (newValue: string) => void;
|
changeHandler: (newValue: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InputRow(props: InputRowProps) {
|
export default function InputRow(props: InputRowProps) {
|
||||||
const { label, placeholder, text, visible, actionHandler, changeHandler, className } = props;
|
const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
|
||||||
|
|
||||||
const handleInputChange = (newValue: string) => {
|
const handleInputChange = (newValue: string) => {
|
||||||
changeHandler(newValue);
|
changeHandler(newValue);
|
||||||
@@ -33,19 +34,31 @@ export default function InputRow(props: InputRowProps) {
|
|||||||
<Input
|
<Input
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(event) => handleInputChange(event.target.value)}
|
onChange={(event) => handleInputChange(event.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
<TooltipActionBtn
|
{readonly ? (
|
||||||
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
|
<IconButton
|
||||||
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
size='sm'
|
||||||
aria-label={`Toggle ${label}`}
|
isDisabled
|
||||||
openDelay={tooltipDelayMid}
|
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||||
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
aria-label={`Toggle ${label}`}
|
||||||
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
size='sm'
|
/>
|
||||||
/>
|
) : (
|
||||||
|
<TooltipActionBtn
|
||||||
|
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
|
||||||
|
tooltip={visible ? 'Make invisible' : 'Make visible'}
|
||||||
|
aria-label={`Toggle ${label}`}
|
||||||
|
openDelay={tooltipDelayMid}
|
||||||
|
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
|
||||||
|
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
|
size='sm'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,3 @@
|
|||||||
color: $action-text-color;
|
color: $action-text-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.padTop {
|
|
||||||
margin-top: $section-spacing;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -34,34 +34,48 @@ export default function MessageControl() {
|
|||||||
actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)}
|
actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)}
|
||||||
/>
|
/>
|
||||||
<InputRow
|
<InputRow
|
||||||
label='Timer message'
|
label='Timer'
|
||||||
placeholder='Shown in stage timer'
|
placeholder='Message shown in stage timer'
|
||||||
text={data.timerMessage.text || ''}
|
text={data.timerMessage.text || ''}
|
||||||
visible={data.timerMessage.visible || false}
|
visible={data.timerMessage.visible || false}
|
||||||
changeHandler={(newValue) => setMessage.presenterText(newValue)}
|
changeHandler={(newValue) => setMessage.presenterText(newValue)}
|
||||||
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
|
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
|
||||||
/>
|
/>
|
||||||
<div className={style.buttonSection}>
|
<div className={style.buttonSection}>
|
||||||
<label className={style.label}>Timer messsage blink</label>
|
|
||||||
<label className={style.label}>Blackout timer screens</label>
|
|
||||||
<Button
|
<Button
|
||||||
|
size='sm'
|
||||||
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
|
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
|
||||||
variant={data.timerMessage.timerBlink ? 'ontime-filled' : 'ontime-subtle'}
|
variant={data.timerMessage.timerBlink ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
leftIcon={data.timerMessage.timerBlink ? <IoSunny size='24px' /> : <IoSunnyOutline size='24px' />}
|
leftIcon={data.timerMessage.timerBlink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
|
||||||
onClick={() => setMessage.timerBlink(!data.timerMessage.timerBlink)}
|
onClick={() => setMessage.timerBlink(!data.timerMessage.timerBlink)}
|
||||||
data-testid='toggle timer blink'
|
data-testid='toggle timer blink'
|
||||||
/>
|
>
|
||||||
|
Blink message
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
size='sm'
|
||||||
className={style.blackoutButton}
|
className={style.blackoutButton}
|
||||||
variant={data.timerMessage.timerBlackout ? 'ontime-filled' : 'ontime-subtle'}
|
variant={data.timerMessage.timerBlackout ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
leftIcon={data.timerMessage.timerBlackout ? <IoEye size='24px' /> : <IoEyeOffOutline size='24px' />}
|
leftIcon={data.timerMessage.timerBlackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
|
||||||
onClick={() => setMessage.timerBlackout(!data.timerMessage.timerBlackout)}
|
onClick={() => setMessage.timerBlackout(!data.timerMessage.timerBlackout)}
|
||||||
data-testid='toggle timer blackout'
|
data-testid='toggle timer blackout'
|
||||||
/>
|
>
|
||||||
|
Blackout screen
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${style.onAirSection} ${style.padTop}`}>
|
<InputRow
|
||||||
|
label='External Message'
|
||||||
|
placeholder='-'
|
||||||
|
readonly
|
||||||
|
text={data.externalMessage.text || ''}
|
||||||
|
visible={data.externalMessage.visible || false}
|
||||||
|
changeHandler={() => undefined}
|
||||||
|
actionHandler={() => undefined}
|
||||||
|
/>
|
||||||
|
<div className={style.onAirSection}>
|
||||||
<label className={style.label}>Toggle On Air state</label>
|
<label className={style.label}>Toggle On Air state</label>
|
||||||
<Button
|
<Button
|
||||||
|
size='sm'
|
||||||
variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'}
|
variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'}
|
||||||
leftIcon={data.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
|
leftIcon={data.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
|
||||||
onClick={() => setMessage.onAir(!data.onAir)}
|
onClick={() => setMessage.onAir(!data.onAir)}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ $table-header-font-size: calc(1rem - 3px);
|
|||||||
min-width: 2rem;
|
min-width: 2rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background-color: $gray-1300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
|
import Color from 'color';
|
||||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
@@ -23,10 +24,7 @@ interface CuesheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
|
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
|
||||||
const followSelected = useCuesheetSettings((state) => state.followSelected);
|
const { followSelected, showSettings, showDelayBlock, showPrevious } = useCuesheetSettings();
|
||||||
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
|
||||||
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
|
|
||||||
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
|
||||||
|
|
||||||
const [columnVisibility, setColumnVisibility] = useLocalStorage('table-hidden', {});
|
const [columnVisibility, setColumnVisibility] = useLocalStorage('table-hidden', {});
|
||||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
|
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
|
||||||
@@ -65,7 +63,9 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
setColumnSizing({});
|
setColumnSizing({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const headerGroups = table.getHeaderGroups;
|
const headerGroups = table.getHeaderGroups();
|
||||||
|
const rowModel = table.getRowModel();
|
||||||
|
const allLeafColumns = table.getAllLeafColumns();
|
||||||
|
|
||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
let isPast = Boolean(selectedId);
|
let isPast = Boolean(selectedId);
|
||||||
@@ -74,7 +74,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
<>
|
<>
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<CuesheetTableSettings
|
<CuesheetTableSettings
|
||||||
columns={table.getAllLeafColumns()}
|
columns={allLeafColumns}
|
||||||
handleResetResizing={resetColumnResizing}
|
handleResetResizing={resetColumnResizing}
|
||||||
handleResetReordering={resetColumnOrder}
|
handleResetReordering={resetColumnOrder}
|
||||||
handleClearToggles={setAllVisible}
|
handleClearToggles={setAllVisible}
|
||||||
@@ -84,7 +84,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
<table className={style.cuesheet}>
|
<table className={style.cuesheet}>
|
||||||
<CuesheetHeader headerGroups={headerGroups} />
|
<CuesheetHeader headerGroups={headerGroups} />
|
||||||
<tbody>
|
<tbody>
|
||||||
{table.getRowModel().rows.map((row) => {
|
{rowModel.rows.map((row) => {
|
||||||
const key = row.original.id;
|
const key = row.original.id;
|
||||||
const isSelected = selectedId === key;
|
const isSelected = selectedId === key;
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@@ -117,6 +117,14 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
let rowBgColour: string | undefined;
|
let rowBgColour: string | undefined;
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700
|
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700
|
||||||
|
} else if (row.original.colour) {
|
||||||
|
try {
|
||||||
|
// the colour is user defined and might be invalid
|
||||||
|
const colour = new Color(row.original.colour).alpha(0.25);
|
||||||
|
rowBgColour = colour.hsl().string();
|
||||||
|
} catch (_error) {
|
||||||
|
/* we do not handle errors here */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,6 +19,6 @@
|
|||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
border: 1px solid $white-10;
|
border: 1px solid $white-10;
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
|
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import Empty from '../../common/components/state/Empty';
|
import Empty from '../../common/components/state/Empty';
|
||||||
@@ -6,7 +6,9 @@ import { useEventAction } from '../../common/hooks/useEventAction';
|
|||||||
import { useCuesheet } from '../../common/hooks/useSocket';
|
import { useCuesheet } from '../../common/hooks/useSocket';
|
||||||
import useRundown from '../../common/hooks-query/useRundown';
|
import useRundown from '../../common/hooks-query/useRundown';
|
||||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||||
|
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
|
||||||
|
|
||||||
|
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||||
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
|
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
|
||||||
import Cuesheet from './Cuesheet';
|
import Cuesheet from './Cuesheet';
|
||||||
import { makeCuesheetColumns } from './cuesheetCols';
|
import { makeCuesheetColumns } from './cuesheetCols';
|
||||||
@@ -20,6 +22,8 @@ export default function CuesheetWrapper() {
|
|||||||
const { updateEvent } = useEventAction();
|
const { updateEvent } = useEventAction();
|
||||||
const featureData = useCuesheet();
|
const featureData = useCuesheet();
|
||||||
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
|
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [headerData, setheaderData] = useState<ProjectData | null>(null);
|
||||||
|
|
||||||
// Set window title
|
// Set window title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,37 +73,77 @@ export default function CuesheetWrapper() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const exportHandler = useCallback(
|
const exportHandler = useCallback(
|
||||||
(headerData: ProjectData) => {
|
(headerData: ProjectData, exportType: ExportType) => {
|
||||||
if (!headerData || !rundown || !userFields) {
|
if (!headerData || !rundown || !userFields) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetData = makeTable(headerData, rundown, userFields);
|
let fileName = '';
|
||||||
const csvContent = makeCSV(sheetData);
|
let url = '';
|
||||||
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
if (exportType === 'json') {
|
||||||
const url = URL.createObjectURL(blob);
|
const jsonContent = JSON.stringify({
|
||||||
|
headerData,
|
||||||
|
rundown,
|
||||||
|
userFields,
|
||||||
|
});
|
||||||
|
|
||||||
|
fileName = 'ontime export.json';
|
||||||
|
|
||||||
|
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
} else if (exportType === 'csv') {
|
||||||
|
const sheetData = makeTable(headerData, rundown, userFields);
|
||||||
|
const csvContent = makeCSV(sheetData);
|
||||||
|
|
||||||
|
fileName = 'ontime export.csv';
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
} else {
|
||||||
|
console.error('Invalid export type: ', exportType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.setAttribute('href', url);
|
link.setAttribute('href', url);
|
||||||
link.setAttribute('download', 'ontime export.csv');
|
link.setAttribute('download', fileName);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
// Clean up the URL.createObjectURL to release resources
|
// Clean up the URL.createObjectURL to release resources
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
return;
|
||||||
},
|
},
|
||||||
[rundown, userFields],
|
[rundown, userFields],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onModalClose = (exportType?: ExportType) => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
|
||||||
|
if (!exportType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headerData) {
|
||||||
|
exportHandler(headerData, exportType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenModal = (projectData: ProjectData) => {
|
||||||
|
setheaderData(projectData);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
if (!rundown || !userFields) {
|
if (!rundown || !userFields) {
|
||||||
return <Empty text='Loading...' />;
|
return <Empty text='Loading...' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||||
<CuesheetTableHeader handleCSVExport={exportHandler} featureData={featureData} />
|
<CuesheetTableHeader handleExport={handleOpenModal} featureData={featureData} />
|
||||||
|
<CuesheetProgress />
|
||||||
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
|
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
|
||||||
|
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.progressOverride {
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||||
|
import { useTimer } from '../../../common/hooks/useSocket';
|
||||||
|
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||||
|
|
||||||
|
import styles from "./CuesheetProgress.module.scss"
|
||||||
|
|
||||||
|
export default function CuesheetProgress() {
|
||||||
|
const { data } = useViewSettings();
|
||||||
|
const timer = useTimer();
|
||||||
|
const totalTime = (timer.duration ?? 0) + (timer.addedTime ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiPartProgressBar
|
||||||
|
now={timer.current}
|
||||||
|
complete={totalTime}
|
||||||
|
normalColor={data!.normalColor}
|
||||||
|
warning={data!.warningThreshold}
|
||||||
|
warningColor={data!.warningColor}
|
||||||
|
danger={data!.dangerThreshold}
|
||||||
|
dangerColor={data!.dangerColor}
|
||||||
|
className={styles.progressOverride}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ import { SortableCell } from './SortableCell';
|
|||||||
import style from '../Cuesheet.module.scss';
|
import style from '../Cuesheet.module.scss';
|
||||||
|
|
||||||
interface CuesheetHeaderProps {
|
interface CuesheetHeaderProps {
|
||||||
headerGroups: () => HeaderGroup<OntimeRundownEntry>[];
|
headerGroups: HeaderGroup<OntimeRundownEntry>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function CuesheetHeader(props: CuesheetHeaderProps) {
|
function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||||
@@ -75,7 +75,7 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<thead className={style.tableHeader}>
|
<thead className={style.tableHeader}>
|
||||||
{headerGroups().map((headerGroup) => {
|
{headerGroups.map((headerGroup) => {
|
||||||
const key = headerGroup.id;
|
const key = headerGroup.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
interface CuesheetRowProps {
|
|
||||||
row: OntimeRundownEntry;
|
|
||||||
isSelected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CuesheetRow() {}
|
|
||||||
@@ -19,9 +19,8 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
|
|||||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
const bgFallback = 'transparent';
|
const bgColour = colour;
|
||||||
const bgColour = colour || bgFallback;
|
const textColour = getAccessibleColour(bgColour);
|
||||||
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
|
|||||||
import style from './CuesheetTableHeader.module.scss';
|
import style from './CuesheetTableHeader.module.scss';
|
||||||
|
|
||||||
interface CuesheetTableHeaderProps {
|
interface CuesheetTableHeaderProps {
|
||||||
handleCSVExport: (headerData: ProjectData) => void;
|
handleExport: (headerData: ProjectData) => void;
|
||||||
featureData: {
|
featureData: {
|
||||||
playback: Playback;
|
playback: Playback;
|
||||||
selectedEventIndex: number | null;
|
selectedEventIndex: number | null;
|
||||||
@@ -25,7 +25,7 @@ interface CuesheetTableHeaderProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetTableHeader({ handleCSVExport, featureData }: CuesheetTableHeaderProps) {
|
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
|
||||||
const followSelected = useCuesheetSettings((state) => state.followSelected);
|
const followSelected = useCuesheetSettings((state) => state.followSelected);
|
||||||
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
||||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
||||||
@@ -33,9 +33,9 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
|
|||||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||||
const { data: project } = useProjectData();
|
const { data: project } = useProjectData();
|
||||||
|
|
||||||
const exportCsv = () => {
|
const exportProject = () => {
|
||||||
if (project) {
|
if (project) {
|
||||||
handleCSVExport(project);
|
handleExport(project);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,9 +72,9 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
|
|||||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip openDelay={tooltipDelayFast} label='Export rundown to CSV'>
|
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
|
||||||
<span className={style.actionIcon} onClick={exportCsv}>
|
<span className={style.actionIcon} onClick={exportProject}>
|
||||||
CSV
|
Export
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
-8
@@ -1,4 +1,4 @@
|
|||||||
import { memo } from 'react';
|
import { memo, ReactNode } from 'react';
|
||||||
import { Button, Checkbox, Switch } from '@chakra-ui/react';
|
import { Button, Checkbox, Switch } from '@chakra-ui/react';
|
||||||
import { Column } from '@tanstack/react-table';
|
import { Column } from '@tanstack/react-table';
|
||||||
import { OntimeRundownEntry } from 'ontime-types';
|
import { OntimeRundownEntry } from 'ontime-types';
|
||||||
@@ -22,12 +22,14 @@ interface CuesheetTableSettingsProps {
|
|||||||
|
|
||||||
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||||
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
||||||
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
const {
|
||||||
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
|
showPrevious,
|
||||||
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
|
toggleDelayVisibility,
|
||||||
const toggleDelayVisibility = useCuesheetSettings((state) => state.toggleDelayVisibility);
|
showDelayBlock,
|
||||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
showDelayedTimes,
|
||||||
const toggleDelayedTimes = useCuesheetSettings((state) => state.toggleDelayedTimes);
|
toggleDelayedTimes,
|
||||||
|
togglePreviousVisibility,
|
||||||
|
} = useCuesheetSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.tableSettings}>
|
<div className={style.tableSettings}>
|
||||||
@@ -44,7 +46,7 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
|||||||
defaultChecked={visible}
|
defaultChecked={visible}
|
||||||
onChange={column.getToggleVisibilityHandler()}
|
onChange={column.getToggleVisibilityHandler()}
|
||||||
/>
|
/>
|
||||||
{columnHeader}
|
{columnHeader as ReactNode}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { millisToString } from 'ontime-utils';
|
|||||||
* @return {string}
|
* @return {string}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const parseField = (field: keyof OntimeRundown, data: unknown): string => {
|
export const parseField = <T extends OntimeEntryCommonKeys>(field: T, data: unknown): string => {
|
||||||
let val;
|
let val;
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'timeStart':
|
case 'timeStart':
|
||||||
@@ -96,6 +96,7 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userF
|
|||||||
|
|
||||||
rundown.forEach((entry) => {
|
rundown.forEach((entry) => {
|
||||||
const row: string[] = [];
|
const row: string[] = [];
|
||||||
|
// @ts-expect-error -- not sure how to type this
|
||||||
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
|
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
|
||||||
data.push(row);
|
data.push(row);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
|
|||||||
import MenuBar from '../menu/MenuBar';
|
import MenuBar from '../menu/MenuBar';
|
||||||
import AboutModal from '../modals/about-modal/AboutModal';
|
import AboutModal from '../modals/about-modal/AboutModal';
|
||||||
import QuickStart from '../modals/quick-start/QuickStart';
|
import QuickStart from '../modals/quick-start/QuickStart';
|
||||||
|
import SheetsModal from '../modals/sheets-modal/SheetsModal';
|
||||||
import UploadModal from '../modals/upload-modal/UploadModal';
|
import UploadModal from '../modals/upload-modal/UploadModal';
|
||||||
|
|
||||||
import styles from './Editor.module.scss';
|
import styles from './Editor.module.scss';
|
||||||
@@ -28,6 +29,7 @@ export default function Editor() {
|
|||||||
} = useDisclosure();
|
} = useDisclosure();
|
||||||
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
|
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
|
||||||
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
|
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
|
||||||
|
const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure();
|
||||||
|
|
||||||
// Set window title
|
// Set window title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -42,6 +44,7 @@ export default function Editor() {
|
|||||||
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
|
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
|
||||||
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
|
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
|
||||||
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
|
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
|
||||||
|
<SheetsModal onClose={onSheetsClose} isOpen={isSheetsOpen} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||||
<div id='settings' className={styles.settings}>
|
<div id='settings' className={styles.settings}>
|
||||||
@@ -58,6 +61,8 @@ export default function Editor() {
|
|||||||
onAboutOpen={onAboutModalOpen}
|
onAboutOpen={onAboutModalOpen}
|
||||||
isQuickStartOpen={isQuickStartOpen}
|
isQuickStartOpen={isQuickStartOpen}
|
||||||
onQuickStartOpen={onQuickStartOpen}
|
onQuickStartOpen={onQuickStartOpen}
|
||||||
|
isSheetsOpen={isSheetsOpen}
|
||||||
|
onSheetsOpen={onSheetsOpen}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import { useCallback } from 'react';
|
|||||||
import { Textarea } from '@chakra-ui/react';
|
import { Textarea } from '@chakra-ui/react';
|
||||||
|
|
||||||
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
|
import { EditorUpdateFields } from '../EventEditor';
|
||||||
import { TitleActions } from './EventEditorDataLeft';
|
|
||||||
|
|
||||||
import style from '../EventEditor.module.scss';
|
import style from '../EventEditor.module.scss';
|
||||||
|
|
||||||
interface CountedTextAreaProps {
|
interface CountedTextAreaProps {
|
||||||
field: TitleActions;
|
field: EditorUpdateFields;
|
||||||
label: string;
|
label: string;
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
submitHandler: (field: TitleActions, value: string) => void;
|
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CountedTextArea(props: CountedTextAreaProps) {
|
export default function CountedTextArea(props: CountedTextAreaProps) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useCallback, useEffect } from 'react';
|
import { memo, useCallback, useEffect, useState } from 'react';
|
||||||
import { VStack } from '@chakra-ui/react';
|
import { VStack } from '@chakra-ui/react';
|
||||||
|
import { IoCalendarOutline } from '@react-icons/all-files/io5/IoCalendarOutline';
|
||||||
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
|
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
|
||||||
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
|
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
|
||||||
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
|
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
|
||||||
@@ -10,11 +11,12 @@ import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline';
|
|||||||
import { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline';
|
import { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline';
|
||||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||||
|
|
||||||
import { downloadRundown } from '../../common/api/ontimeApi';
|
import { downloadCSV, downloadRundown } from '../../common/api/ontimeApi';
|
||||||
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
|
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
|
||||||
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
|
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
|
||||||
import useElectronEvent from '../../common/hooks/useElectronEvent';
|
import useElectronEvent from '../../common/hooks/useElectronEvent';
|
||||||
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
||||||
|
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
|
||||||
|
|
||||||
import style from './MenuBar.module.scss';
|
import style from './MenuBar.module.scss';
|
||||||
|
|
||||||
@@ -30,6 +32,8 @@ interface MenuBarProps {
|
|||||||
onAboutOpen: () => void;
|
onAboutOpen: () => void;
|
||||||
isQuickStartOpen: boolean;
|
isQuickStartOpen: boolean;
|
||||||
onQuickStartOpen: () => void;
|
onQuickStartOpen: () => void;
|
||||||
|
isSheetsOpen: boolean;
|
||||||
|
onSheetsOpen: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonStyle = {
|
const buttonStyle = {
|
||||||
@@ -57,6 +61,8 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
onAboutOpen,
|
onAboutOpen,
|
||||||
isQuickStartOpen,
|
isQuickStartOpen,
|
||||||
onQuickStartOpen,
|
onQuickStartOpen,
|
||||||
|
isSheetsOpen,
|
||||||
|
onSheetsOpen,
|
||||||
} = props;
|
} = props;
|
||||||
const { isElectron, sendToElectron } = useElectronEvent();
|
const { isElectron, sendToElectron } = useElectronEvent();
|
||||||
|
|
||||||
@@ -100,6 +106,22 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
};
|
};
|
||||||
}, [handleKeyPress, isElectron]);
|
}, [handleKeyPress, isElectron]);
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const onModalClose = (exportType?: ExportType) => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
|
||||||
|
if (!exportType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exportType === 'json') {
|
||||||
|
downloadRundown();
|
||||||
|
} else if (exportType === 'csv') {
|
||||||
|
downloadCSV();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack>
|
<VStack>
|
||||||
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} size='md' />
|
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} size='md' />
|
||||||
@@ -128,12 +150,14 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
{...buttonStyle}
|
{...buttonStyle}
|
||||||
icon={<IoSaveOutline />}
|
icon={<IoSaveOutline />}
|
||||||
isDisabled={appMode === AppMode.Run}
|
isDisabled={appMode === AppMode.Run}
|
||||||
clickHandler={downloadRundown}
|
clickHandler={() => setIsModalOpen(true)}
|
||||||
tooltip='Export project file'
|
tooltip='Export project file'
|
||||||
aria-label='Export project file'
|
aria-label='Export project file'
|
||||||
size='sm'
|
size='sm'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ExportModal onClose={onModalClose} isOpen={isModalOpen} />
|
||||||
|
|
||||||
<div className={style.gap} />
|
<div className={style.gap} />
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
{...buttonStyle}
|
{...buttonStyle}
|
||||||
@@ -155,6 +179,16 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={style.gap} />
|
<div className={style.gap} />
|
||||||
|
<TooltipActionBtn
|
||||||
|
{...buttonStyle}
|
||||||
|
isDisabled={appMode === AppMode.Run}
|
||||||
|
icon={<IoCalendarOutline />}
|
||||||
|
className={isSheetsOpen ? style.open : ''}
|
||||||
|
clickHandler={onSheetsOpen}
|
||||||
|
tooltip='Sheets'
|
||||||
|
aria-label='Sheets'
|
||||||
|
size='sm'
|
||||||
|
/>
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
{...buttonStyle}
|
{...buttonStyle}
|
||||||
isDisabled={appMode === AppMode.Run}
|
isDisabled={appMode === AppMode.Run}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
.title {
|
.title {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
padding-left: 8px;
|
padding-left: 0.5rem;
|
||||||
margin: 8px 0;
|
margin: 0.5rem 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +107,16 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
color: $error-red;
|
color: $error-red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
@include subsection;
|
||||||
|
color: $action-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedbackSection {
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
.buttonSection {
|
.buttonSection {
|
||||||
margin-top: $section-spacing;
|
margin-top: $section-spacing;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -117,6 +127,10 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vSpacer {
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.shiftRight {
|
.shiftRight {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
@@ -135,6 +149,12 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.twoEqualColumn {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.padBottom {
|
.padBottom {
|
||||||
padding-bottom: $element-spacing;
|
padding-bottom: $element-spacing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@use '../../../theme/v2Styles' as *;
|
||||||
|
|
||||||
|
.buttonRow {
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: $section-spacing;
|
||||||
|
display: flex;
|
||||||
|
gap: $section-spacing;
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import styles from './ExportModal.module.scss';
|
||||||
|
|
||||||
|
export type ExportType = 'csv' | 'json';
|
||||||
|
|
||||||
|
interface ExportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: (type?: ExportType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExportModal(props: ExportModalProps) {
|
||||||
|
const { isOpen, onClose } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} motionPreset='slideInBottom' size='xl' variant='ontime-small'>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader className={styles.modalHeader}>Download options</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody className={styles.buttonRow}>
|
||||||
|
<Button onClick={() => onClose('csv')} variant='ontime-subtle-on-light' width='100%'>
|
||||||
|
Rundown as CSV
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onClose('json')} variant='ontime-filled' width='100%'>
|
||||||
|
Project file
|
||||||
|
</Button>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-nocheck -- working on it
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FormControl, Input, Switch } from '@chakra-ui/react';
|
import { FormControl, Input, Switch } from '@chakra-ui/react';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import type { ProjectData } from 'ontime-types';
|
import type { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import { PROJECT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
|
import { PROJECT_DATA, RUNDOWN } from '../../../common/api/apiConstants';
|
||||||
import { postNew } from '../../../common/api/ontimeApi';
|
import { postNew } from '../../../common/api/ontimeApi';
|
||||||
import useProjectData from '../../../common/hooks-query/useProjectData';
|
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||||
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
|
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
|
||||||
@@ -52,8 +52,8 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
|
|||||||
const onSubmit = async (data: Partial<ProjectData>) => {
|
const onSubmit = async (data: Partial<ProjectData>) => {
|
||||||
try {
|
try {
|
||||||
await postNew(data);
|
await postNew(data);
|
||||||
await ontimeQueryClient.invalidateQueries(PROJECT_DATA);
|
await ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_DATA });
|
||||||
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
|
await ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import style from './SettingsModal.module.scss';
|
|||||||
const aliasesDocsUrl = 'https://ontime.gitbook.io/v2/features/url-aliases';
|
const aliasesDocsUrl = 'https://ontime.gitbook.io/v2/features/url-aliases';
|
||||||
|
|
||||||
// we wrap the array in an object to be simplify react-hook-form
|
// we wrap the array in an object to be simplify react-hook-form
|
||||||
type Aliases = { aliases: Alias[] };
|
type Aliases = {
|
||||||
|
aliases: Alias[];
|
||||||
|
};
|
||||||
|
|
||||||
export default function AliasesForm() {
|
export default function AliasesForm() {
|
||||||
const { data, status, isFetching, refetch } = useAliases();
|
const { data, status, isFetching, refetch } = useAliases();
|
||||||
@@ -46,7 +48,7 @@ export default function AliasesForm() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
reset(data);
|
reset({ aliases: data });
|
||||||
}
|
}
|
||||||
}, [data, reset]);
|
}, [data, reset]);
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ export default function AliasesForm() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const disableInputs = status === 'loading';
|
const disableInputs = status === 'pending';
|
||||||
const hasTooManyOptions = fields.length >= 20;
|
const hasTooManyOptions = fields.length >= 20;
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
@@ -93,8 +95,7 @@ export default function AliasesForm() {
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Custom aliases allow providing a short name for any ontime URL. <br />
|
Custom aliases allow providing a short name for any ontime URL. <br />
|
||||||
It serves two primary purposes: <br />
|
It serves two primary purposes: <br />
|
||||||
- Providing dynamic URLs for automation or unattended screens <br />
|
- Providing dynamic URLs for automation or unattended screens <br />- Simplifying complex URLs
|
||||||
- Simplifying complex URLs
|
|
||||||
<ModalLink href={aliasesDocsUrl}>For more information, see the docs</ModalLink>
|
<ModalLink href={aliasesDocsUrl}>For more information, see the docs</ModalLink>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,6 +112,7 @@ export default function AliasesForm() {
|
|||||||
icon={<IoRemove />}
|
icon={<IoRemove />}
|
||||||
colorScheme='red'
|
colorScheme='red'
|
||||||
isDisabled={disableInputs}
|
isDisabled={disableInputs}
|
||||||
|
data-testid={`field__delete_${index}`}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
@@ -120,6 +122,7 @@ export default function AliasesForm() {
|
|||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
placeholder='URL Alias'
|
placeholder='URL Alias'
|
||||||
isDisabled={disableInputs}
|
isDisabled={disableInputs}
|
||||||
|
data-testid={`field__alias_${index}`}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
@@ -129,6 +132,7 @@ export default function AliasesForm() {
|
|||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
placeholder='URL (portion after ontime Port)'
|
placeholder='URL (portion after ontime Port)'
|
||||||
isDisabled={disableInputs}
|
isDisabled={disableInputs}
|
||||||
|
data-testid={`field__url_${index}`}
|
||||||
/>
|
/>
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
clickHandler={(event) => handleLinks(event, alias.alias)}
|
clickHandler={(event) => handleLinks(event, alias.alias)}
|
||||||
@@ -139,8 +143,14 @@ export default function AliasesForm() {
|
|||||||
icon={<IoOpenOutline />}
|
icon={<IoOpenOutline />}
|
||||||
colorScheme='red'
|
colorScheme='red'
|
||||||
isDisabled={disableInputs}
|
isDisabled={disableInputs}
|
||||||
|
data-testid={`field__test_${index}`}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
{...register(`aliases.${index}.enabled`)}
|
||||||
|
variant='ontime-on-light'
|
||||||
|
isDisabled={disableInputs}
|
||||||
|
data-testid={`field__enable_${index}`}
|
||||||
/>
|
/>
|
||||||
<Switch {...register(`aliases.${index}.enabled`)} variant='ontime-on-light' isDisabled={disableInputs} />
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function AppSettingsModal() {
|
|||||||
reset(data);
|
reset(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const disableInputs = status === 'loading';
|
const disableInputs = status === 'pending';
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
return <ModalLoader />;
|
return <ModalLoader />;
|
||||||
@@ -87,7 +87,7 @@ export default function AppSettingsModal() {
|
|||||||
description='Protect the editor with a pin code'
|
description='Protect the editor with a pin code'
|
||||||
error={errors.editorKey?.message}
|
error={errors.editorKey?.message}
|
||||||
>
|
>
|
||||||
<ModalPinInput register={register} formName='editorKey' isDisabled={disableInputs} />
|
<ModalPinInput register={register as any} formName='editorKey' isDisabled={disableInputs} />
|
||||||
</ModalSplitInput>
|
</ModalSplitInput>
|
||||||
<ModalSplitInput
|
<ModalSplitInput
|
||||||
field='operatorKey'
|
field='operatorKey'
|
||||||
@@ -95,7 +95,7 @@ export default function AppSettingsModal() {
|
|||||||
description='Protect the cuesheet with a pin code'
|
description='Protect the cuesheet with a pin code'
|
||||||
error={errors.operatorKey?.message}
|
error={errors.operatorKey?.message}
|
||||||
>
|
>
|
||||||
<ModalPinInput register={register} formName='operatorKey' isDisabled={disableInputs} />
|
<ModalPinInput register={register as any} formName='operatorKey' isDisabled={disableInputs} />
|
||||||
</ModalSplitInput>
|
</ModalSplitInput>
|
||||||
<div style={{ height: '16px' }} />
|
<div style={{ height: '16px' }} />
|
||||||
<ModalSplitInput
|
<ModalSplitInput
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function CuesheetSettingsForm() {
|
|||||||
reset(data);
|
reset(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const disableInputs = status === 'loading';
|
const disableInputs = status === 'pending';
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
return <ModalLoader />;
|
return <ModalLoader />;
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import { UseFormRegister } from 'react-hook-form';
|
|||||||
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
|
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
|
||||||
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
|
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
|
||||||
|
|
||||||
|
interface FormInput {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ModalPinInputProps {
|
interface ModalPinInputProps {
|
||||||
register: UseFormRegister<any>;
|
register: UseFormRegister<FormInput>;
|
||||||
formName: string;
|
formName: string;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function ProjectDataForm() {
|
|||||||
reset(data);
|
reset(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const disableInputs = status === 'loading';
|
const disableInputs = status === 'pending';
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
return <ModalLoader />;
|
return <ModalLoader />;
|
||||||
|
|||||||
@@ -3,19 +3,24 @@
|
|||||||
.aliases {
|
.aliases {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 0.5rem;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 0;
|
padding: 0.5rem 0;
|
||||||
|
|
||||||
.aliasRow {
|
.aliasRow {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grow {
|
.grow {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.url {
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Input, Switch } from '@chakra-ui/react';
|
import { Alert, AlertDescription, AlertIcon, AlertTitle, Input, Switch } from '@chakra-ui/react';
|
||||||
import { ViewSettings } from 'ontime-types';
|
import { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { logAxiosError } from '../../../common/api/apiUtils';
|
import { logAxiosError } from '../../../common/api/apiUtils';
|
||||||
import { postViewSettings } from '../../../common/api/ontimeApi';
|
import { postViewSettings } from '../../../common/api/ontimeApi';
|
||||||
import { PopoverPickerRHF } from '../../../common/components/input/popover-picker/PopoverPicker';
|
import { PopoverPickerRHF } from '../../../common/components/input/popover-picker/PopoverPicker';
|
||||||
|
import useInfo from '../../../common/hooks-query/useInfo';
|
||||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||||
import { mtm } from '../../../common/utils/timeConstants';
|
import { mtm } from '../../../common/utils/timeConstants';
|
||||||
import ModalLoader from '../modal-loader/ModalLoader';
|
import ModalLoader from '../modal-loader/ModalLoader';
|
||||||
import { inputProps } from '../modalHelper';
|
import { inputProps } from '../modalHelper';
|
||||||
import ModalInput from '../ModalInput';
|
import ModalInput from '../ModalInput';
|
||||||
|
import ModalLink from '../ModalLink';
|
||||||
import ModalSplitInput from '../ModalSplitInput';
|
import ModalSplitInput from '../ModalSplitInput';
|
||||||
import OntimeModalFooter from '../OntimeModalFooter';
|
import OntimeModalFooter from '../OntimeModalFooter';
|
||||||
|
|
||||||
@@ -18,8 +20,12 @@ import InputMillisWithString from './InputMillisWithString';
|
|||||||
|
|
||||||
import style from './SettingsModal.module.scss';
|
import style from './SettingsModal.module.scss';
|
||||||
|
|
||||||
|
const cssOverrideDocsUrl = 'https://ontime.gitbook.io/v2/features/custom-styling';
|
||||||
|
|
||||||
export default function ViewSettingsForm() {
|
export default function ViewSettingsForm() {
|
||||||
const { data, status, refetch, isFetching } = useViewSettings();
|
const { data, status, refetch, isFetching } = useViewSettings();
|
||||||
|
const { data: info, isFetching: isFetchingInfo } = useInfo();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -73,15 +79,26 @@ export default function ViewSettingsForm() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const disableInputs = status === 'loading';
|
const disableInputs = status === 'pending';
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching || isFetchingInfo) {
|
||||||
return <ModalLoader />;
|
return <ModalLoader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} id='view-settings' className={style.sectionContainer}>
|
<form onSubmit={handleSubmit(onSubmit)} id='view-settings' className={style.sectionContainer}>
|
||||||
<span className={style.title}>General view settings</span>
|
<span className={style.title}>General view settings</span>
|
||||||
|
<Alert status='info' variant='ontime-on-light-info'>
|
||||||
|
<AlertIcon />
|
||||||
|
<div className={style.column}>
|
||||||
|
<AlertTitle>CSS Override</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Ontime will use the CSS file at its install location. <br />
|
||||||
|
<span className={style.url}>{info?.cssOverride}</span>
|
||||||
|
<ModalLink href={cssOverrideDocsUrl}>For more information, see the docs</ModalLink>
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
<ModalSplitInput
|
<ModalSplitInput
|
||||||
field='overrideStyles'
|
field='overrideStyles'
|
||||||
title='Override CSS Styles'
|
title='Override CSS Styles'
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { ChangeEvent, useEffect,useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { IoArrowDownCircleOutline } from '@react-icons/all-files/io5/IoArrowDownCircleOutline';
|
||||||
|
import { IoArrowUpCircleOutline } from '@react-icons/all-files/io5/IoArrowUpCircleOutline';
|
||||||
|
import { IoCheckmarkCircleOutline } from '@react-icons/all-files/io5/IoCheckmarkCircleOutline';
|
||||||
|
import { IoCloseCircleOutline } from '@react-icons/all-files/io5/IoCloseCircleOutline';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { GoogleSheetState, OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import { PROJECT_DATA, RUNDOWN, USERFIELDS } from '../../../common/api/apiConstants';
|
||||||
|
import { maybeAxiosError } from '../../../common/api/apiUtils';
|
||||||
|
import {
|
||||||
|
getSheetsAuthUrl,
|
||||||
|
getSheetSettings,
|
||||||
|
getSheetstate,
|
||||||
|
patchData,
|
||||||
|
postPreviewSheet,
|
||||||
|
postPushSheet,
|
||||||
|
postSheetSettings,
|
||||||
|
uploadSheetClientFile,
|
||||||
|
} from '../../../common/api/ontimeApi';
|
||||||
|
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
|
||||||
|
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
|
||||||
|
import PreviewExcel from '../upload-modal/preview/PreviewExcel';
|
||||||
|
|
||||||
|
interface SheetsModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SheetsModal(props: SheetsModalProps) {
|
||||||
|
const { isOpen, onClose } = props;
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
|
||||||
|
const [userFields, setUserFields] = useState<UserFields | null>(null);
|
||||||
|
const [project, setProject] = useState<ProjectData | null>(null);
|
||||||
|
|
||||||
|
const [sheetState, setSheetState] = useState<GoogleSheetState>({ auth: false, id: false, worksheet: false });
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const sheetid = useRef<HTMLInputElement>(null);
|
||||||
|
const worksheet = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setRundown(null);
|
||||||
|
setProject(null);
|
||||||
|
setUserFields(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const handleClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFile = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFile = event?.target?.files?.[0];
|
||||||
|
if (!selectedFile) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
await uploadSheetClientFile(selectedFile);
|
||||||
|
_onChange();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _onChange = async () => {
|
||||||
|
setSheetState(await getSheetstate());
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSheetSettings().then((data) => {
|
||||||
|
if (sheetid.current?.value != data.id || worksheet.current?.value != data.worksheet) {
|
||||||
|
_onChange();
|
||||||
|
}
|
||||||
|
if (sheetid.current) {
|
||||||
|
sheetid.current.value = data.id;
|
||||||
|
}
|
||||||
|
if (worksheet.current) {
|
||||||
|
worksheet.current.value = data.worksheet;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handelSave = () => {
|
||||||
|
postSheetSettings({ id: sheetid.current?.value ?? '', worksheet: worksheet.current?.value ?? '' }).then((data) => {
|
||||||
|
_onChange();
|
||||||
|
if (sheetid.current) {
|
||||||
|
sheetid.current.value = data.id;
|
||||||
|
}
|
||||||
|
if (worksheet.current) {
|
||||||
|
worksheet.current.value = data.worksheet;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthenticate = () => {
|
||||||
|
getSheetsAuthUrl().then((data) => {
|
||||||
|
if (data != 'bad') {
|
||||||
|
window.open(data, '_blank', 'noreferrer');
|
||||||
|
//TODO: can we detect when this window is closed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePullData = () => {
|
||||||
|
postPreviewSheet().then((data) => {
|
||||||
|
setProject(data.project);
|
||||||
|
setRundown(data.rundown);
|
||||||
|
setUserFields(data.userFields);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePushData = () => {
|
||||||
|
postPushSheet().then((data) => {
|
||||||
|
console.log(data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinalise = async () => {
|
||||||
|
// this step is currently only used for excel files, after preview
|
||||||
|
if (rundown && userFields && project) {
|
||||||
|
let doClose = false;
|
||||||
|
try {
|
||||||
|
await patchData({ rundown, userFields, project });
|
||||||
|
queryClient.setQueryData(RUNDOWN, rundown);
|
||||||
|
queryClient.setQueryData(USERFIELDS, userFields);
|
||||||
|
queryClient.setQueryData(PROJECT_DATA, project);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [...RUNDOWN, ...USERFIELDS, ...PROJECT_DATA],
|
||||||
|
});
|
||||||
|
doClose = true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
console.log(message);
|
||||||
|
// setErrors(`Failed applying changes ${message}`);
|
||||||
|
} finally {
|
||||||
|
if (doClose) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onClose={handleClose}
|
||||||
|
isOpen={isOpen}
|
||||||
|
closeOnOverlayClick={false}
|
||||||
|
motionPreset='slideInBottom'
|
||||||
|
size='xl'
|
||||||
|
scrollBehavior='inside'
|
||||||
|
preserveScrollBarGap
|
||||||
|
variant='ontime-upload'
|
||||||
|
>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Sheets!</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
{rundown && (
|
||||||
|
<>
|
||||||
|
<PreviewExcel
|
||||||
|
rundown={rundown ?? []}
|
||||||
|
project={project ?? projectDataPlaceholder}
|
||||||
|
userFields={userFields ?? userFieldsPlaceholder}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!rundown && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
type='file'
|
||||||
|
onChange={handleFile}
|
||||||
|
accept='.json'
|
||||||
|
data-testid='file-input'
|
||||||
|
/>
|
||||||
|
<div>Need to add some help here</div>
|
||||||
|
<div>
|
||||||
|
<Button onClick={handleClick}>Upload Client Secrect</Button>
|
||||||
|
</div>
|
||||||
|
<Button variant='ontime-filled' padding='0 2em' onClick={handleAuthenticate}>
|
||||||
|
Authenticate
|
||||||
|
</Button>
|
||||||
|
{sheetState.auth ? <div>You are authenticated</div> : <div>You are not authenticated</div>}
|
||||||
|
<div>
|
||||||
|
<label htmlFor='sheetid'>Sheet ID </label>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
ref={sheetid}
|
||||||
|
id='sheetid'
|
||||||
|
width='240px'
|
||||||
|
size='sm'
|
||||||
|
textAlign='right'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
/>
|
||||||
|
{sheetState.id ? <IoCheckmarkCircleOutline /> : <IoCloseCircleOutline />}
|
||||||
|
<br />
|
||||||
|
<label htmlFor='worksheet'>Worksheet </label>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
ref={worksheet}
|
||||||
|
id='worksheet'
|
||||||
|
width='240px'
|
||||||
|
size='sm'
|
||||||
|
textAlign='right'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
/>
|
||||||
|
{sheetState.worksheet ? <IoCheckmarkCircleOutline /> : <IoCloseCircleOutline />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
{!rundown && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant='ontime-subtle-on-light'
|
||||||
|
padding='0 2em'
|
||||||
|
onClick={handlePullData}
|
||||||
|
rightIcon={<IoArrowDownCircleOutline />}
|
||||||
|
>
|
||||||
|
Pull data
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ontime-subtle-on-light'
|
||||||
|
padding='0 2em'
|
||||||
|
onClick={handlePushData}
|
||||||
|
rightIcon={<IoArrowUpCircleOutline />}
|
||||||
|
>
|
||||||
|
Push data
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button variant='ontime-ghost-on-light'>Reset</Button>
|
||||||
|
{!rundown && (
|
||||||
|
<Button variant='ontime-filled' padding='0 2em' onClick={handelSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{rundown && (
|
||||||
|
<Button variant='ontime-filled' padding='0 2em' onClick={handleFinalise}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { ChangeEvent, useRef, useState } from 'react';
|
||||||
|
import { Input } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import UploadEntry from './upload-entry/UploadEntry';
|
||||||
|
import { useUploadModalContextStore } from './uploadModalContext';
|
||||||
|
import { validateFile } from './uploadUtils';
|
||||||
|
|
||||||
|
import style from './UploadModal.module.scss';
|
||||||
|
|
||||||
|
export default function UploadFile() {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { file, setFile, progress } = useUploadModalContextStore();
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<string>('');
|
||||||
|
|
||||||
|
const clearFile = () => {
|
||||||
|
setFile(null);
|
||||||
|
setErrors('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setErrors('');
|
||||||
|
|
||||||
|
const selectedFile = event?.target?.files?.[0];
|
||||||
|
if (!selectedFile) {
|
||||||
|
setFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateFile(selectedFile);
|
||||||
|
setFile(selectedFile);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
setErrors(error.message);
|
||||||
|
} else {
|
||||||
|
setErrors('An unexpected error occurred while validating the file.');
|
||||||
|
}
|
||||||
|
setFile(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
type='file'
|
||||||
|
onChange={handleFile}
|
||||||
|
accept='.json, .xlsx'
|
||||||
|
data-testid='file-input'
|
||||||
|
/>
|
||||||
|
{!file && (
|
||||||
|
<div className={style.uploadArea} onClick={handleClick} role='button'>
|
||||||
|
Click to select Ontime project or xlsx rundown
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(file || errors) && <UploadEntry file={file} errors={errors} progress={progress} handleClear={clearFile} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,79 +5,31 @@
|
|||||||
.uploadBody {
|
.uploadBody {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uploadArea {
|
.uploadArea {
|
||||||
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 200px;
|
max-width: 550px;
|
||||||
border: 2px dashed $gray-50;
|
|
||||||
|
min-height: 150px;
|
||||||
|
border: 2px dashed $gray-200;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
transition-property: background-color;
|
transition-property: background-color;
|
||||||
transition-duration: $transition-time-action;
|
transition-duration: $transition-time-action;
|
||||||
|
font-size: calc(1rem - 1px);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border: 2px solid $blue-500;
|
border: 2px solid $blue-500;
|
||||||
background-color: $blue-50;
|
background-color: $blue-50;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.comment {
|
&.comment {
|
||||||
color: gray;
|
color: $modal-note-color;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.uploadedItem {
|
|
||||||
background-color: $gray-50;
|
|
||||||
padding: 8px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-areas:
|
|
||||||
"icon title close"
|
|
||||||
"icon info ."
|
|
||||||
"progress progress progress";
|
|
||||||
grid-template-columns: auto 1fr auto;
|
|
||||||
column-gap: 16px;
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
align-self: center;
|
|
||||||
grid-area: icon;
|
|
||||||
font-size: 32px;
|
|
||||||
color: $gray-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileTitle {
|
|
||||||
grid-area: title;
|
|
||||||
font-size: 14px;
|
|
||||||
color: $gray-1350;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileInfo {
|
|
||||||
grid-area: info;
|
|
||||||
font-size: 12px;
|
|
||||||
color: $gray-1100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileProgress {
|
|
||||||
grid-area: progress;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancelUpload {
|
|
||||||
grid-area: close;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.error {
|
|
||||||
.icon {
|
|
||||||
color: $error-red;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.success {
|
|
||||||
.icon {
|
|
||||||
color: $green-500;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,3 +41,9 @@
|
|||||||
.pad {
|
.pad {
|
||||||
margin: 8px;
|
margin: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.twoColumn {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Input,
|
|
||||||
Modal,
|
Modal,
|
||||||
ModalBody,
|
ModalBody,
|
||||||
ModalCloseButton,
|
ModalCloseButton,
|
||||||
@@ -9,23 +8,34 @@ import {
|
|||||||
ModalFooter,
|
ModalFooter,
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
ModalOverlay,
|
ModalOverlay,
|
||||||
Progress,
|
|
||||||
Switch,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
|
||||||
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
|
|
||||||
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||||
|
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { RUNDOWN_TABLE } from '../../../common/api/apiConstants';
|
import { PROJECT_DATA, RUNDOWN, USERFIELDS } from '../../../common/api/apiConstants';
|
||||||
import { uploadData } from '../../../common/api/ontimeApi';
|
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils';
|
||||||
import { useEmitLog } from '../../../common/stores/logger';
|
import {
|
||||||
import ModalSplitInput from '../ModalSplitInput';
|
patchData,
|
||||||
|
postPreviewExcel,
|
||||||
|
ProjectFileImportOptions,
|
||||||
|
uploadProjectFile,
|
||||||
|
} from '../../../common/api/ontimeApi';
|
||||||
|
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
|
||||||
|
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
|
||||||
|
|
||||||
import { validateFile } from './utils';
|
import PreviewExcel from './preview/PreviewExcel';
|
||||||
|
import ExcelFileOptions from './upload-options/ExcelFileOptions';
|
||||||
|
import OntimeFileOptions from './upload-options/OntimeFileOptions';
|
||||||
|
import UploadStepTracker from './upload-step/UploadStep';
|
||||||
|
import UploadFile from './UploadFile';
|
||||||
|
import { useUploadModalContextStore } from './uploadModalContext';
|
||||||
|
import { getPersistedOptions, isExcelFile, isOntimeFile, persistOptions } from './uploadUtils';
|
||||||
|
|
||||||
import style from './UploadModal.module.scss';
|
import style from './UploadModal.module.scss';
|
||||||
|
|
||||||
|
export type UploadStep = 'import' | 'review';
|
||||||
|
|
||||||
interface UploadModalProps {
|
interface UploadModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -33,64 +43,152 @@ interface UploadModalProps {
|
|||||||
|
|
||||||
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { emitError } = useEmitLog();
|
|
||||||
const [errors, setErrors] = useState<string | undefined>();
|
|
||||||
const [isSubmitting, setSubmitting] = useState(false);
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const overrideOptionRef = useRef<HTMLInputElement>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
|
|
||||||
const handleFile = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
const { file, setProgress, clear } = useUploadModalContextStore();
|
||||||
const fileUploaded = event?.target?.files?.[0];
|
|
||||||
if (!fileUploaded) return;
|
|
||||||
|
|
||||||
const validate = validateFile(fileUploaded);
|
const [uploadStep, setUploadStep] = useState<UploadStep>('import');
|
||||||
setErrors(validate.errors?.[0]);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
|
||||||
|
const [userFields, setUserFields] = useState<UserFields | null>(null);
|
||||||
|
const [project, setProject] = useState<ProjectData | null>(null);
|
||||||
|
|
||||||
if (validate.isValid) {
|
const [errors, setErrors] = useState('');
|
||||||
setFile(fileUploaded);
|
|
||||||
} else {
|
const ontimeFileOptions = useRef<Partial<ProjectFileImportOptions>>({});
|
||||||
setFile(null);
|
const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
|
||||||
|
|
||||||
|
const updateOntimeFileOptions = <T extends keyof ProjectFileImportOptions>(
|
||||||
|
field: T,
|
||||||
|
value: ProjectFileImportOptions[T],
|
||||||
|
) => {
|
||||||
|
ontimeFileOptions.current = { ...ontimeFileOptions.current, [field]: value };
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExcelFileOptions = <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
|
||||||
|
if (excelFileOptions.current[field] !== value) {
|
||||||
|
excelFileOptions.current = { ...excelFileOptions.current, [field]: value };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// We want to populate the options with any previous options given by the user
|
||||||
|
useEffect(() => {
|
||||||
|
const excelOptions = getPersistedOptions('excel');
|
||||||
|
if (excelOptions) {
|
||||||
|
excelFileOptions.current = excelOptions;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
// if the modal re-opens, we want to restart all states
|
||||||
setSubmitting(true);
|
useEffect(() => {
|
||||||
|
clear();
|
||||||
|
setUploadStep('import');
|
||||||
|
setSubmitting(false);
|
||||||
|
setRundown(null);
|
||||||
|
setUserFields(null);
|
||||||
|
setProject(null);
|
||||||
|
setErrors('');
|
||||||
|
}, [clear, isOpen]);
|
||||||
|
|
||||||
|
/* uploads file to backend
|
||||||
|
* - in the case of excel, we get the preview
|
||||||
|
* - in the case of project file, this is end of line
|
||||||
|
**/
|
||||||
|
const handleUpload = async () => {
|
||||||
|
let doClose = false;
|
||||||
if (file) {
|
if (file) {
|
||||||
|
setSubmitting(true);
|
||||||
|
setErrors('');
|
||||||
try {
|
try {
|
||||||
const options = {
|
if (isOntimeFile(file)) {
|
||||||
onlyRundown: overrideOptionRef.current?.checked || false,
|
// TODO: we would also like to have preview for ontime project files
|
||||||
};
|
const options = ontimeFileOptions.current;
|
||||||
await uploadData(file, setProgress, options);
|
await handleOntimeFile(file, options);
|
||||||
|
await invalidateAllCaches();
|
||||||
|
doClose = true;
|
||||||
|
} else if (isExcelFile(file)) {
|
||||||
|
const options = excelFileOptions.current;
|
||||||
|
persistOptions({ optionType: 'excel', options });
|
||||||
|
await handleExcelFile(file, options);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitError(`Failed uploading file: ${error}`);
|
const message = maybeAxiosError(error);
|
||||||
|
setErrors(`Failed uploading file ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
await queryClient.invalidateQueries(RUNDOWN_TABLE);
|
setSubmitting(false);
|
||||||
setSuccess(true);
|
if (doClose) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSubmitting(false);
|
|
||||||
}, [emitError, file, queryClient]);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
// when we upload excel, we populate state with preview data
|
||||||
fileInputRef.current?.click();
|
async function handleExcelFile(file: File, options: ExcelImportMap) {
|
||||||
};
|
const response = await postPreviewExcel(file, setProgress, options);
|
||||||
|
if (response.status === 200) {
|
||||||
const clearFile = () => {
|
setRundown(response.data.rundown);
|
||||||
setFile(null);
|
setUserFields(response.data.userFields);
|
||||||
|
setProject(response.data.project);
|
||||||
|
// in excel imports we have an extra review step
|
||||||
|
setUploadStep('review');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// when we upload project files, no extra operations are done
|
||||||
|
async function handleOntimeFile(file: File, options: Partial<ProjectFileImportOptions>) {
|
||||||
|
await uploadProjectFile(file, setProgress, options);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// before closing the modal, we clear data from mutations
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
clearFile();
|
clear();
|
||||||
setSuccess(false);
|
setRundown([]);
|
||||||
setErrors(undefined);
|
setUserFields(userFieldsPlaceholder);
|
||||||
setProgress(0);
|
setProject(projectDataPlaceholder);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const disableSubmit = !file || isSubmitting;
|
const handleFinalise = async () => {
|
||||||
|
// this step is currently only used for excel files, after preview
|
||||||
|
if (isExcel && rundown && userFields && project) {
|
||||||
|
let doClose = false;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await patchData({ rundown, userFields, project });
|
||||||
|
queryClient.setQueryData(RUNDOWN, { rundown, revision: -1 });
|
||||||
|
queryClient.setQueryData(USERFIELDS, userFields);
|
||||||
|
queryClient.setQueryData(PROJECT_DATA, project);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [...RUNDOWN, ...USERFIELDS, ...PROJECT_DATA],
|
||||||
|
});
|
||||||
|
doClose = true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
setErrors(`Failed applying changes ${message}`);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
if (doClose) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const undoReview = () => {
|
||||||
|
setUploadStep('import');
|
||||||
|
setErrors('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isImporting = uploadStep === 'import';
|
||||||
|
const isReview = uploadStep === 'review';
|
||||||
|
const isExcel = isExcelFile(file);
|
||||||
|
const isOntime = isOntimeFile(file);
|
||||||
|
|
||||||
|
const handleGoBack = isImporting ? undefined : undoReview;
|
||||||
|
const handleSubmit = isImporting ? handleUpload : handleFinalise;
|
||||||
|
const disableSubmit = (isImporting && !file) || (isReview && rundown === null);
|
||||||
|
const disableGoBack = isImporting;
|
||||||
|
const submitText = isImporting ? 'Import' : 'Finish';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -101,66 +199,50 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
|||||||
size='xl'
|
size='xl'
|
||||||
scrollBehavior='inside'
|
scrollBehavior='inside'
|
||||||
preserveScrollBarGap
|
preserveScrollBarGap
|
||||||
variant='ontime-small'
|
variant='ontime-upload'
|
||||||
>
|
>
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>File import</ModalHeader>
|
<ModalHeader>File import</ModalHeader>
|
||||||
<ModalCloseButton />
|
<ModalCloseButton />
|
||||||
<ModalBody className={style.uploadBody}>
|
<ModalBody className={style.uploadBody}>
|
||||||
<Input
|
{isExcel && <UploadStepTracker uploadStep={uploadStep} />}
|
||||||
ref={fileInputRef}
|
{uploadStep === 'import' ? (
|
||||||
style={{ display: 'none' }}
|
<>
|
||||||
type='file'
|
<UploadFile />
|
||||||
onChange={handleFile}
|
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} updateOptions={updateOntimeFileOptions} />}
|
||||||
accept='.json, .xlsx'
|
{isExcel && <ExcelFileOptions optionsRef={excelFileOptions} updateOptions={updateExcelFileOptions} />}
|
||||||
data-testid='file-input'
|
</>
|
||||||
/>
|
) : (
|
||||||
<div className={style.uploadArea} onClick={handleClick}>
|
<PreviewExcel
|
||||||
Click to upload Ontime project file
|
rundown={rundown ?? []}
|
||||||
</div>
|
project={project ?? projectDataPlaceholder}
|
||||||
{file && (
|
userFields={userFields ?? userFieldsPlaceholder}
|
||||||
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
|
/>
|
||||||
<IoClose className={style.cancelUpload} onClick={clearFile} />
|
|
||||||
<IoDocumentTextOutline className={style.icon} />
|
|
||||||
<span className={style.fileTitle}>{file.name}</span>
|
|
||||||
<span className={style.fileInfo}>{`${(file.size / 1024).toFixed(2)}kb - ${file.type}`}</span>
|
|
||||||
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{errors && (
|
|
||||||
<div className={`${style.uploadedItem} ${style.error}`}>
|
|
||||||
<IoWarningOutline className={style.icon} />
|
|
||||||
<span className={style.fileTitle}>{errors}</span>
|
|
||||||
<span className={style.fileInfo}>Please try again</span>
|
|
||||||
<Progress className={style.fileProgress} value={progress} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={style.uploadOptions}>
|
|
||||||
<span className={style.title}>Import options</span>
|
|
||||||
<ModalSplitInput
|
|
||||||
field=''
|
|
||||||
title='Only import rundown'
|
|
||||||
description='All other options, including application settings will be discarded'
|
|
||||||
>
|
|
||||||
<Switch variant='ontime-on-light' ref={overrideOptionRef} />
|
|
||||||
</ModalSplitInput>
|
|
||||||
</div>
|
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter className={`${style.buttonSection} ${style.pad}`}>
|
<ModalFooter>
|
||||||
<Button onClick={handleClose} isDisabled={isSubmitting} variant='ontime-ghost-on-light' size='sm'>
|
<div className={style.feedbackSection}>{errors && <div className={style.error}>{errors}</div>}</div>
|
||||||
Cancel
|
<div className={`${style.buttonSection} ${style.pad}`}>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
onClick={handleGoBack}
|
||||||
onClick={handleSubmit}
|
isDisabled={disableGoBack || submitting}
|
||||||
isLoading={isSubmitting}
|
variant='ontime-ghost-on-light'
|
||||||
isDisabled={disableSubmit}
|
size='sm'
|
||||||
variant='ontime-filled'
|
>
|
||||||
padding='0 2em'
|
Go Back
|
||||||
size='sm'
|
</Button>
|
||||||
>
|
<Button
|
||||||
Import
|
onClick={handleSubmit}
|
||||||
</Button>
|
isLoading={submitting}
|
||||||
|
isDisabled={disableSubmit}
|
||||||
|
variant='ontime-filled'
|
||||||
|
padding='0 2em'
|
||||||
|
size='sm'
|
||||||
|
>
|
||||||
|
{submitText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@use "../../../../theme/_ontimeColours" as *;
|
||||||
|
|
||||||
|
@mixin pad-item {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewTable {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: repeat(6, auto);
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
font-weight: 200;
|
||||||
|
@include pad-item;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
@include pad-item;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import PreviewProjectData from './PreviewProjectData';
|
||||||
|
import PreviewRundown from './PreviewRundown';
|
||||||
|
|
||||||
|
import style from '../../Modal.module.scss';
|
||||||
|
|
||||||
|
interface PreviewExcelProps {
|
||||||
|
rundown: OntimeRundown;
|
||||||
|
project: ProjectData;
|
||||||
|
userFields: UserFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PreviewExcel(props: PreviewExcelProps) {
|
||||||
|
const { rundown, project, userFields } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${style.column}`}>
|
||||||
|
<div className={style.title}>Review Project Data</div>
|
||||||
|
<PreviewProjectData project={project} />
|
||||||
|
<div className={style.vSpacer} />
|
||||||
|
<div className={style.title}>Review Rundown</div>
|
||||||
|
<PreviewRundown rundown={rundown} userFields={userFields} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
|
import style from './PreviewColumn.module.scss';
|
||||||
|
|
||||||
|
interface PreviewProjectDataProps {
|
||||||
|
project: ProjectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PreviewProjectData({ project }: PreviewProjectDataProps) {
|
||||||
|
return (
|
||||||
|
<div className={style.previewTable}>
|
||||||
|
<span className={style.field}>Title</span>
|
||||||
|
<span className={style.value}>{project.title}</span>
|
||||||
|
<span className={style.field}>Description</span>
|
||||||
|
<span className={style.value}>{project.description}</span>
|
||||||
|
<span className={style.field}>Public URL</span>
|
||||||
|
<span className={style.value}>{project.publicUrl}</span>
|
||||||
|
<span className={style.field}>Public info</span>
|
||||||
|
<span className={style.value}>{project.publicInfo}</span>
|
||||||
|
<span className={style.field}>Backstage URL</span>
|
||||||
|
<span className={style.value}>{project.backstageUrl}</span>
|
||||||
|
<span className={style.field}>Backstage info</span>
|
||||||
|
<span className={style.value}>{project.backstageInfo}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { isOntimeEvent, OntimeRundown, UserFields } from 'ontime-types';
|
||||||
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
|
|
||||||
|
import Tag from './Tag';
|
||||||
|
|
||||||
|
import style from './PreviewTable.module.scss';
|
||||||
|
|
||||||
|
interface PreviewRundownProps {
|
||||||
|
rundown: OntimeRundown;
|
||||||
|
userFields: UserFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanToText(value?: boolean) {
|
||||||
|
return value ? 'Yes' : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PreviewRundown({ rundown, userFields }: PreviewRundownProps) {
|
||||||
|
return (
|
||||||
|
<div className={style.container}>
|
||||||
|
<table className={style.rundownPreview}>
|
||||||
|
<thead className={style.header}>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Cue</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Subtitle</th>
|
||||||
|
<th>Presenter</th>
|
||||||
|
<th>Time Start</th>
|
||||||
|
<th>Time End</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Is Public</th>
|
||||||
|
<th>Skip</th>
|
||||||
|
<th>Colour</th>
|
||||||
|
<th>Timer Type</th>
|
||||||
|
<th>End Action</th>
|
||||||
|
<th>
|
||||||
|
user0 <Tag>{userFields.user0}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user1 <Tag>{userFields.user1}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user2 <Tag>{userFields.user2}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user3 <Tag>{userFields.user3}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user4 <Tag>{userFields.user4}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user5 <Tag>{userFields.user5}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user6 <Tag>{userFields.user6}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user7 <Tag>{userFields.user7}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user8 <Tag>{userFields.user8}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user9 <Tag>{userFields.user9}</Tag>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className={style.body}>
|
||||||
|
{rundown.map((event, index) => {
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const key = event.id;
|
||||||
|
const colour = event.colour ? getAccessibleColour(event.colour) : {};
|
||||||
|
const isPublic = booleanToText(event.isPublic);
|
||||||
|
const skip = booleanToText(event.skip);
|
||||||
|
return (
|
||||||
|
<Fragment key={key}>
|
||||||
|
<tr>
|
||||||
|
<td className={style.center}>
|
||||||
|
<Tag>{index + 1}</Tag>
|
||||||
|
</td>
|
||||||
|
<td className={style.center}>
|
||||||
|
<Tag>Event</Tag>
|
||||||
|
</td>
|
||||||
|
<td className={style.nowrap}>{event.cue}</td>
|
||||||
|
<td>{event.title}</td>
|
||||||
|
<td>{event.subtitle}</td>
|
||||||
|
<td>{event.presenter}</td>
|
||||||
|
<td>{millisToString(event.timeStart)}</td>
|
||||||
|
<td>{millisToString(event.timeEnd)}</td>
|
||||||
|
<td>{millisToString(event.duration)}</td>
|
||||||
|
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
|
||||||
|
<td>{skip && <Tag>{skip}</Tag>}</td>
|
||||||
|
<td style={{ ...colour }}>{event.colour}</td>
|
||||||
|
<td className={style.center}>
|
||||||
|
<Tag>{event.timerType}</Tag>
|
||||||
|
</td>
|
||||||
|
<td className={style.center}>
|
||||||
|
<Tag>{event.endAction}</Tag>
|
||||||
|
</td>
|
||||||
|
<td>{event.user0}</td>
|
||||||
|
<td>{event.user1}</td>
|
||||||
|
<td>{event.user2}</td>
|
||||||
|
<td>{event.user3}</td>
|
||||||
|
<td>{event.user4}</td>
|
||||||
|
<td>{event.user5}</td>
|
||||||
|
<td>{event.user6}</td>
|
||||||
|
<td>{event.user7}</td>
|
||||||
|
<td>{event.user8}</td>
|
||||||
|
<td>{event.user9}</td>
|
||||||
|
</tr>
|
||||||
|
{event.note && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={99} className={style.secondaryRow}>
|
||||||
|
Note: {event.note}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
@use "../../../../theme/_ontimeColours" as *;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: max(300px, 30vh);
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rundownPreview {
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
overflow-x: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.body {
|
||||||
|
th {
|
||||||
|
font-weight: 400;
|
||||||
|
height: unset;
|
||||||
|
line-height: calc(1rem - 2px);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 0 2px $gray-50;
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: 200;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
word-wrap: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryRow {
|
||||||
|
padding: 0.25em 0.25em;
|
||||||
|
background-color: $gray-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table tr td:first-child,
|
||||||
|
table tr th:first-child {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 1px 0 $gray-50;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@use "../../../../theme/_ontimeColours" as *;
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
background-color: $gray-100;
|
||||||
|
color: $ui-black;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import style from './Tag.module.scss';
|
||||||
|
|
||||||
|
export default function Tag({ children }: { children: ReactNode }) {
|
||||||
|
return <span className={style.tag}>{children}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
@use '../../../../theme/ontimeColours' as *;
|
||||||
|
@use '../../../../theme/v2Styles' as *;
|
||||||
|
|
||||||
|
.uploadedItem {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 550px;
|
||||||
|
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
"icon title close"
|
||||||
|
"icon info ."
|
||||||
|
"progress progress progress";
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
column-gap: 1rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
align-self: center;
|
||||||
|
grid-area: icon;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileTitle {
|
||||||
|
grid-area: title;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
color: $ui-black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileInfo {
|
||||||
|
grid-area: info;
|
||||||
|
font-size: calc(1rem - 4px);
|
||||||
|
color: $gray-1100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileProgress {
|
||||||
|
grid-area: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelUpload {
|
||||||
|
grid-area: close;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
.icon {
|
||||||
|
color: $error-red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Progress } from '@chakra-ui/react';
|
||||||
|
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||||
|
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
|
||||||
|
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
|
||||||
|
|
||||||
|
import { isExcelFile, isOntimeFile } from '../uploadUtils';
|
||||||
|
|
||||||
|
import style from './UploadEntry.module.scss';
|
||||||
|
|
||||||
|
interface UploadEntryProps {
|
||||||
|
file: File | null;
|
||||||
|
errors?: string;
|
||||||
|
progress: number;
|
||||||
|
handleClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadEntry(props: UploadEntryProps) {
|
||||||
|
const { file, errors, progress, handleClear } = props;
|
||||||
|
|
||||||
|
if (errors) {
|
||||||
|
return (
|
||||||
|
<div className={`${style.uploadedItem} ${style.error}`}>
|
||||||
|
<IoClose className={style.cancelUpload} onClick={handleClear} />
|
||||||
|
<IoWarningOutline className={style.icon} />
|
||||||
|
<span className={style.fileTitle}>{errors}</span>
|
||||||
|
<span className={style.fileInfo}>Please try again</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const fileSize = `${(file.size / 1024).toFixed(2)}kb`;
|
||||||
|
let fileType = '';
|
||||||
|
if (isOntimeFile(file)) {
|
||||||
|
fileType = 'Ontime Project File';
|
||||||
|
} else if (isExcelFile(file)) {
|
||||||
|
fileType = 'Excel Rundown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.uploadedItem}>
|
||||||
|
<IoClose className={style.cancelUpload} onClick={handleClear} />
|
||||||
|
<IoDocumentTextOutline className={style.icon} />
|
||||||
|
<span className={style.fileTitle}>{file.name}</span>
|
||||||
|
<span className={style.fileInfo}>{`${fileSize} - ${fileType}`}</span>
|
||||||
|
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user