Compare commits

...

87 Commits

Author SHA1 Message Date
arc-alex b37494109a ifx: use template string not concatination 2023-11-27 23:27:10 +01:00
arc-alex aaa1fb9368 fix: saveClientSecrets type object 2023-11-27 23:24:36 +01:00
arc-alex cb198bc3fe comments 2023-11-26 17:29:12 +01:00
arc-alex 6880f459b6 check keys of client secrets 2023-11-26 17:11:55 +01:00
arc-alex 7b34c8f3b1 push project data 2023-11-26 16:48:34 +01:00
arc-alex c714727801 useEffect 2023-11-26 14:26:05 +01:00
arc-alex 8fc70fa162 cellRequenstFromEvent change 2023-11-26 14:23:12 +01:00
arc-alex 54c4a7f68c add test 2023-11-26 14:16:50 +01:00
arc-alex 9820c71a2d move util functions to separat file for testing 2023-11-25 20:03:30 +01:00
arc-alex 2ff7d0f546 pass lint 2023-11-25 19:56:19 +01:00
arc-alex f29ed1b05a types for getA1Notation function 2023-11-25 19:54:33 +01:00
arc-alex b1de7ffe5b Merge branch 'master' into google-sheets-lite 2023-11-25 19:39:53 +01:00
arc-alex 46d17e0570 extend authServer timeout if opened again 2023-11-24 21:17:30 +01:00
arc-alex 38e3ef7586 dont save login token Between Sessions 2023-11-24 20:07:50 +01:00
Carlos Valente 804e4ad33e chore: disable save params feature (#615) 2023-11-24 18:29:37 +01:00
arc-alex e8bcbb4291 clean up sheet flow 2023-11-24 17:47:21 +01:00
arc-alex 111df94d6d parseGoogleSheet from db 2023-11-24 16:49:37 +01:00
Carlos Valente 5be60ff6c3 feat: show progress in op and cuesheet (#605) 2023-11-24 12:04:38 +01:00
Carlos Valente bf8ebe942e feat: allow editing field from operator (#609)
* feat: allow editing field from operator

Co-authored-by: arc-alex <ac@omnivox.dk>

* style: functional and presentation tweaks
---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-11-24 12:04:22 +01:00
arc-alex d32d8a547d lint 2023-11-23 18:38:01 +01:00
arc-alex 5bc2c2f241 remove unused 2023-11-23 17:55:22 +01:00
arc-alex 28533464d4 add db test for google sheet 2023-11-23 17:49:08 +01:00
arc-alex 4f1fa2053f sheet settings with feedback 2023-11-23 17:17:13 +01:00
arc-alex b15b4b48a7 add googleSheet to db model 2023-11-23 14:53:22 +01:00
Carlos Valente bdd216d881 feat: customise timer elements (#607)
* feat: customise timer elements
2023-11-22 21:38:35 +01:00
Alex Christoffer Rasmussen 797a0edf57 External field (#580)
* feat: add external endpoint

* add external to messagecontrol

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2023-11-22 08:06:48 +01:00
asharonbaltazar c4886a617c remove storedViewParams from the dep array (#606) 2023-11-20 18:41:41 -05:00
Carlos Valente a79ae7e0c4 fix: cuesheet header (#600)
* `useLocalStorage` hook uses `useSyncExternalStore`
---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
2023-11-20 21:11:52 +01:00
Carlos Valente afa62a6e38 test: alias feature (#602) 2023-11-20 11:45:17 +01:00
Carlos Valente 1b55dbc170 style: clock period (#601)
* style: tweaks to studio override

* style: improvements to 12 hour format
2023-11-20 09:53:35 +01:00
arc-alex ef2fa99673 finde correct range 2023-11-19 20:15:07 +01:00
arc-alex 85ee9de528 lint 2023-11-19 19:39:13 +01:00
arc-alex e52a75db7d test sheet exist and get worksheet id 2023-11-19 19:24:56 +01:00
arc-alex 07ebb5a321 fix merge 2023-11-19 19:24:31 +01:00
arc-alex 071cae4cc6 Merge remote-tracking branch 'org/master' into google-sheets-lite 2023-11-19 18:28:57 +01:00
arc-alex 543e365f82 push to sheet 2023-11-19 17:04:14 +01:00
arc-alex a7c9ce876c get cell location when from parseExcel 2023-11-19 17:03:50 +01:00
arc-alex ae4e2ceafe push function 2023-11-18 21:41:40 +01:00
arc-alex b77db4cbf6 rename to pull 2023-11-18 21:17:44 +01:00
arc-alex fe67e65a95 return auth url on first clik 2023-11-18 21:15:39 +01:00
arc-alex 785695c01c clear rundownState on modal close 2023-11-18 21:01:32 +01:00
arc-alex 65efebe180 Preview Sheet 2023-11-18 20:58:57 +01:00
arc-alex 52a9c85dbc remove duplicate routs 2023-11-18 17:57:13 +01:00
arc-alex 6b2b6dc6dd auth flow 2023-11-18 17:53:08 +01:00
arc-alex 31b46bbb34 handle client secret upload 2023-11-18 17:24:29 +01:00
arc-alex 38cf3a7b63 ensure sheets directory 2023-11-18 17:23:19 +01:00
Carlos Valente 2316bbebac chore: type improvements (#598)
* chore: type improvements

* ci: typechecking in pipeline
2023-11-18 08:35:54 +01:00
Carlos Valente 884ab0b67b refactor: rundown (#597)
* refactor: add revision number to rundown

* chore: migrate query
2023-11-17 16:18:02 +01:00
arc-alex 81cf30daa0 use only sheets portion of google api 2023-11-17 16:15:28 +01:00
Carlos Valente e9683c6cab wip: placeholder sheets modal 2023-11-17 15:21:19 +01:00
Carlos Valente 58239af8bb fix: distinguish user initiated scroll (#596) 2023-11-17 13:01:28 +01:00
Alex Christoffer Rasmussen c59e070076 Local build (#595)
* add build:electron script

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-11-17 13:01:08 +01:00
Carlos Valente fad8d2a933 hotfix ci (#594)
* ci: upgrade pnpm

* version bump
2023-11-16 11:26:01 +01:00
Carlos Valente 54def8b820 feat: small improvements to instance info (#591)
* refactor: add typings to backend info

* refactor: write current version to project file

* feat: show location of css override
2023-11-15 20:20:05 +01:00
arc-alex 370777326d add jsdoc comments 2023-11-15 10:26:04 +01:00
arc-alex 309f622077 update lock file 2023-11-15 10:09:50 +01:00
arc-alex 285b05bd65 comment 2023-11-15 09:06:03 +01:00
arc-alex 05207189cf add google sheet functions 2023-11-15 09:06:03 +01:00
arc-alex d70bb2174a install google apis 2023-11-15 09:06:03 +01:00
Carlos Valente 536e447eaa style: export modal (#592)
* style: tweaks to export modal
2023-11-14 22:31:14 +01:00
Carlos Valente 1ca3b9c134 refactor: better errors (#590)
* refactor: improve error messages from server
2023-11-14 21:54:56 +01:00
Carlos Valente a41fe8806b configure lint staged (#588)
* chore: upgrade monorepo deps

* chore: configure lint-staged

* refactor: lenient use of any
2023-11-13 22:04:55 +01:00
Alex Christoffer Rasmussen 140daef7e7 add eventsPerPage to schedule (#583)
* add eventsPerPage to schedule

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-11-13 22:03:17 +01:00
Carlos Valente cbed3e4fe4 ci: remove stale bot (#587) 2023-11-12 21:41:55 +01:00
Carlos Valente 9da7613450 ci: close issues without user activity (#586)
* ci: close issues without user activity
2023-11-11 14:58:46 +01:00
Carlos Valente a7980ef2ab fix: github workflow (#585)
* fix: github workflow

* chore: update lockfile
2023-11-11 14:42:06 +01:00
Carlos Valente 45fe669f0a feat: excel import (#527)
* refactor: simplify excel import

* chore: add external deepmerge utility

* chore: create import map utilities

* chore: increase size limits on uploads

* style: small presentation tweaks

* feat: resolve times on excel import, refs #508
2023-11-11 14:28:43 +01:00
asharonbaltazar 0ce3449083 View Params Persist when Navigating Back (#581)
* add url search params to local storage

* add clearing local storage

* use `useLocation` instead of `location.pathname`
2023-11-10 08:49:25 -05:00
Carlos Valente de715b3d3e chore: upgrade pnpm (#582)
* chore: upgrade pnpm
2023-11-09 21:34:17 +01:00
Carlos Valente 6012cb7bd1 chore(lint): lenient eol (#578)
* chore(lint): lenient eol
2023-11-07 22:39:47 +01:00
Carlos Valente 2c47d90f34 chore: configure project wide linter (#577)
* chore(lint): configure project-wide linter

* chore(lint): improve linting in files

* chore(lint): hide warnings in CI
2023-11-07 22:30:39 +01:00
Carlos Valente 943dd19c09 style: wording and UI tweaks (#564)
* style: wording and UI tweaks

* style: prevent note overflow

* style: small wording changes

* soft blink (#569)

* chore: version bump


---------

Co-authored-by: Alex Christoffer Rasmussen <alex.christoffer@gmail.com>
2023-11-07 21:27:33 +01:00
Alex Christoffer Rasmussen 138be478ca Addtime in sec (#576)
* addtime endpoint

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-11-07 21:24:44 +01:00
Carlos Valente 98bd320cbd style: coloured row background (#571)
* style: coloured row background
2023-11-05 09:47:55 +01:00
Alex Christoffer Rasmussen f57faf1837 remove unnecessary import 2023-11-04 08:46:35 +01:00
arc-alex 44f025b359 update node-osc to v9 2023-11-04 08:46:35 +01:00
Carlos Valente 0555d0f400 feat: expand user options for views with schedule (#565)
* feat: expand user options for views with schedule

* fix: selected public id on load

* fix: load events on roll

* style: remove empty container
2023-11-03 13:47:38 +01:00
Carlos Valente 1e90ce4c1d style: sticky index column (#566)
* style: sticky index column
2023-11-02 20:59:30 +01:00
Carlos Valente 2f09d27813 fix: nullcheck for nextPublicEventId in findRoll (#567)
Co-authored-by: Alex Christoffer Rasmussen <alex.christoffer@gmail.com>
2023-11-02 10:31:05 +01:00
Alex Christoffer Rasmussen a01046b479 Feat/change event api (#553)
* feat: change attribute for given event 

---------

Co-authored-by: Ary <arylmoraesn@gmail.com>
Co-authored-by: arc-alex <ac@omnivox.dk>
2023-10-31 19:24:22 +01:00
Enubia 78d5d442cf feat: added export options (#529)
* feat: added export options

* chore: eslint console proposal

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-10-30 11:41:15 +01:00
Brian Teeman 7c234466f7 typos (#562)
quick fix for some typos/spelling

Small way of saying thanks for the software
2023-10-30 11:36:54 +01:00
Carlos Valente 29e5c96d44 fix: prevent error when port is unchanged (#551)
* fix: prevent error when port is unchanged
2023-10-28 19:01:58 +02:00
Kye Lewis 36b1effaaf Clarify build before E2E steps (#556)
Co-authored-by: Kye <kye@klewis.me>
2023-10-26 14:57:11 +02:00
Alex Christoffer Rasmussen a84c0654e2 Feat resumability (#547)
* add resumability functionality

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2023-10-24 08:33:17 +02:00
Carlos Valente b34fe71995 chore: add line end to prettier (#548) 2023-10-14 19:12:14 +02:00
Carlos Valente 2f74293505 chore: update docs (#537)
- Avoid duplicate instructions on docker and refer to documentation
- Add link to macOS docs on running an unsigned app
2023-10-08 11:47:45 +02:00
232 changed files with 9168 additions and 3805 deletions
+1
View File
@@ -0,0 +1 @@
"ONTIME_VERSION.js"
+38 -2
View File
@@ -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"
}
]
} }
} }
+6 -6
View File
@@ -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:
+6 -6
View File
@@ -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
+28 -4
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
BIN
View File
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
build
coverage
dist
node_modules
playwright-report
**/*.toml
**/*.yml
**/*.json
+1
View File
@@ -1,4 +1,5 @@
{ {
"endOfLine": "lf",
"trailingComma": "all", "trailingComma": "all",
"tabWidth": 2, "tabWidth": 2,
"semi": true, "semi": true,
+3 -1
View File
@@ -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`
+36 -44
View File
@@ -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.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png) ![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png)
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,5 @@
{ {
"endOfLine": "lf",
"trailingComma": "all", "trailingComma": "all",
"tabWidth": 2, "tabWidth": 2,
"semi": true, "semi": true,
+15 -14
View File
@@ -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",
+1 -2
View File
@@ -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'];
+33 -5
View File
@@ -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'] });
}
+11 -1
View File
@@ -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;
+139 -32
View File
@@ -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);
} }
+72 -49
View File
@@ -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',
}); });
+11 -29
View File
@@ -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 -1
View File
@@ -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
+34 -41
View File
@@ -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),
+22 -16
View File
@@ -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,
+1 -1
View File
@@ -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
}, },
}, },
}); });
+5 -1
View File
@@ -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));
@@ -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');
});
}); });
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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>;
} }
} }
+1 -1
View File
@@ -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;
} }
} }
+15 -7
View File
@@ -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>
@@ -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) {
+37 -3
View File
@@ -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