Compare commits

...

122 Commits

Author SHA1 Message Date
Alex Christoffer Rasmussen 661337fef9 fix: change all ExcelImportMaps to lowercase (#716)
* fix: change all ExcelImportMaps to lowercase

* chore: bump patch version
2024-01-15 21:37:04 +01:00
Carlos Valente 71092afc02 ontime release (#694)
* chore: version bump

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2024-01-10 20:50:19 +01:00
Alex Christoffer Rasmussen b1ba47a80f Fix/external path (#702)
* leave styles as is but move other external to `external` folder
2024-01-09 22:57:06 +01:00
Alex Christoffer Rasmussen 9611722a6d fix: use background-color: transparent; for overlap indicators (#699) 2024-01-09 16:42:50 +01:00
Alex Christoffer Rasmussen 92cefa1331 refactor: errors from integrationcontroller (#700)
* refactor: these functions don't throw, test the return success and then throw

* refactor: no need to catch and re-throw
2024-01-09 16:01:17 +01:00
Alex Christoffer Rasmussen c0fb865959 feat: google sheets(#579)
---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-01-08 15:51:58 +01:00
Oliver Herrmann 068ec800f4 add localdocker build commands (#638) 2024-01-05 21:04:52 +01:00
Alex Christoffer Rasmussen 1a8b35a5ea feat: show overlap (#633)
* feat: show spacing or overlap
2024-01-05 21:04:16 +01:00
Fabian Posenau 40bede200c nightly builds hopefully (#681)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-30 10:04:42 +01:00
Fabian Posenau f3e2944c55 fix docker compose ontime version (#680)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-29 22:25:41 +01:00
Fabian Posenau 09f2874784 Fix Visual bug on rundown (#678)
* fix: bug with draw order on past rundown items

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-29 20:18:05 +01:00
Carlos Valente 8afffb9870 style: fix missing overrides (#666)
* style: fix missing overrides

* style: fix recover idle colour
2023-12-22 21:05:23 +01:00
Carlos Valente 33575c551a refactor: improve typing on transform (#626)
* refactor: improve typing on transform

* chore: use placeholder for data errors
2023-12-19 10:03:25 +01:00
Carlos Valente 3173fb57fc chore: add italian to languages (#656) 2023-12-18 15:07:48 +01:00
Carlos Valente 7d33019f53 refactor: prevent accidental edit in operator view (#659)
* refactor: prevent accidental edit in operator view
2023-12-18 15:07:30 +01:00
Matteo Gheza 6526b32d4c Add italian client translation (#655)
* Add Italian client translation
2023-12-17 20:59:05 +01:00
Carlos Valente d6aef5ea31 fix: correct link for CSS overrides (#650) 2023-12-14 20:14:09 +01:00
Alex Christoffer Rasmussen 8756b396cc feat: minimal demo page (#644)
* minimal

* eslint-browser

* dynamic port, reconnect socket, msTo string without Date

* 404 on external not found
2023-12-14 13:27:18 +01:00
Carlos Valente 5a2f711eab refactor: cleanup sentry errors (#653) 2023-12-13 22:12:54 +01:00
Carlos Valente a56e2b1c1c fix: link for mac download (#648) 2023-12-13 19:22:17 +01:00
Carlos Valente da829dc09f Release preparations (#642)
* chore: version bump
2023-12-11 22:06:19 +01:00
Carlos Valente c4d65dbe5f fix: display correct end time in backstage (#635) 2023-12-10 19:12:29 +01:00
Alex Christoffer Rasmussen 2586b0b10c feat: http integration (#575)
* addtime endpoint

* create universal Subscription

* add http integration

* catch error from HTTP integration emit

* unify osc and http validateSubscriptionEntry

* add necessary endpoint for http subscription

* make subscription part of modal generic

* add http subscription to integration modal

* remove log

* Revert "addtime endpoint"

This reverts commit 4c039220dc.

* reuse agent and test url compatibility

* simplify retun path

* add todo in UI

* test for http protocol

* import not needed yet

* lint

* fix merge

* lint

* fix httpPlaceholder

* wip: prepare endpoints

* wip: temporary fix to get form to work

* disable HTTP integration if enabledOut==false

* register/unregister http

* refactor: subscription types and form register

* refactor: validation

* cleanup

* cleanup

* try GOT

* allow https

* split url and searchParams allow for post option

* add options to post

* add retry count

* fix test

* Revert "fix test"

This reverts commit 927e88370f.

* Revert "add options to post"

This reverts commit 0523a68ef4.

* Revert "split url and searchParams allow for post option"

This reverts commit 54ab8d4ffe.

* missing retryCount in httpPlaceholder

* remove global this

* remove retry count

* remove https

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2023-12-10 16:56:42 +01:00
Alex Christoffer Rasmussen abe27d54a2 refactor: use add time in client (#636) 2023-12-08 21:46:29 +01:00
Alex Christoffer Rasmussen 1343818917 Improve change api (#628)
* fix: jsdoc comments

* more human casting of string values to boolean

* add coerceColour to get named colors to work

* cue can't pass isKeyOfType event check

* dont mutate value

* alloow cue in eventDef

* lint

* css named or hex formatting

* "sideEffects": false

* test colour hex regex

* roll eventDef back to master

* parse property

* don't export cssColour names

* remove import

* only allow string types to colour

* handle transparent colour

* mix alpha with bg colour

* only allow updating events

* descriptive names

* add test for getAccessibleColour

* readability

* move isColourHex to regex-utils

* simplify and add test
2023-12-08 12:55:04 +01:00
Carlos Valente a93cd3e9b1 style: prevent menu mirroring (#632) 2023-12-07 17:57:04 +01:00
Alex Christoffer Rasmussen 813eb9ad86 Merge pull request #604 from cpvalente/roll-back
add skippedOutOfEvent to TimerService
2023-12-04 12:53:43 +01:00
Carlos Valente 34e9b1ef07 refactor: clarify responsabilities 2023-11-30 21:35:09 +01:00
arc-alex a67b89190d guard and test midnight 2023-11-28 16:04:12 +01:00
arc-alex ecd151a0a8 use expectedFinish 2023-11-28 15:27:19 +01:00
asharonbaltazar 4cbc09864c Params Input Placeholders (#627)
* add `placeholder?: string;` to `StringField` & `NumberField`

* add `placeholder` to inputs

* move values from `defaultValue` to `placeholder`
2023-11-27 18:25:46 -05:00
arc-alex 20d5d8b129 move skip logic to timerUtils 2023-11-27 17:42:42 +01:00
Alex Christoffer Rasmussen 11db39ee73 Merge pull request #623 from cpvalente/osc-enabledOut
Osc disable/enable output
2023-11-27 16:56:54 +01:00
asharonbaltazar edb79d5819 Default Values in ParamEditor (#610)
* add `defaultvalue` to `Field` types

* add `defaultValue` to constants; turn into functions for time values

* update constant imports/calls in view components

* use `defaultValue`s in `ParamInput`

* change `Clear` to `Reset`to better reflect actions

* make `defaultValue` optional rather than `undefined`

* update forgotten constants

* add `defaultValue` to boolean input

* change `101010` to `000000`

* add `prefix` property to types

* implement `prefix` for `ParamInput`s

* change `paramField` to `prop`

* add `onEditDrawerClose` to `resetParams`

* `ViewParamsEditor` omits default values

* undo close on reset

* move `useSettings` into `ViewWrapper`

* change `settings` to include `undefined`
2023-11-26 15:57:18 -05:00
arc-alex 715fa66444 disable/enable from ui without restart 2023-11-26 20:41:33 +01:00
arc-alex 27f6d10677 actually disable/enable osc output 2023-11-26 20:41:01 +01:00
Carlos Valente f2504072ce feat: add change endpoint to ws (#618)
* feat: add change endpoint to ws
2023-11-26 19:02:16 +01:00
Carlos Valente 9e285ee8f3 chore: version bump (#616) 2023-11-25 22:31:22 +01:00
Carlos Valente 1ccf2108eb style: tweaks on info data (#622) 2023-11-25 22:31:09 +01:00
Carlos Valente db56ac8049 style: separe arguments in copy fields (#621) 2023-11-25 22:28:54 +01:00
Carlos Valente 0660f014c8 fix: prevent autocomplete (#620) 2023-11-25 22:04:47 +01:00
Carlos Valente 41c26778cb fix: prevent shortcut collision with menu (#619) 2023-11-25 21:55:27 +01:00
Carlos Valente 804e4ad33e chore: disable save params feature (#615) 2023-11-24 18:29:37 +01:00
arc-alex b11041938d self documenting 2023-11-24 14:05:12 +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
Carlos Valente bdd216d881 feat: customise timer elements (#607)
* feat: customise timer elements
2023-11-22 21:38:35 +01:00
arc-alex e6b4f537af rollSkipLimit 2023-11-22 19:05:08 +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
arc-alex 8cabb347e9 remove test log 2023-11-20 18:24:11 +01:00
arc-alex 22d89c6fbc add _startAt to all tests 2023-11-20 17:51:50 +01:00
arc-alex facce4f096 trigger doRoll if clock is less than startAt 2023-11-20 17:44:39 +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
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
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
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
Carlos Valente e2aabe3646 chore: version bump (#528) 2023-09-17 15:04:55 +02:00
Carlos Valente 7545f6f7d0 refactor: clarify description import (#526) 2023-09-16 15:24:23 +02:00
Carlos Valente 958c68b6f6 style hover (#525)
* refactor: render if in view

* style: add hover indicator on row
2023-09-16 13:49:12 +02:00
Carlos Valente a0c5375376 refactor: project data (#523) 2023-09-15 17:33:30 +02:00
Carlos Valente 9708f0bfc6 refactor: pass events instead of derived data to titles (#493)
* refactor: pass events instead of derived data to titles
2023-09-14 22:30:23 +02:00
Carlos Valente 13eca98133 refactor: recompose and memoise (#522) 2023-09-14 21:06:48 +02:00
Carlos Valente 126e31403e style tweaks (#524)
* style: allow 2 rows in block title

* fix: accidental change to next and progress bar
2023-09-13 21:51:24 +02:00
Carlos Valente 53beea2768 refactor: prevent calling undefined (#521) 2023-09-13 21:24:49 +02:00
Carlos Valente 0bb09dd039 fix: sanitise cue import (#519)
* fix: sanitise cue import
2023-09-11 21:06:32 +02:00
Carlos Valente 93fb48ea1c feat: operator view (#440)
* feat: operator view

* chore: smoke test operator

* refactor: small improvements from deepsource

---------

Co-authored-by: arihanv <arihanvaranasi@gmail.com>
Co-authored-by: arihanv <63890951+arihanv@users.noreply.github.com>
2023-09-11 21:03:11 +02:00
Carlos Valente 1de3e01216 style: localise labels to user device (#509) 2023-09-03 09:13:56 +02:00
Carlos Valente 8d427a6bbe fix: allow spaces in title input (#504) 2023-09-01 23:26:59 +02:00
Carlos Valente 23dcca5527 fix: issue with resolving dev environment (#501)
* fix: issue with resolving dev environment

* fix: revert accidental commtit

* chore: version bump
2023-08-31 22:08:18 +02:00
Carlos Valente a3bbd74db7 chore: reduce sentry error logging (#498)
* chore: reduce sentry error logging

* chore: version bump

* chore: improve test selectors
2023-08-27 22:00:18 +02:00
Carlos Valente 5323e627fb feat: count to end (#500) 2023-08-27 19:25:40 +02:00
Carlos Valente cd9dbcdc68 fix: add event (#499)
* refactor: remove guards on event creation

* style: minimum safe block size

* fix: insert after finds previous valid cue
2023-08-26 21:45:51 +02:00
Carlos Valente af99882919 release test fixes (#496)
* refactor: handle missing type

* fix: prevent stale data on cancel delay
2023-08-24 22:53:23 +02:00
Carlos Valente 1d4dc3a26f feat: unlock changing port in UI (#476)
* refactor: unlock changing port in UI

* Change server port (#464)

* Ability to save custom port

* Launch server on custom port & pass that port to electron

* Add serverPort validation

* Prevent changing port in docker

---------

Co-authored-by: Snehanshu Phukon <snehanshu@mamboemm.com>

---------

Co-authored-by: স্নেহাংশু ফুকন <hello@snehanshu.com>
Co-authored-by: Snehanshu Phukon <snehanshu@mamboemm.com>
2023-08-23 22:31:57 +02:00
Carlos Valente e5fdf27f6c fix: apply delay (#494)
* fix: bug with applying delays

* fix: show delay data only in production screens

* chore: cover delay with feature test

* refactor: simplify type assertion
2023-08-23 22:31:15 +02:00
Carlos Valente 657cc22b44 feat: event cue (#473)
* feat: parse cue

* refactor: get delay from backend

* feat: add cue to UI

* refactor: remove deprecated delay logic

* refactor: extract studio clock specific logic

* refactor: extract utilities

* refactor: fix issue with missing key

* style: prevent cue overflow

* style: prevent whitespace wrap

* feat: add support for cues in integrations
2023-08-17 21:43:11 +02:00
asharonbaltazar 51c31adaf1 Swap Event Data (#446)
* use zustand store in `ContextMenuContext`

* add `withDivider` prop to context menu `Option`

* add `isDisabled` prop to `Option`

* create `eventIdSwapping` store

* create swapping context menu options

* fix `key` in ContextMenu

* address `tanstack-eslint` errors

* create initial `requestEventSwap` event

* create initial `swapEvents` action

* use `swapEvents` in `EventBlock`

* move from `emitError` to `logAxiosError`

* remove extra curly brace from Copy ID

* add `clearEventId` func

* finalize context menu swapping logic

* write optimistic swapping logic

* remove unused import and type out handler in `rundownController`

* create `rundownSwapValidator`

* move index increase to `EventBlock`

* move swapping logic from `id` to `index`

* create `swapEvents` endpoint

* move `useEventIdSwapping` hook into its own file

* revert to using event `id`

* logic now uses indexes to swap events

* add `todo` to `swapEvents`

* revert index increment

* create `swapOntimeEvents` and export in `index.ts`

* use `swapOntimeEvents` in frontend & server

* update import path

* remove extra `setCached`
2023-08-15 16:04:53 -04:00
Carlos Valente a46a0e1631 fix: typo in macos release (#488) 2023-08-12 11:52:05 +02:00
Carlos Valente 4f298e6fd9 version bump (#487) 2023-08-12 11:38:36 +02:00
Carlos Valente f3610059ef fix paused (#486)
* fix: prevent timer continue on paused count up

* refactor: throw if not found on delete
2023-08-12 11:24:35 +02:00
Carlos Valente 023aac4ead fix: midnight roll (#484)
* refactor: extract utilities and types

* refactor: extract and simplify progress bar logic

* refactor: duration validation handles midnight

* fix: prevent stale event loader

* refactor: consider next event in timer invalidation

* refactor: reload changes on changed roll target

* refactor: timer load accounts for midnight

* fix: issues with midnight on roll

* refactor: prevent stale secondary event
2023-08-12 10:42:43 +02:00
Carlos Valente 8c9cb908cf chore: improve demo file (#482) 2023-08-12 10:00:30 +02:00
Carlos Valente 839cee18f3 feat: add french translations (#483) 2023-08-12 10:00:14 +02:00
Olayinka Zadat O b83ebf53a9 Adding Alias as a Parameter (#471)
* redirect on alias to a new page

* move alias to a HOC wrapper
2023-08-11 14:53:36 +02:00
Carlos Valente 1b8392bba4 dx: improve local build command (#480) 2023-08-11 10:55:24 +02:00
Carlos Valente af1241bec7 feat: event description (#481)
* feat: enable configuration of <Info> panel

* feat: add description field to event data
2023-08-11 10:55:02 +02:00
Carlos Valente c361ff4b3f refactor: handle undefined (#479) 2023-08-11 10:54:24 +02:00
Cody Wilson b579a4c57e dist-mac: create separate dists for aarch64, x64 (#470)
* dist-mac: create separate dists for aarch64, x64
2023-08-11 10:51:00 +02:00
392 changed files with 16647 additions and 7007 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"
}
]
} }
} }
+19 -7
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:
@@ -44,7 +44,8 @@ jobs:
- name: Docker Setup Buildx - name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0 uses: docker/setup-buildx-action@v2.5.0
- name: Build and push Docker images - name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0 uses: docker/build-push-action@v4.0.0
with: with:
context: . context: .
@@ -54,3 +55,14 @@ jobs:
push: true push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+9 -7
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
@@ -35,7 +35,9 @@ jobs:
- name: Release - name: Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
files: './apps/electron/dist/ontime-macOS.dmg' files: |
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -50,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
@@ -83,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,
+33 -9
View File
@@ -1,38 +1,48 @@
# GETTING STARTED # GETTING STARTED
Ontime consists of 3 distinct parts Ontime consists of 3 distinct parts
- __client__: A React app for Ontime's UI and web clients
- __client__: A React app for Ontime's UI and web clients
- __electron__: An electron app which facilitates the cross-platform distribution of Ontime - __electron__: An electron app which facilitates the cross-platform distribution of Ontime
- __server__: A node application which handles the domains services and integrations - __server__: A node application which handles the domains services and integrations
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- __node__ (>=16.16) - __node__ (>=16.16)
- __pnpm__ (>=7) - __pnpm__ (>=7)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
The electron app is only necessary to distribute an installable version of the app and is not required for local development. The electron app is only necessary to distribute an installable version of the app and is not required for local
development.
Locally, we would need to run both the React client and the node.js server in development mode Locally, we would need to run both the React client and the node.js server in development mode
From the project root, run the following commands From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i` - __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev` - __Run dev mode__ by running `turbo dev`
### Debugging backend ### Debugging backend
To debug backend code in Node.js: To debug backend code in Node.js:
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories. - Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server applications. - In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect`. applications.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
running `pnpm dev:inspect`.
## TESTING ## TESTING
Generally we have 2 types of tests. Generally we have 2 types of tests.
- Unit tests for functions that contain business logic - Unit tests for functions that contain business logic
- End-to-end tests for core features - End-to-end tests for core features
### Unit tests ### Unit tests
Unit tests are contained in mostly all the apps and packages (client, server and utils) Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running turbo `turbo test:pipeline` from the project root. You can run unit tests by running turbo `turbo test:pipeline` from the project root.
@@ -41,12 +51,22 @@ This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
### E2E tests ### E2E tests
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the webserver to test against
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the
webserver to test against
These tests also run against a separate version of the DB (test-db) 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 start the webserver with `pnpm dev:server` When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually
start the webserver with `pnpm dev:server`
Some other useful commands
- `pnpm e2e --ui` open playwright UI
- `pnpm e2e --headed` run tests with a visible browser window
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux) ## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
@@ -54,8 +74,9 @@ Ontime uses Electron to distribute the application.
You can generate a distribution for your OS by running the following steps. 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`
@@ -66,10 +87,13 @@ Ontime provides a docker-compose file to aid with building and running docker im
While it should allow for a generic setup, it might need to be modified to fit your infrastructure. While it should allow for a generic setup, it might need to be modified to fit your infrastructure.
From the project root, run the following commands 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 packages__ by running `pnpm build:localdocker`
- __Build docker image from__ by running `docker build -t getontime/ontime` - __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d` - __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands Other useful commands
- __List running processes__ by running `docker ps` - __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>` - __Kill running process__ by running `docker kill <process-id>`
+37 -45
View File
@@ -4,7 +4,7 @@
## Download the latest releases here ## Download the latest releases here
<div style="display: flex; justify-content: space-around"> <div style="display: flex; justify-content: space-around">
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a> <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a> <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a> <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a> <a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
@@ -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,
+16 -14
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "2.0.9", "version": "2.28.12",
"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,11 @@
"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 .", "build:localdocker": "cross-env NODE_ENV=local vite build",
"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 +61,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 +83,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",
+12 -12
View File
@@ -4,9 +4,9 @@ import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ContextMenu } from './common/components/context-menu/ContextMenu';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import { AppContextProvider } from './common/context/AppContext'; import { AppContextProvider } from './common/context/AppContext';
import { ContextMenuProvider } from './common/context/ContextMenuContext';
import useElectronEvent from './common/hooks/useElectronEvent'; import useElectronEvent from './common/hooks/useElectronEvent';
import { ontimeQueryClient } from './common/queryClient'; import { ontimeQueryClient } from './common/queryClient';
import { socketClientName } from './common/stores/connectionName'; import { socketClientName } from './common/stores/connectionName';
@@ -52,18 +52,18 @@ function App() {
<ChakraProvider resetCSS theme={theme}> <ChakraProvider resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}> <QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider> <AppContextProvider>
<ContextMenuProvider> <BrowserRouter>
<BrowserRouter> <div className='App'>
<div className='App'> <ErrorBoundary>
<ErrorBoundary> <TranslationProvider>
<TranslationProvider> <ContextMenu>
<AppRouter /> <AppRouter />
</TranslationProvider> </ContextMenu>
</ErrorBoundary> </TranslationProvider>
<ReactQueryDevtools initialIsOpen={false} /> </ErrorBoundary>
</div> <ReactQueryDevtools initialIsOpen={false} />
</BrowserRouter> </div>
</ContextMenuProvider> </BrowserRouter>
</AppContextProvider> </AppContextProvider>
</QueryClientProvider> </QueryClientProvider>
</ChakraProvider> </ChakraProvider>
+15 -27
View File
@@ -1,11 +1,12 @@
import { lazy, Suspense, useEffect } from 'react'; import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import useAliases from './common/hooks-query/useAliases'; import withAlias from './features/AliasWrapper';
import withData from './features/viewers/ViewWrapper'; import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor')); const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet')); const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/Operator'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer')); const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
@@ -17,14 +18,14 @@ const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper')); const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock')); const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withData(TimerView); const STimer = withAlias(withData(TimerView));
const SMinimalTimer = withData(MinimalTimerView); const SMinimalTimer = withAlias(withData(MinimalTimerView));
const SClock = withData(ClockView); const SClock = withAlias(withData(ClockView));
const SCountdown = withData(Countdown); const SCountdown = withAlias(withData(Countdown));
const SBackstage = withData(Backstage); const SBackstage = withAlias(withData(Backstage));
const SPublic = withData(Public); const SPublic = withAlias(withData(Public));
const SLowerThird = withData(Lower); const SLowerThird = withAlias(withData(Lower));
const SStudio = withData(StudioClock); const SStudio = withAlias(withData(StudioClock));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper')); const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport')); const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
@@ -33,22 +34,6 @@ const MessageControl = lazy(() => import('./features/control/message/MessageCont
const Info = lazy(() => import('./features/info/InfoExport')); const Info = lazy(() => import('./features/info/InfoExport'));
export default function AppRouter() { export default function AppRouter() {
const { data } = useAliases();
const location = useLocation();
const navigate = useNavigate();
// navigate if is alias route
useEffect(() => {
if (!data) return;
for (const d of data) {
if (`/${d.alias}` === location.pathname && d.enabled) {
navigate(`/${d.pathAndParams}`);
break;
}
}
}, [data, location, navigate]);
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<Routes> <Routes>
@@ -74,6 +59,9 @@ export default function AppRouter() {
{/*/!* Lower cannot have fallback *!/*/} {/*/!* Lower cannot have fallback *!/*/}
<Route path='/lower' element={<SLowerThird />} /> <Route path='/lower' element={<SLowerThird />} />
<Route path='/op' element={<Operator />} />
<Route path='/operator' element={<Operator />} />
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} /> <Route path='/cuesheet' element={<Cuesheet />} />
+9 -8
View File
@@ -1,25 +1,26 @@
export const STATIC_PORT = 4001;
// REST stuff // REST stuff
export const EVENT_DATA = ['eventdata']; 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 HTTP_SETTINGS = ['httpSettings'];
export const APP_SETTINGS = ['appSettings']; export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const RUNTIME = ['runtimeStore']; export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
const location = window.location; const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const serverPort = import.meta.env.DEV ? STATIC_PORT : location.port; const STATIC_PORT = 4001;
export const serverURL = import.meta.env.DEV ? `http://${location.hostname}:${serverPort}` : location.origin; export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`; export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const eventURL = `${serverURL}/eventdata`; export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`; export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`; export const ontimeURL = `${serverURL}/ontime`;
+36 -3
View File
@@ -2,13 +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 maybeAxiosError(error: unknown) {
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
let data = (error as AxiosError).response?.data ?? '';
if (typeof data === 'object') {
if ('message' in data) {
data = JSON.stringify(data.message);
} else {
data = JSON.stringify(data);
}
}
return `${statusText}: ${data}`;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
export function logAxiosError(prepend: string, error: unknown) { export function logAxiosError(prepend: string, error: unknown) {
const message = axios.isAxiosError(error) const message = `${prepend}: ${maybeAxiosError(error)}`;
? `${prepend}, ${(error as AxiosError).response?.statusText}: ${(error as AxiosError).response?.data}`
: `${prepend}: ${error}`;
addLog({ addLog({
id: generateId(), id: generateId(),
@@ -18,3 +37,17 @@ export function logAxiosError(prepend: string, error: unknown) {
text: message, text: message,
}); });
} }
/**
* Utility function invalidates react-query caches
*/
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] });
}
@@ -1,21 +0,0 @@
import axios from 'axios';
import { EventData } from 'ontime-types';
import { eventURL } from './apiConstants';
/**
* @description HTTP request to fetch event data
* @return {Promise}
*/
export async function fetchEventData(): Promise<EventData> {
const res = await axios.get(eventURL);
return res.data;
}
/**
* @description HTTP request to mutate event data
* @return {Promise}
*/
export async function postEventData(data: EventData) {
return axios.post(eventURL, data);
}
+24 -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;
@@ -50,6 +60,19 @@ export async function requestApplyDelay(eventId: string) {
return axios.patch(`${rundownURL}/applydelay/${eventId}`); return axios.patch(`${rundownURL}/applydelay/${eventId}`);
} }
export type SwapEntry = {
from: string;
to: string;
};
/**
* @description HTTP request to swap two events
* @return {Promise}
*/
export async function requestEventSwap(data: SwapEntry) {
return axios.patch(`${rundownURL}/swap`, data);
}
/** /**
* @description HTTP request to delete given event * @description HTTP request to delete given event
* @return {Promise} * @return {Promise}
+162 -33
View File
@@ -1,16 +1,21 @@
import axios from 'axios'; import axios, { AxiosResponse } from 'axios';
import { import {
Alias, Alias,
EventData, DatabaseModel,
GetInfo,
HttpSettings,
OntimeRundown,
OSCSettings, OSCSettings,
OscSubscription, OscSubscription,
ProjectData,
Settings, Settings,
UserFields, UserFields,
ViewSettings, ViewSettings,
} from 'ontime-types'; } 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';
@@ -35,7 +40,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;
} }
@@ -100,6 +105,23 @@ export async function getOSC(): Promise<OSCSettings> {
return res.data; return res.data;
} }
/**
* @description HTTP request to retrieve http settings
* @return {Promise}
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(`${ontimeURL}/http`);
return res.data;
}
/**
* @description HTTP request to mutate http settings
* @return {Promise}
*/
export async function postHTTP(data: HttpSettings) {
return axios.post(`${ontimeURL}/http`, data);
}
/** /**
* @description HTTP request to mutate osc settings * @description HTTP request to mutate osc settings
* @return {Promise} * @return {Promise}
@@ -117,45 +139,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: {
@@ -169,6 +184,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;
@@ -186,6 +242,79 @@ export async function getLatestVersion(): Promise<HasUpdate> {
}; };
} }
export async function postNew(initialData: Partial<EventData>) { export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData); return axios.post(`${ontimeURL}/new`, initialData);
} }
/**
* @description STEP 1
*/
export const uploadSheetClientFile = async (file: File) => {
const formData = new FormData();
formData.append('userFile', file);
const res = await axios
.post(`${ontimeURL}/sheet/clientsecret`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then((response) => response.data.id);
return res;
};
/**
* @description STEP 1 test
*/
export const getClientSecrect = async () => {
const response = await axios.get(`${ontimeURL}/sheet/clientsecret`);
return response.data;
};
/**
* @description STEP 2
*/
export const getSheetsAuthUrl = async () => {
const response = await axios.get(`${ontimeURL}/sheet/authentication/url`);
return response.data;
};
/**
* @description STEP 2 test
*/
export const getAuthentication = async () => {
const response = await axios.get(`${ontimeURL}/sheet/authentication`);
return response.data;
};
/**
* @description STEP 3
* @returns worksheetOptions
*/
export const postId = async (id: string) => {
const response = await axios.post(`${ontimeURL}/sheet/id`, { id });
return response.data;
};
/**
* @description STEP 4
*/
export const postWorksheet = async (id: string, worksheet: string) => {
const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { id, worksheet });
return response.data;
};
/**
* @description STEP 5
*/
export const postPreviewSheet = async (id: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet/pull`, { id, options });
return response.data.data;
};
/**
* @description STEP 5
*/
export const postPushSheet = async (id: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options });
return response.data.data;
};
@@ -0,0 +1,21 @@
import axios from 'axios';
import { ProjectData } from 'ontime-types';
import { projectDataURL } from './apiConstants';
/**
* @description HTTP request to fetch project data
* @return {Promise}
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectDataURL);
return res.data;
}
/**
* @description HTTP request to mutate project data
* @return {Promise}
*/
export async function postProjectData(data: ProjectData) {
return axios.post(projectDataURL, data);
}
@@ -0,0 +1,84 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { Fragment, ReactElement } from 'react';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
x: number;
y: number;
};
export type Option = {
label: string;
icon: IconType;
onClick: () => void;
withDivider?: boolean;
isDisabled?: boolean;
};
type ContextMenuStore = {
coords: ContextMenuCoords;
options: Option[];
isOpen: boolean;
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
setIsOpen: (newIsOpen: boolean) => void;
};
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
coords: { x: 0, y: 0 },
options: [],
isOpen: false,
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
}));
interface ContextMenuProps {
// ReactElement type required due to early `return` (line 51) returning {children}
children: ReactElement;
}
export const ContextMenu = ({ children }: ContextMenuProps) => {
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
const onClose = () => {
return setIsOpen(false);
};
if (!isOpen) {
return children;
}
return (
<>
{children}
<div className={style.contextMenuBackdrop} />
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
w={1}
h={1}
style={{
left: coords.x,
top: coords.y,
}}
/>
<MenuList>
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
<Fragment key={label}>
{withDivider && <MenuDivider />}
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</Fragment>
))}
</MenuList>
</Menu>
</>
);
};
@@ -0,0 +1,9 @@
@use '../../../theme/v2Styles' as *;
.delaySymbol {
svg {
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
}
}
@@ -0,0 +1,40 @@
import { Tooltip } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig';
import style from './DelayIndicator.module.scss';
interface DelayIndicatorProps {
delayValue?: number;
}
export default function DelayIndicator(props: DelayIndicatorProps) {
const { delayValue } = props;
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
}
return null;
}
@@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorTitles'; 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';
} }
@@ -17,9 +17,8 @@ interface TimeInputProps {
time?: number; time?: number;
delay?: number; delay?: number;
placeholder: string; placeholder: string;
validationHandler: (entry: TimeEntryField, val: number) => boolean;
previousEnd?: number; previousEnd?: number;
warning?: string; tooltip?: string;
} }
function ButtonInitial(name: TimeEntryField) { function ButtonInitial(name: TimeEntryField) {
@@ -29,15 +28,15 @@ function ButtonInitial(name: TimeEntryField) {
return ''; return '';
} }
function ButtonTooltip(name: TimeEntryField, warning?: string) { function ButtonTooltip(name: TimeEntryField, tooltip?: string) {
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`; if (name === 'timeStart') return `Start${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`; if (name === 'timeEnd') return `End${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`; if (name === 'durationOverride') return `Duration${tooltip ? `: ${tooltip}` : ''}`;
return ''; return '';
} }
export default function TimeInput(props: TimeInputProps) { export default function TimeInput(props: TimeInputProps) {
const { id, name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props; const { id, name, submitHandler, time = 0, delay = 0, placeholder, previousEnd = 0 } = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
@@ -93,15 +92,12 @@ export default function TimeInput(props: TimeInputProps) {
// check if time is different from before // check if time is different from before
if (newValMillis === time) return false; if (newValMillis === time) return false;
// validate with parent
if (!validationHandler(name, newValMillis)) return false;
// update entry // update entry
submitHandler(name, newValMillis); submitHandler(name, newValMillis);
return true; return true;
}, },
[name, previousEnd, submitHandler, time, validationHandler], [name, previousEnd, submitHandler, time],
); );
/** /**
@@ -161,11 +157,11 @@ export default function TimeInput(props: TimeInputProps) {
const isDelayed = delay !== 0; const isDelayed = delay !== 0;
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]); const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null, warning ? style.warn : null]); const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null]);
const TooltipLabel = useMemo(() => { const TooltipLabel = useMemo(() => {
return ButtonTooltip(name, warning); return ButtonTooltip(name, '');
}, [name, warning]); }, [name]);
const ButtonText = useMemo(() => { const ButtonText = useMemo(() => {
return ButtonInitial(name); return ButtonInitial(name);
@@ -191,7 +187,7 @@ export default function TimeInput(props: TimeInputProps) {
<Input <Input
ref={inputRef} ref={inputRef}
id={id} id={id}
data-testid='time-input' data-testid={`time-input-${name}`}
className={style.inputField} className={style.inputField}
type='text' type='text'
placeholder={placeholder} placeholder={placeholder}
@@ -202,6 +198,7 @@ export default function TimeInput(props: TimeInputProps) {
onKeyDown={onKeyDownHandler} onKeyDown={onKeyDownHandler}
value={value} value={value}
maxLength={8} maxLength={8}
autoComplete='off'
/> />
</InputGroup> </InputGroup>
); );
@@ -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>
); );
} }
@@ -10,10 +10,6 @@ $icon-color: $ui-white;
$button-bg: $gray-1050; $button-bg: $gray-1050;
$button-size: 48px; $button-size: 48px;
.mirror {
transform: rotate(180deg);
}
.buttonContainer { .buttonContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -12,7 +12,6 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside'; import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen'; import useFullscreen from '../../hooks/useFullscreen';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import RenameClientModal from './rename-client-modal/RenameClientModal'; import RenameClientModal from './rename-client-modal/RenameClientModal';
@@ -23,18 +22,19 @@ function NavigationMenu() {
const location = useLocation(); const location = useLocation();
const { isFullScreen, toggleFullScreen } = useFullscreen(); const { isFullScreen, toggleFullScreen } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore(); const { toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false); const [showButton, setShowButton] = useState(false);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false)); useClickOutside(menuRef, () => setShowMenu(false));
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev); const toggleMenu = () => setShowMenu((prev) => !prev);
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen });
// show on mouse move
useEffect(() => { useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null; let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => { const setShowMenuTrue = () => {
@@ -58,12 +58,12 @@ function NavigationMenu() {
const handleMirror = () => toggleMirror(); const handleMirror = () => toggleMirror();
const showEditFormDrawer = () => { const showEditFormDrawer = () => {
searchParams.append('edit', 'true'); searchParams.set('edit', 'true');
setSearchParams(searchParams); setSearchParams(searchParams);
}; };
return createPortal( return createPortal(
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}> <div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal isOpen={isOpen} onClose={onClose} /> <RenameClientModal isOpen={isOpen} onClose={onClose} />
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}> <div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}> <button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
@@ -116,6 +116,10 @@ function NavigationMenu() {
Cuesheet Cuesheet
<IoArrowUp className={style.linkIcon} /> <IoArrowUp className={style.linkIcon} />
</Link> </Link>
<Link to='/op' className={style.link} tabIndex={0}>
Operator
<IoArrowUp className={style.linkIcon} />
</Link>
<hr className={style.separator} /> <hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<Link <Link
@@ -8,10 +8,12 @@ import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps { interface PlaybackIconProps {
state: Playback; state: Playback;
skipTooltip?: boolean;
className?: string;
} }
export default function PlaybackIcon(props: PlaybackIconProps) { export default function PlaybackIcon(props: PlaybackIconProps) {
const { state } = props; const { state, skipTooltip, className } = props;
// if timer is Pause or Armed // if timer is Pause or Armed
let label = 'Timer Paused'; let label = 'Timer Paused';
@@ -28,9 +30,13 @@ export default function PlaybackIcon(props: PlaybackIconProps) {
Icon = IoStop; Icon = IoStop;
} }
if (skipTooltip) {
return <Icon className={className} />;
}
return ( return (
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren> <Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
<Icon /> <Icon className={className} />
</Tooltip> </Tooltip>
); );
} }
@@ -1,19 +1,19 @@
import Empty from '../state/Empty';
import { useSchedule } from './ScheduleContext'; import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem'; import ScheduleItem from './ScheduleItem';
import './Schedule.scss'; import './Schedule.scss';
interface ScheduleProps { interface ScheduleProps {
isProduction?: boolean;
className?: string; className?: string;
} }
export default function Schedule({ 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';
@@ -30,12 +30,16 @@ export default function Schedule({ className }: ScheduleProps) {
selectedState = 'future'; selectedState = 'future';
} }
} }
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
return ( return (
<ScheduleItem <ScheduleItem
key={event.id} key={event.id}
selected={selectedState} selected={selectedState}
timeStart={event.timeStart} timeStart={timeStart}
timeEnd={event.timeEnd} timeEnd={timeEnd}
title={event.title} title={event.title}
colour={isBackstage ? event.colour : ''} colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic} backstageEvent={!event.isPublic}
@@ -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,4 +1,5 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg'; import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg';
import style from './Empty.module.scss'; import style from './Empty.module.scss';
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Input, Select, Switch } from '@chakra-ui/react'; import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
import { isStringBoolean } from '../../utils/viewUtils'; import { isStringBoolean } from '../../utils/viewUtils';
@@ -9,16 +9,22 @@ interface EditFormInputProps {
paramField: ParamField; paramField: ParamField;
} }
export default function ParamInput({ paramField }: EditFormInputProps) { export default function ParamInput(props: EditFormInputProps) {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { id, type } = paramField; const { paramField } = props;
const { id, type, defaultValue } = paramField;
if (type === 'option') { if (type === 'option') {
const optionFromParams = searchParams.get(id); const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || undefined; const defaultOptionValue = optionFromParams || defaultValue;
return ( return (
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}> <Select
placeholder={defaultValue ? undefined : 'Select an option'}
variant='ontime'
name={id}
defaultValue={defaultOptionValue}
>
{Object.entries(paramField.values).map(([key, value]) => ( {Object.entries(paramField.values).map(([key, value]) => (
<option key={key} value={key}> <option key={key} value={key}>
{value} {value}
@@ -29,19 +35,38 @@ export default function ParamInput({ paramField }: EditFormInputProps) {
} }
if (type === 'boolean') { if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) ?? false; const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
// checked value should be 'true', so it can be captured by the form event // checked value should be 'true', so it can be captured by the form event
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />; return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
} }
if (type === 'number') { if (type === 'number') {
const defaultNumberValue = searchParams.get(id) ?? ''; const { prefix, placeholder } = paramField;
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
return <Input type='number' step='any' variant='ontime-filled' name={id} defaultValue={defaultNumberValue} />; return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input
type='number'
step='any'
variant='ontime-filled'
name={id}
defaultValue={defaultNumberValue}
placeholder={placeholder}
/>
</InputGroup>
);
} }
const defaultStringValue = searchParams.get(id) ?? ''; const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { prefix, placeholder } = paramField;
return <Input variant='ontime-filled' name={id} defaultValue={defaultStringValue} />; return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input name={id} defaultValue={defaultStringValue} placeholder={placeholder} />
</InputGroup>
);
} }
@@ -2,7 +2,7 @@
@use '../../../theme/ontimeColours' as *; @use '../../../theme/ontimeColours' as *;
.drawerContent { .drawerContent {
background-color: $gray-1200; background-color: $gray-1250;
} }
.drawerHeader { .drawerHeader {
@@ -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,34 @@ 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, paramFields: ParamField[]) => {
const defaultValues = paramFields.map(({ defaultValue }) => String(defaultValue));
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) {
if (defaultValues.includes(value)) {
return newSearchParams;
}
newSearchParams.set(id, value);
return newSearchParams;
}
return newSearchParams;
}, new URLSearchParams());
};
interface EditFormDrawerProps { interface EditFormDrawerProps {
paramFields: ParamField[]; paramFields: ParamField[];
} }
@@ -24,6 +47,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,36 +58,51 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
} }
}, [searchParams, onOpen]); }, [searchParams, onOpen]);
const onEditDrawerClose = () => { /**
* 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 onCloseWithoutSaving = () => {
onClose(); onClose();
searchParams.delete('edit'); searchParams.delete('edit');
setSearchParams(searchParams); setSearchParams(searchParams);
}; };
const clearParams = () => { const resetParams = () => {
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
setSearchParams(); setSearchParams();
onClose();
}; };
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => { const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
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, paramFields);
if (typeof value === 'string' && value.length) {
newSearchParams.set(id, value);
return newSearchParams; setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
}
return newSearchParams;
}, new URLSearchParams());
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
}; };
return ( return (
<Drawer isOpen={isOpen} placement='right' onClose={onEditDrawerClose} size='lg'> <Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
<DrawerOverlay /> <DrawerOverlay />
<DrawerContent> <DrawerContent>
<DrawerHeader className={style.drawerHeader}> <DrawerHeader className={style.drawerHeader}>
@@ -85,10 +125,10 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
</DrawerBody> </DrawerBody>
<DrawerFooter className={style.drawerFooter}> <DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={clearParams} type='reset'> <Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Clear Reset
</Button> </Button>
<Button variant='ontime-subtle' onClick={onEditDrawerClose}> <Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
Cancel Cancel
</Button> </Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'> <Button variant='ontime-filled' form='edit-params-form' type='submit'>
@@ -1,44 +1,56 @@
import { UserFields } from 'ontime-types';
import { TimeFormat } from 'ontime-types/src/definitions/core/TimeFormat.type';
import { ParamField } from './types'; import { ParamField } from './types';
export const TIME_FORMAT_OPTION: ParamField = { export const getTimeOption = (timeFormat: TimeFormat): ParamField => ({
id: 'format', id: 'format',
title: '12 / 24 hour timer', title: '12 / 24 hour timer',
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences', description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
type: 'option', type: 'option',
values: { '12': '12 hour AM/PM', '24': '24 hour' }, values: { '12': '12 hour AM/PM', '24': '24 hour' },
}; defaultValue: timeFormat,
});
export const CLOCK_OPTIONS: ParamField[] = [ export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [
TIME_FORMAT_OPTION, getTimeOption(timeFormat),
{ {
id: 'key', id: 'key',
title: 'Key Colour', title: 'Key Colour',
description: 'Background colour in hexadecimal', description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string', type: 'string',
placeholder: '00000000 (default)',
}, },
{ {
id: 'text', id: 'text',
title: 'Text Colour', title: 'Text Colour',
description: 'Text colour in hexadecimal', description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string', type: 'string',
placeholder: 'fffff (default)',
}, },
{ {
id: 'textbg', id: 'textbg',
title: 'Text Background', title: 'Text Background',
description: 'Colour of text background in hexadecimal', description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string', type: 'string',
placeholder: '00000000 (default)',
}, },
{ {
id: 'font', id: 'font',
title: 'Font', title: 'Font',
description: 'Font family, will use the fonts available in the system', description: 'Font family, will use the fonts available in the system',
type: 'string', type: 'string',
placeholder: 'Arial Black (default)',
}, },
{ {
id: 'size', id: 'size',
title: 'Text Size', title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)', description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number', type: 'number',
placeholder: '1 (default)',
}, },
{ {
id: 'alignx', id: 'alignx',
@@ -46,12 +58,14 @@ export const CLOCK_OPTIONS: ParamField[] = [
description: 'Moves the horizontally in page to start = left | center | end = right', description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option', type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' }, values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
}, },
{ {
id: 'offsetx', id: 'offsetx',
title: 'Offset Horizontal', title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels', description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number', type: 'number',
placeholder: '0 (default)',
}, },
{ {
id: 'aligny', id: 'aligny',
@@ -59,47 +73,94 @@ export const CLOCK_OPTIONS: ParamField[] = [
description: 'Moves the vertically in page to start = left | center | end = right', description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option', type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' }, values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
}, },
{ {
id: 'offsety', id: 'offsety',
title: 'Offset Vertical', title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels', description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number', type: 'number',
placeholder: '0 (default)',
}, },
]; ];
export const TIMER_OPTIONS: ParamField[] = [TIME_FORMAT_OPTION]; export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
{ {
id: 'key', id: 'key',
title: 'Key Colour', title: 'Key Colour',
description: 'Background colour in hexadecimal', description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string', type: 'string',
placeholder: '00000000 (default)',
}, },
{ {
id: 'text', id: 'text',
title: 'Text Colour', title: 'Text Colour',
description: 'Text colour in hexadecimal', description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string', type: 'string',
placeholder: 'fffff (default)',
}, },
{ {
id: 'textbg', id: 'textbg',
title: 'Text Background', title: 'Text Background',
description: 'Colour of text background in hexadecimal', description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string', type: 'string',
placeholder: '00000000 (default)',
}, },
{ {
id: 'font', id: 'font',
title: 'Font', title: 'Font',
description: 'Font family, will use the fonts available in the system', description: 'Font family, will use the fonts available in the system',
type: 'string', type: 'string',
placeholder: 'Arial Black (default)',
}, },
{ {
id: 'size', id: 'size',
title: 'Text Size', title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)', description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number', type: 'number',
placeholder: '1 (default)',
}, },
{ {
id: 'alignx', id: 'alignx',
@@ -107,12 +168,14 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
description: 'Moves the horizontally in page to start = left | center | end = right', description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option', type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' }, values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
}, },
{ {
id: 'offsetx', id: 'offsetx',
title: 'Offset Horizontal', title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels', description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number', type: 'number',
placeholder: '0 (default)',
}, },
{ {
id: 'aligny', id: 'aligny',
@@ -120,84 +183,209 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
description: 'Moves the vertically in page to start = left | center | end = right', description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option', type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' }, values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
}, },
{ {
id: 'offsety', id: 'offsety',
title: 'Offset Vertical', title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels', description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number', type: 'number',
placeholder: '0 (default)',
}, },
{ {
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',
defaultValue: false,
}, },
{ {
id: 'hidemessages', id: 'hidemessages',
title: 'Hide Message Overlay', title: 'Hide Message Overlay',
description: 'Whether to hide the overlay from showing timer screen messages', description: 'Whether to hide the overlay from showing timer screen messages',
type: 'boolean', type: 'boolean',
defaultValue: false,
}, },
{ {
id: 'hideendmessage', id: 'hideendmessage',
title: 'Hide End Message', title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime', description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean', type: 'boolean',
defaultValue: false,
}, },
]; ];
export const LOWER_THIRDS_OPTIONS: ParamField[] = [ export const LOWER_THIRDS_OPTIONS: ParamField[] = [
{
id: 'preset',
title: 'Preset',
description: 'Selects a style preset (0-1)',
type: 'number',
},
{ {
id: 'size', id: 'size',
title: 'Size', title: 'Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)', description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number', type: 'number',
placeholder: '1 (default)',
}, },
{ {
id: 'transition', id: 'transition',
title: 'Transition', title: 'Transition',
description: 'Transition in time in seconds (default 5)', description: 'Transition in time in seconds (default 3)',
type: 'number', type: 'number',
placeholder: '3 (default)',
}, },
{ {
id: 'text', id: 'text',
title: 'Text Colour', title: 'Text Colour',
description: 'Text colour in hexadecimal', description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string', type: 'string',
placeholder: 'fffffa (default)',
}, },
{ {
id: 'bg', id: 'bg',
title: 'Text Background', title: 'Text Background',
description: 'Text background colour in hexadecimal', description: 'Text background colour in hexadecimal',
prefix: '#',
type: 'string', type: 'string',
placeholder: '00000033 (default)',
}, },
{ {
id: 'key', id: 'key',
title: 'Key Colour', title: 'Key Colour',
description: 'Screen background colour in hexadecimal', description: 'Screen background colour in hexadecimal',
prefix: '#',
type: 'string', type: 'string',
placeholder: '00000033 (default)',
}, },
{ {
id: 'fadeout', id: 'fadeout',
title: 'Fadeout', title: 'Fadeout',
description: 'Time (in seconds) the lower third displays before fading out', description: 'Time (in seconds) the lower third displays before fading out',
type: 'number', type: 'number',
placeholder: '3 (default)',
}, },
]; ];
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [ export const getBackstageOptions = (timeFormat: TimeFormat): ParamField[] => [
TIME_FORMAT_OPTION, getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
];
export const getPublicOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
];
export const getStudioClockOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{ {
id: 'seconds', id: 'seconds',
title: 'Show Seconds', title: 'Show Seconds',
description: 'Shows seconds in clock', description: 'Shows seconds in clock',
type: 'boolean', type: 'boolean',
defaultValue: false,
}, },
]; ];
export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeFormat): ParamField[] => {
return [
getTimeOption(timeFormat),
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
},
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a field to highlight',
type: 'option',
values: {
user0: userFields.user0 || 'user0',
user1: userFields.user1 || 'user1',
user2: userFields.user2 || 'user2',
user3: userFields.user3 || 'user3',
user4: userFields.user4 || 'user4',
user5: userFields.user5 || 'user5',
user6: userFields.user6 || 'user6',
user7: userFields.user7 || 'user7',
user8: userFields.user8 || 'user8',
user9: userFields.user9 || 'user9',
},
},
{
id: 'shouldEdit',
title: 'Edit user field',
description: 'Allows editing an events user field by long pressing on it. Needs a selected highlighted field',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -4,9 +4,13 @@ type BaseField = {
description: string; description: string;
}; };
type OptionsField = { type: 'option'; values: Record<string, string> }; type OptionsField = {
type StringField = { type: 'string' }; type: 'option';
type BooleanField = { type: 'boolean' }; values: Record<string, string>;
type NumberField = { type: 'number' }; defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField); export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
@@ -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,76 +0,0 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { createContext, ReactNode, useState } from 'react';
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import style from './ContextMenuContext.module.scss';
type ContextMenuCoords = {
x: number;
y: number;
};
type ContextMenuContextType = {
createContextMenu: (options: Option[], menuCoordinates: ContextMenuCoords) => void;
};
export const ContextMenuContext = createContext<ContextMenuContextType | null>(null);
export type Option = {
label: string;
icon: IconType;
onClick: () => void;
};
interface ContextMenuProviderProps {
children: ReactNode;
}
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const [isOpen, setIsOpen] = useState(false);
const [coords, setCoords] = useState<ContextMenuCoords>({ x: 0, y: 0 });
const [options, setOptions] = useState<Option[]>([]);
const onClose = () => {
return setIsOpen(false);
};
const createContextMenu = (options: Option[], menuCoords: ContextMenuCoords) => {
setCoords(menuCoords);
setOptions(options);
setIsOpen(true);
};
return (
<ContextMenuContext.Provider value={{ createContextMenu }}>
{children}
{isOpen && (
<>
<div className={style.contextMenuBackdrop} />
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
w={1}
h={1}
style={{
left: coords.x,
top: coords.y,
}}
/>
<MenuList>
{options.map(({ label, icon: Icon, onClick }, i) => (
<MenuItem key={i} icon={<Icon />} onClick={onClick}>
{label}
</MenuItem>
))}
</MenuList>
</Menu>
</>
)}
</ContextMenuContext.Provider>
);
};
@@ -0,0 +1,32 @@
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
import { useCallback, useEffect, useState } from 'react';
function getMatches(query: string): boolean {
return window.matchMedia(query).matches;
}
// TODO: debounce handleChange
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMatches(query));
const handleChange = useCallback(() => {
setMatches(getMatches(query));
}, [query]);
useEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
// Listen matchMedia
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [handleChange, query]);
return matches;
}
@@ -0,0 +1,33 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { HttpSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getHTTP, postHTTP } from '../api/ontimeApi';
import { httpPlaceholder } from '../models/Http';
import { ontimeQueryClient } from '../queryClient';
export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: httpPlaceholder,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch };
}
export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
});
return { isPending, mutateAsync };
}
@@ -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,5 +1,4 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { OSCSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants'; import { OSC_SETTINGS } from '../api/apiConstants';
@@ -11,7 +10,10 @@ import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() { export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS, queryKey: OSC_SETTINGS,
queryFn: getOSC, queryFn: async () => {
const oscData = await getOSC();
return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) };
},
placeholderData: oscPlaceholderSettings, placeholderData: oscPlaceholderSettings,
retry: 5, retry: 5,
retryDelay: (attempt: number) => attempt * 2500, retryDelay: (attempt: number) => attempt * 2500,
@@ -19,25 +21,24 @@ export default function useOscSettings() {
networkMode: 'always', networkMode: 'always',
}); });
// we need to jump through some hoops because of the type op port return { data: data ?? oscPlaceholderSettings, status, isFetching, isError, refetch };
return { data: data! as unknown as OSCSettings, status, isFetching, isError, refetch };
} }
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,15 +1,15 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { EVENT_DATA } from '../api/apiConstants'; import { PROJECT_DATA } from '../api/apiConstants';
import { fetchEventData } from '../api/eventDataApi'; import { getProjectData } from '../api/projectDataApi';
import { eventDataPlaceholder } from '../models/EventData'; import { projectDataPlaceholder } from '../models/ProjectData';
export default function useEventData() { export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: EVENT_DATA, queryKey: PROJECT_DATA,
queryFn: fetchEventData, queryFn: getProjectData,
placeholderData: eventDataPlaceholder, placeholderData: projectDataPlaceholder,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -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,22 +1,16 @@
import { MouseEvent, useContext } from 'react'; import { MouseEvent } from 'react';
import { ContextMenuContext, Option } from '../context/ContextMenuContext'; import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu';
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => { export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
const contextMenuContext = useContext(ContextMenuContext); const { setContextMenu } = useContextMenuStore();
if (contextMenuContext === null) {
throw new Error('useContextMenu should be wrapped by ContextMenuProvider');
}
const { createContextMenu } = contextMenuContext;
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => { const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up // prevent browser default context menu from showing up
contextMenuEvent.preventDefault(); contextMenuEvent.preventDefault();
const { pageX, pageY } = contextMenuEvent; const { pageX, pageY } = contextMenuEvent;
return createContextMenu(options, { x: pageX, y: pageY }); return setContextMenu({ x: pageX, y: pageY }, options);
}; };
return [localCreateContextMenu]; return [localCreateContextMenu];
@@ -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);
} }
+147 -50
View File
@@ -1,26 +1,29 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
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,
requestApplyDelay, requestApplyDelay,
requestDelete, requestDelete,
requestDeleteAll, requestDeleteAll,
requestEventSwap,
requestPostEvent, requestPostEvent,
requestPutEvent, requestPutEvent,
requestReorderEvent, requestReorderEvent,
SwapEntry,
} from '../api/eventsApi'; } from '../api/eventsApi';
import { useLocalEvent } from '../stores/localEvent'; import { useEditorSettings } from '../stores/editorSettings';
/** /**
* @description Set of utilities for events * @description Set of utilities for events
*/ */
export const useEventAction = () => { export const useEventAction = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const eventSettings = useLocalEvent((state) => state.eventSettings); const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic; const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd; const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
@@ -28,11 +31,12 @@ export const useEventAction = () => {
* Calls mutation to add new event * Calls mutation to add new event
* @private * @private
*/ */
const _addEventMutation = useMutation(requestPostEvent, { const _addEventMutation = useMutation({
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
mutationFn: requestPostEvent,
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always', networkMode: 'always',
}); });
@@ -55,7 +59,7 @@ export const useEventAction = () => {
const newEvent: Partial<OntimeRundownEntry> = { ...event }; const newEvent: Partial<OntimeRundownEntry> = { ...event };
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (newEvent.type === SupportedEvent.Event) { if (isOntimeEvent(newEvent)) {
const applicationOptions = { const applicationOptions = {
defaultPublic: options?.defaultPublic ?? defaultPublic, defaultPublic: options?.defaultPublic ?? defaultPublic,
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd, startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
@@ -63,16 +67,21 @@ export const useEventAction = () => {
after: options?.after, after: options?.after,
}; };
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
if (newEvent?.cue === undefined) {
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
// this until timeOptions gets implemented // this until timeOptions gets implemented
if (typeof newEvent?.timeStart !== 'undefined' && typeof newEvent.timeEnd !== 'undefined') { if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) {
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0; newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
} }
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 (typeof previousEvent !== 'undefined' && previousEvent.type === 'event') { if (previousEvent !== undefined && previousEvent.type === 'event') {
newEvent.timeStart = previousEvent.timeEnd; newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd; newEvent.timeEnd = previousEvent.timeEnd;
} }
@@ -92,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],
@@ -102,30 +111,40 @@ export const useEventAction = () => {
* Calls mutation to update existing event * Calls mutation to update existing event
* @private * @private
*/ */
const _updateEventMutation = useMutation(requestPutEvent, { const _updateEventMutation = useMutation({
mutationFn: requestPutEvent,
// 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',
}); });
@@ -148,32 +167,42 @@ export const useEventAction = () => {
* Calls mutation to delete an event * Calls mutation to delete an event
* @private * @private
*/ */
const _deleteEventMutation = useMutation(requestDelete, { const _deleteEventMutation = useMutation({
mutationFn: requestDelete,
// 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',
}); });
@@ -196,30 +225,31 @@ export const useEventAction = () => {
* Calls mutation to delete all events * Calls mutation to delete all events
* @private * @private
*/ */
const _deleteAllEventsMutation = useMutation(requestDeleteAll, { const _deleteAllEventsMutation = useMutation({
mutationFn: requestDeleteAll,
// 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',
}); });
@@ -239,10 +269,11 @@ export const useEventAction = () => {
* Calls mutation to apply a delay * Calls mutation to apply a delay
* @private * @private
*/ */
const _applyDelayMutation = useMutation(requestApplyDelay, { const _applyDelayMutation = useMutation({
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',
}); });
@@ -265,34 +296,37 @@ export const useEventAction = () => {
* Calls mutation to reorder an event * Calls mutation to reorder an event
* @private * @private
*/ */
const _reorderEventMutation = useMutation(requestReorderEvent, { const _reorderEventMutation = useMutation({
mutationFn: requestReorderEvent,
// 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',
}); });
@@ -316,5 +350,68 @@ export const useEventAction = () => {
[_reorderEventMutation], [_reorderEventMutation],
); );
return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent }; /**
* Calls mutation to swap events
* @private
*/
const _swapEvents = useMutation({
mutationFn: requestEventSwap,
// we optimistically update here
onMutate: async ({ from, to }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
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 optimisticRundown = swapOntimeEvents(previousData.rundown, fromEventIndex, toEventIndex);
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
// Return a context with the previous events
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
* Swaps the schedule of two events
*/
const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => {
// TODO: before calling `/swapEvents`,
// we should determine the events are of type `OntimeEvent`
try {
await _swapEvents.mutateAsync({ from, to });
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
},
[_swapEvents],
);
return {
addEvent,
updateEvent,
deleteEvent,
deleteAllEvents,
applyDelay,
reorderEvent,
swapEvents,
};
}; };
+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 };
}; };
@@ -0,0 +1,64 @@
import { MutableRefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: MutableRefObject<ComponentRef>,
scrollRef: MutableRefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
interface UseFollowComponentProps {
followRef: MutableRefObject<HTMLElement | null>;
scrollRef: MutableRefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
}
export default function useFollowComponent(props: UseFollowComponentProps) {
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
// when cursor moves, view should follow
useEffect(() => {
if (!doFollow) {
return;
}
if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(
followRef as MutableRefObject<HTMLElement>,
scrollRef as MutableRefObject<HTMLElement>,
topOffset,
);
setScrollFlag?.(false);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [followRef?.current, scrollRef?.current]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef.current && containerRef.current) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, scrollRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent;
}
@@ -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 {
@@ -42,7 +44,7 @@ export default function useFullscreen() {
}); });
} else if (element.webkitRequestFullscreen) { } else if (element.webkitRequestFullscreen) {
// iOS Safari fullscreen API is supported // iOS Safari fullscreen API is supported
element.webkitRequestFullscreen().catch(() => { element.webkitRequestFullscreen?.().catch(() => {
/* nothing to do */ /* nothing to do */
}); });
} }
@@ -55,7 +57,7 @@ export default function useFullscreen() {
}); });
} else if ((document as WebkitDocument).webkitExitFullscreen) { } else if ((document as WebkitDocument).webkitExitFullscreen) {
// iOS Safari fullscreen API is supported // iOS Safari fullscreen API is supported
(document as WebkitDocument).webkitExitFullscreen().catch(() => { (document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
/* nothing to do */ /* nothing to do */
}); });
} }
+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]);
}
+17 -4
View File
@@ -13,11 +13,21 @@ export const useRundownEditor = () => {
return useRuntimeStore(featureSelector, deepCompare); return useRuntimeStore(featureSelector, deepCompare);
}; };
export const useOperator = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
});
return useRuntimeStore(featureSelector, deepCompare);
};
export const useMessageControl = () => { export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
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,
}); });
@@ -31,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),
@@ -63,14 +75,15 @@ export const setPlayback = {
reload: () => { reload: () => {
socketSendJson('reload'); socketSendJson('reload');
}, },
delay: (amount: number) => { addTime: (amount: number) => {
socketSendJson('delay', amount); socketSendJson('addtime', amount);
}, },
}; };
export const useInfoPanel = () => { export const useInfoPanel = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
titles: state.titles, eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.playback, playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex, selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents, numEvents: state.loaded.numEvents,
@@ -85,7 +98,7 @@ export const useCuesheet = () => {
selectedEventId: state.loaded.selectedEventId, selectedEventId: state.loaded.selectedEventId,
selectedEventIndex: state.loaded.selectedEventIndex, selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents, numEvents: state.loaded.numEvents,
titleNow: state.titles.titleNow, titleNow: state.eventNow?.title || '',
}); });
return useRuntimeStore(featureSelector, deepCompare); return useRuntimeStore(featureSelector, deepCompare);
@@ -1,9 +0,0 @@
import { EventData } from 'ontime-types';
export const eventDataPlaceholder: EventData = {
title: '',
publicUrl: '',
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
};
+11 -24
View File
@@ -1,26 +1,13 @@
export const httpPlaceholder = { import { HttpSettings } from 'ontime-types';
onLoad: {
url: '', export const httpPlaceholder: HttpSettings = {
enabled: false, enabledOut: false,
}, subscriptions: {
onStart: { onLoad: [],
url: '', onStart: [],
enabled: false, onUpdate: [],
}, onPause: [],
onUpdate: { onStop: [],
url: '', onFinish: [],
enabled: false,
},
onPause: {
url: '',
enabled: false,
},
onStop: {
url: '',
enabled: false,
},
onFinish: {
url: '',
enabled: false,
}, },
}; };
+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,
@@ -0,0 +1,10 @@
import { ProjectData } from 'ontime-types';
export const projectDataPlaceholder: ProjectData = {
title: '',
description: '',
publicUrl: '',
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
};
+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
}, },
}, },
}); });
@@ -0,0 +1,67 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type EditorSettings = {
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
defaultPublic: boolean;
showNif: boolean;
};
type EditorSettingsStore = {
eventSettings: EditorSettings;
setLocalEventSettings: (newState: EditorSettings) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setShowNif: (showNif: boolean) => void;
};
enum EditorSettingsKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public',
ShowNif = 'ontime-show-nif',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
eventSettings: {
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
showNif: booleanFromLocalStorage(EditorSettingsKeys.ShowNif, true),
},
setLocalEventSettings: (value) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value };
}),
setShowQuickEntry: (showQuickEntry) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
}),
setDefaultPublic: (defaultPublic) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
setShowNif: (showNif) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowNif, String(showNif));
return { eventSettings: { ...state.eventSettings, showNif } };
}),
}));
@@ -1,57 +0,0 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type EventSettings = {
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
defaultPublic: boolean;
};
type LocalEventStore = {
eventSettings: EventSettings;
setLocalEventSettings: (newState: EventSettings) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
};
enum LocalEventKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public',
}
export const useLocalEvent = create<LocalEventStore>((set) => ({
eventSettings: {
showQuickEntry: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true),
},
setLocalEventSettings: (value) =>
set(() => {
localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
localStorage.setItem(LocalEventKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value };
}),
setShowQuickEntry: (showQuickEntry) =>
set((state) => {
localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
set((state) => {
localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
}),
setDefaultPublic: (defaultPublic) =>
set((state) => {
localStorage.setItem(LocalEventKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
}));
+9 -21
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,
@@ -42,26 +46,10 @@ export const runtimeStorePlaceholder = {
nextEventId: null, nextEventId: null,
nextPublicEventId: null, nextPublicEventId: null,
}, },
titles: { eventNow: null,
titleNow: null, eventNext: null,
subtitleNow: null, publicEventNow: null,
presenterNow: null, publicEventNext: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
},
titlesPublic: {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
},
}; };
export const runtime = createStore<RuntimeStore>(() => ({ export const runtime = createStore<RuntimeStore>(() => ({
@@ -1,4 +1,6 @@
import { validateAlias } from '../aliases'; import { resolvePath } from 'react-router-dom';
import { generateURLFromAlias, getAliasRoute, validateAlias } from '../aliases';
describe('An alias fails if incorrect', () => { describe('An alias fails if incorrect', () => {
const testsToFail = [ const testsToFail = [
@@ -23,3 +25,79 @@ describe('An alias fails if incorrect', () => {
}), }),
); );
}); });
describe('generateURLFromAlias and getAliasRoute function', () => {
test('generate the expected url from an alias', () => {
const testData = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
const expected = [
{
url: '/timer?user=guest&alias=demopage',
},
];
expect(generateURLFromAlias(testData[0])).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL is just the alias', () => {
const aliases = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the alias
const location = resolvePath(aliases[0].alias);
const expected = [
{
url: '/timer?user=guest&alias=demopage',
},
];
expect(getAliasRoute(location, aliases, null)).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL the same url but with a change of params', () => {
const aliases = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(aliases[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', aliases[0].alias); //
// update current alias with extra param
aliases[0].pathAndParams += '&eventId=674';
const expected = [
{
url: '/timer?user=guest&eventId=674&alias=demopage',
},
];
expect(getAliasRoute(location, aliases, urlSearchParams)).toStrictEqual(expected[0].url);
});
test('generate no url to redirect to when the current URL the same url', () => {
const aliases = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(aliases[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', aliases[0].alias); //
expect(getAliasRoute(location, aliases, urlSearchParams)).toBeNull();
});
});
@@ -1,536 +0,0 @@
import { formatEventList, getEventsWithDelay, trimRundown } from '../eventsManager';
describe('getEventsWithDelay function', () => {
test('with positive delays', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
duration: 60000,
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000,
timeEnd: 720000,
colour: '',
type: 'event',
id: '8222',
},
{
duration: 900000,
type: 'delay',
revision: 0,
id: 'a386',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000,
timeEnd: 38520000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000,
timeEnd: 45120000,
colour: '',
type: 'event',
id: '2651',
},
{
type: 'block',
id: 'e6a1',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000 + 60000,
timeEnd: 720000 + 60000,
colour: '',
type: 'event',
id: '8222',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000 + 60000 + 900000,
timeEnd: 38520000 + 60000 + 900000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000 + 60000 + 900000,
timeEnd: 45120000 + 60000 + 900000,
colour: '',
type: 'event',
id: '2651',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
test('with negative delays', () => {
const testData = [
{
duration: -20,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 100,
timeEnd: 200,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 80,
timeEnd: 180,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
});
describe('getEventsWithDelay edge cases', () => {
it('ensures time start cannot be below 0', () => {
const testData = [
{
duration: -200,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 10,
timeEnd: 20,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 0,
timeEnd: 0,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
it('does not modify original array', () => {
const testData = [
{
duration: 10,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 10,
timeEnd: 20,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 20,
timeEnd: 30,
colour: '',
type: 'event',
id: '5946',
},
];
const expectedSafe = [
{
title: 'Welcome to Ontime',
timeStart: 20,
timeEnd: 30,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
expect(getEventsWithDelay(expectedSafe)).toStrictEqual(expected);
});
it('given an empty array', () => {
const emptyArray = {
test: [],
expect: [],
};
expect(getEventsWithDelay(emptyArray.test)).toStrictEqual(emptyArray.expect);
});
it('given an undefined object', () => {
const withUndefined = {
test: undefined,
expect: [],
};
expect(getEventsWithDelay(withUndefined.test)).toStrictEqual(withUndefined.expect);
});
it('given a corrupted event object', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
duration: 60000,
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
it('given a corrupted delay object', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
});
describe('test trimEventlist function', () => {
const limit = 8;
const testData = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
{ id: '10' },
{ id: '11' },
{ id: '12' },
];
it('when we use the first item', () => {
const selectedId = '1';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the third item', () => {
const selectedId = '3';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the fourth item', () => {
const selectedId = '4';
const expected = [
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('if selected is not found', () => {
const selectedId = '15';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
});
describe('test formatEvents function', () => {
const testEvent = [
{
title: 'Welcome to Ontime',
subtitle: 'Subtitles are useful',
presenter: 'cpvalente',
note: 'Maybe a running note for the operator?',
timeStart: 28800000,
timeEnd: 30600000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
subtitle: '',
presenter: '',
note: 'In green, below',
timeStart: 34800000,
timeEnd: 35400000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: '8ee5',
},
];
it('it parses correctly', () => {
const selectedId = 'otherEvent';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles selected correctly', () => {
const selectedId = '5946';
const nextId = '8ee5';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: true,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: true,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles next correctly', () => {
const selectedId = '8ee5';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: true,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
});
@@ -0,0 +1,55 @@
import { EndAction, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
import { cloneEvent } from '../eventsManager';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original = {
id: 'unique',
type: SupportedEvent.Event,
title: 'title',
cue: 'cue',
subtitle: 'subtitle',
presenter: 'presenter',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
endAction: EndAction.None,
isPublic: false,
skip: false,
colour: 'F00',
revision: 10,
user0: 'user0',
user1: 'user1',
user2: 'user2',
user3: 'user3',
user4: 'user4',
user5: 'user5',
user6: 'user6',
user7: 'user7',
user8: 'user8',
user9: 'user9',
} as OntimeEvent;
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
// @ts-expect-error -- safeguarding this
expect(cloned?.id).toBe(undefined);
expect(cloned.title).toBe(original.title);
expect(cloned.subtitle).toBe(original.subtitle);
expect(cloned.presenter).toBe(original.presenter);
expect(cloned.note).toBe(original.note);
expect(cloned.endAction).toBe(original.endAction);
expect(cloned.timerType).toBe(original.timerType);
expect(cloned.timeStart).toBe(original.timeStart);
expect(cloned.timeEnd).toBe(original.timeEnd);
expect(cloned.duration).toBe(original.duration);
expect(cloned.isPublic).toBe(original.isPublic);
expect(cloned.skip).toBe(original.skip);
expect(cloned.colour).toBe(original.colour);
expect(cloned.type).toBe(SupportedEvent.Event);
expect(cloned.revision).toBe(0);
});
});
@@ -1,56 +0,0 @@
import getDelayTo from '../getDelayTo';
describe('getDelayTo function', () => {
it('handles list with delays', () => {
const delayDuration = 100;
const events = [
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
];
const notDelayed = getDelayTo(events, 0);
expect(notDelayed).toBe(0);
const delayedEvent = getDelayTo(events, 2);
expect(delayedEvent).toBe(delayDuration);
});
it('handles list without delays', () => {
const events = [{ type: 'event' }, { type: 'event' }];
const notDelayed = getDelayTo(events, 1);
expect(notDelayed).toBe(0);
});
it('handles list with multiple delays', () => {
const delayDuration = 100;
const events = [
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
];
const doubleDelay = getDelayTo(events, 4);
expect(doubleDelay).toBe(delayDuration * 2);
});
it('handles list with blocks', () => {
const events = [
{ type: 'event' },
{ type: 'delay', duration: 100 },
{ type: 'event' },
{ type: 'block' },
{ type: 'event' },
];
const notDelayed = getDelayTo(events, 4);
expect(notDelayed).toBe(0);
});
it('handles index greater than list', () => {
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
const notDelayed = getDelayTo(events, 3);
expect(notDelayed).toBe(0);
});
it('handles negative index (not found)', () => {
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
const notDelayed = getDelayTo(events, -1);
expect(notDelayed).toBe(0);
});
});
@@ -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));
@@ -1,4 +1,4 @@
import { isIPAddress, isOnlyNumbers } from '../regex'; import { isIPAddress, isOnlyNumbers, startsWithHttp } from '../regex';
describe('simple tests for regex', () => { describe('simple tests for regex', () => {
test('isOnlyNumbers', () => { test('isOnlyNumbers', () => {
@@ -24,4 +24,16 @@ describe('simple tests for regex', () => {
expect(isIPAddress.test(t)).toBe(false); expect(isIPAddress.test(t)).toBe(false);
}); });
}); });
test('startsWithHttp', () => {
const right = ['http://test'];
const wrong = ['https://test', 'testing', '123.0.1'];
right.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(false);
});
});
}); });
@@ -1,4 +1,4 @@
import { cx } from '../styleUtils'; import { cx, getAccessibleColour } from '../styleUtils';
import style from './styleUtils.module.scss'; import style from './styleUtils.module.scss';
@@ -13,3 +13,24 @@ describe('cx()', () => {
expect(merged).toMatchSnapshot(); expect(merged).toMatchSnapshot();
}); });
}); });
describe('getAccessibleColour()', () => {
it('handles named colours', () => {
const colour = 'red';
const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#FF0000FF');
expect(color).toBe('#fffffa');
});
it('handles hex colours', () => {
const colour = '#0F0';
const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#00FF00FF');
expect(color).toBe('black');
});
it('handles transparens', () => {
const colour = '#0F08';
const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#0C940CFF');
expect(color).toBe('#fffffa');
});
});
@@ -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,27 +0,0 @@
import { calculateDuration, DAY_TO_MS } from '../timesManager';
describe('calculateDuration()', () => {
describe('Given start and end values', () => {
it('calculates duration correctly', () => {
const testStart = 1;
const testEnd = 2;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd - testStart);
});
});
describe('Handles edge cases', () => {
it('when start is after end', () => {
const testStart = 3;
const testEnd = 2;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd + DAY_TO_MS - testStart);
});
it('when both are equal', () => {
const testStart = 1;
const testEnd = 1;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd - testStart);
});
});
});
+50 -2
View File
@@ -1,10 +1,13 @@
import isEqual from 'react-fast-compare';
import { Location, resolvePath } from 'react-router-dom';
import { Alias } from 'ontime-types';
/** /**
* Validates an alias against defined parameters * Validates an alias against defined parameters
* @param {string} alias * @param {string} alias
* @returns {{message: string, status: boolean}} * @returns {{message: string, status: boolean}}
*/ */
export const validateAlias = (alias: string) => { export const validateAlias = (alias: string) => {
const valid = { status: true, message: 'ok' }; const valid = { status: true, message: 'ok' };
if (alias === '' || alias == null) { if (alias === '' || alias == null) {
@@ -26,4 +29,49 @@ export const validateAlias = (alias: string) => {
} }
return valid; return valid;
}; };
/**
* Gets the URL to send an alias to
* @param location
* @param data
* @param searchParams
*/
export const getAliasRoute = (location: Location, data: Alias[], searchParams: URLSearchParams) => {
const currentURL = location.pathname.substring(1);
// we need to check if the whole url here is an alias, so we can redirect
const foundAlias = data.filter((d) => d.alias === currentURL && d.enabled)[0];
if (foundAlias) {
return generateURLFromAlias(foundAlias);
}
const aliasOnPage = searchParams.get('alias');
for (const d of data) {
if (aliasOnPage) {
// if the alias fits the alias on this page, but the URL is different, we redirect user to the new URL
// if we have the same alias and its enabled and its not empty
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
const newAliasPath = resolvePath(d.pathAndParams);
const urlParams = new URLSearchParams(newAliasPath.search);
urlParams.set('alias', d.alias);
// we confirm either the url parameters does not match or the url path doesnt
if (!isEqual(urlParams, searchParams) || newAliasPath.pathname !== location.pathname) {
// we then redirect to the alias route, since the view listening to this alias has an outdated URL
return `${newAliasPath.pathname}?${urlParams}`;
}
}
}
}
return null;
};
/**
* Generate URL from an alias
* @param aliasData
*/
export const generateURLFromAlias = (aliasData: Alias) => {
const newAliasPath = resolvePath(aliasData.pathAndParams);
const urlParams = new URLSearchParams(newAliasPath.search);
urlParams.set('alias', aliasData.alias);
return `${newAliasPath.pathname}?${urlParams}`;
};
+12
View File
@@ -0,0 +1,12 @@
export function debounce(callback: () => void, wait: number) {
let timeout: NodeJS.Timeout | null;
return () => {
if (timeout) {
return;
}
timeout = setTimeout(() => {
timeout = null;
callback();
}, wait);
};
}
@@ -0,0 +1,6 @@
export function isMacOS() {
const userAgent = navigator.userAgent.toLowerCase();
return userAgent.includes('macintosh') || userAgent.includes('mac os');
}
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
+13 -155
View File
@@ -1,174 +1,32 @@
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { formatTime } from './time';
/**
* @description From a list of events, returns only events of type event with calculated delays
* @param {Object[]} rundown - given rundown
* @returns {Object[]} Filtered events with calculated delays
*/
export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[] => {
if (rundown == null) return [];
const delayedEvents: OntimeEvent[] = [];
// Add running delay
let delay = 0;
for (const event of rundown) {
if (event.type === SupportedEvent.Block) delay = 0;
else if (event.type === SupportedEvent.Delay) {
if (typeof event.duration === 'number') {
delay += event.duration;
}
} else if (event.type === SupportedEvent.Event) {
const delayedEvent = { ...event };
if (delay !== 0) {
delayedEvent.timeStart = Math.max(delayedEvent.timeStart + delay, 0);
delayedEvent.timeEnd = Math.max(delayedEvent.timeEnd + delay, 0);
}
delayedEvents.push(delayedEvent);
}
}
return delayedEvents;
};
/**
* @description Returns trimmed event list array
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects
*/
export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
if (rundown == null) return [];
const BEFORE = 2;
const trimmedRundown = [...rundown];
// limit events length if necessary
if (limit != null) {
while (trimmedRundown.length > limit) {
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) {
trimmedRundown.pop();
} else {
trimmedRundown.shift();
}
}
}
return trimmedRundown;
};
type FormatEventListOptionsProp = {
showEnd?: boolean;
};
/**
* @description Returns list of events formatted to be displayed
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event
* @param {object} [options]
* @param {boolean} [options.showEnd] - whether to show the end time
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/
export const formatEventList = (
rundown: OntimeEvent[],
selectedId: string,
nextId: string,
options: FormatEventListOptionsProp,
): ScheduleEvent[] => {
if (rundown == null) return [];
const { showEnd = false } = options;
const givenEvents = [...rundown];
// format list
const formattedEvents = [];
for (const event of givenEvents) {
const start = formatTime(event.timeStart);
const end = formatTime(event.timeEnd);
formattedEvents.push({
id: event.id,
time: showEnd ? `${start} - ${end}` : start,
title: event.title,
isNow: event.id === selectedId,
isNext: event.id === nextId,
colour: event.colour,
});
}
return formattedEvents;
};
export type ScheduleEvent = {
id: string;
time: string;
title: string;
isNow: boolean;
isNext: boolean;
colour: string;
};
/** /**
* @description Creates a safe duplicate of an event * @description Creates a safe duplicate of an event
* @param {object} event * @param {OntimeEvent} event
* @return {object} clean event * @param {string} [after]
* @return {OntimeEvent} clean event
*/ */
type ClonedEvent = OntimeEvent | { after?: string }; type ClonedEvent = Omit<
OntimeEvent,
'id' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
>;
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => { export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
return { return {
type: SupportedEvent.Event, type: SupportedEvent.Event,
title: event.title, title: event.title,
cue: event.cue,
subtitle: event.subtitle, subtitle: event.subtitle,
presenter: event.presenter, presenter: event.presenter,
note: event.note, note: event.note,
timeStart: event.timeStart, timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd, timeEnd: event.timeEnd,
timerType: event.timerType,
endAction: event.endAction,
isPublic: event.isPublic, isPublic: event.isPublic,
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
after: after, after: after,
revision: 0,
}; };
}; };
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeEvent | null}
*/
export function getFirstEvent(rundown: OntimeRundownEntry[]) {
return rundown.length ? rundown[0] : null;
}
/**
* Gets next event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index + 1 < rundown.length) {
return rundown[index + 1];
} else {
return null;
}
}
/**
* Gets previous event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index - 1 >= 0) {
return rundown[index - 1];
} else {
return null;
}
}
@@ -0,0 +1,56 @@
import axios from 'axios';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
type FileOptions = {
name: string;
type: string;
};
type BlobOptions = {
type: string;
};
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
const response = await axios({
url: `${url}/db`,
method: 'GET',
});
const headerLine = response.headers['Content-Disposition'];
let { name: fileName } = fileOptions;
const { type: fileType } = fileOptions;
const { project, rundown, userFields } = response.data;
// try and get the filename from the response
if (headerLine != null) {
const startFileNameIndex = headerLine.indexOf('"') + 1;
const endFileNameIndex = headerLine.lastIndexOf('"');
fileName = headerLine.substring(startFileNameIndex, endFileNameIndex);
}
let fileContent = '';
if (fileType === 'json') {
fileContent = JSON.stringify(response.data);
fileName += '.json';
}
if (fileType === 'csv') {
const sheetData = makeTable(project, rundown, userFields);
fileContent = makeCSV(sheetData);
fileName += '.csv';
}
const blob = new Blob([fileContent], { type: blobOptions.type });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', downloadUrl);
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// Clean up the URL.createObjectURL to release resources
URL.revokeObjectURL(downloadUrl);
return;
}
@@ -1,25 +0,0 @@
/**
* @description calculates delay to a given event
* @param {array} events
* @param {number} eventIndex
* @return {number} - delay value of given event
*/
export default function getDelayTo(events, eventIndex) {
let delay = 0;
let index = 0;
if (eventIndex >= 0) {
for (const event of events) {
if (eventIndex === index) {
return delay;
}
if (event.type === 'delay') {
delay += event.duration;
} else if (event.type === 'block') {
delay = 0;
}
index++;
}
}
return 0;
}
+1
View File
@@ -1,2 +1,3 @@
export const isOnlyNumbers = /^\d+$/; export const isOnlyNumbers = /^\d+$/;
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/; export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
export const startsWithHttp = /^http:\/\//;
+3 -15
View File
@@ -1,6 +1,6 @@
import { Log, RuntimeStore } from 'ontime-types'; import { Log, RuntimeStore } from 'ontime-types';
import { RUNTIME, websocketUrl } from '../api/apiConstants'; import { isProduction, RUNTIME, websocketUrl } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { socketClientName } from '../stores/connectionName'; import { socketClientName } from '../stores/connectionName';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
@@ -64,7 +64,7 @@ export const connectSocket = (preferredClientName?: string) => {
} }
case 'ontime': { case 'ontime': {
runtime.setState(payload as RuntimeStore); runtime.setState(payload as RuntimeStore);
if (import.meta.env.DEV) { if (!isProduction) {
ontimeQueryClient.setQueryData(RUNTIME, data.payload); ontimeQueryClient.setQueryData(RUNTIME, data.payload);
} }
break; break;
@@ -87,18 +87,6 @@ export const connectSocket = (preferredClientName?: string) => {
runtime.setState(state); runtime.setState(state);
break; break;
} }
case 'ontime-titles': {
const state = runtime.getState();
state.titles = payload;
runtime.setState(state);
break;
}
case 'ontime-titlesPublic': {
const state = runtime.getState();
state.titlesPublic = payload;
runtime.setState(state);
break;
}
case 'ontime-timerMessage': { case 'ontime-timerMessage': {
const state = runtime.getState(); const state = runtime.getState();
state.timerMessage = payload; state.timerMessage = payload;
@@ -141,7 +129,7 @@ export const socketSend = (message: any) => {
} }
}; };
export const socketSendJson = (type: string, payload?: any) => { export const socketSendJson = (type: string, payload?: unknown) => {
socketSend( socketSend(
JSON.stringify({ JSON.stringify({
type, type,
+10 -8
View File
@@ -3,27 +3,29 @@ import Color from 'color';
type ColourCombination = { type ColourCombination = {
backgroundColor: string; backgroundColor: string;
color: string; color: string;
} };
/** /**
* @description Selects text colour to maintain accessible contrast * @description Selects text colour to maintain accessible contrast
* @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 originalColour = Color(bgColour);
return { backgroundColor: bgColour, color: textColor }; const backgroundColorMix = originalColour.alpha(1).mix(Color('#1a1a1a'), 1 - originalColour.alpha());
} catch (error) { const textColor = backgroundColorMix.isLight() ? 'black' : '#fffffa';
console.log(`Unable to parse colour: ${bgColour}`); return { backgroundColor: backgroundColorMix.hexa(), color: textColor };
} catch (_error) {
/* we do not handle errors here */
} }
} }
return { backgroundColor: '#000', color: "#fffffa" }; return { backgroundColor: '#1a1a1a', color: '#fffffa' };
}; };
/** /**
* @description Creates a list of classnames from array of css module conditions * @description Creates a list of classnames from array of css module conditions
* @param classNames - css modules objects * @param classNames - css modules objects
*/ */
export const cx = (classNames: any[]) => classNames.filter(Boolean).join(" "); export const cx = (classNames: any[]) => classNames.filter(Boolean).join(' ');
+2 -1
View File
@@ -51,6 +51,7 @@ export const formatTime = (milliseconds: number | null, options?: FormatOptions,
return '...'; return '...';
} }
const timeFormat = resolver(); const timeFormat = resolver();
const { showSeconds = false, format: formatString = 'hh:mm a' } = options || {}; const fallback = options?.showSeconds ? 'hh:mm:ss a' : 'hh:mm a';
const { showSeconds = false, format: formatString = fallback } = options || {};
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds); return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
}; };
@@ -10,15 +10,8 @@ export const mts = 1000;
*/ */
export const mtm = 1000 * 60; export const mtm = 1000 * 60;
/** /**
* millis to hours * millis to hours
* @type {number} * @type {number}
*/ */
export const mth = 1000 * 60 * 60; export const mth = 1000 * 60 * 60;
/**
* milliseconds in a day
* @type {number}
*/
export const DAY_TO_MS = 86400000;
@@ -1,55 +1 @@
export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride'; export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride';
/**
* @description Milliseconds in a day
*/
export const DAY_TO_MS = 86400000;
/**
* @description calculates duration from given values
*/
export const calculateDuration = (start: number, end: number): number =>
start > end ? end + DAY_TO_MS - start : end - start;
/**
* @description Checks which field the value relates to
*/
export const handleTimeEntry = (
field: TimeEntryField,
val: number,
timeStart: number,
timeEnd: number,
): { start: number; end: number; durationOverride: boolean } => {
let start = timeStart;
let end = timeEnd;
let durationOverride = false;
if (field === 'timeStart') {
start = val;
} else if (field === 'timeEnd') {
end = val;
} else {
durationOverride = field === 'durationOverride';
}
return { start, end, durationOverride };
};
/**
* @description Validates time entry
*/
export const validateEntry = (
field: TimeEntryField,
value: number,
timeStart: number,
timeEnd: number,
): { value: boolean; warnings: { start?: string; end?: string; duration?: string } } => {
const validate = { value: true, warnings: { start: '', end: '', duration: '' } };
const { start, end } = handleTimeEntry(field, value, timeStart, timeEnd);
if (end < start) {
validate.warnings.start = 'Start time later than end time';
}
return validate;
};
+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>;
} }
} }
+29
View File
@@ -0,0 +1,29 @@
/* eslint-disable react/display-name */
import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import useAliases from '../common/hooks-query/useAliases';
import { getAliasRoute } from '../common/utils/aliases';
const withAlias = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
const { data } = useAliases();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
// navigate if is alias route
useEffect(() => {
if (!data) return;
const url = getAliasRoute(location, data, searchParams);
// navigate to this route if its not empty
if (url) {
navigate(url);
}
}, [data, searchParams, navigate, location]);
return <Component {...(props as P)} />;
};
};
export default withAlias;
@@ -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)}
@@ -86,22 +86,22 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
)} )}
<div className={style.btn}> <div className={style.btn}>
<Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}> <Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-1)} disabled={disableButtons} aspect='square'> <TapButton onClick={() => setPlayback.addTime(-60)} disabled={disableButtons} aspect='square'>
-1 -1
</TapButton> </TapButton>
</Tooltip> </Tooltip>
<Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}> <Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(1)} disabled={disableButtons} aspect='square'> <TapButton onClick={() => setPlayback.addTime(60)} disabled={disableButtons} aspect='square'>
+1 +1
</TapButton> </TapButton>
</Tooltip> </Tooltip>
<Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}> <Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-5)} disabled={disableButtons} aspect='square'> <TapButton onClick={() => setPlayback.addTime(-5 * 60)} disabled={disableButtons} aspect='square'>
-5 -5
</TapButton> </TapButton>
</Tooltip> </Tooltip>
<Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}> <Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(+5)} disabled={disableButtons} aspect='square'> <TapButton onClick={() => setPlayback.addTime(+5 * 60)} disabled={disableButtons} aspect='square'>
+5 +5
</TapButton> </TapButton>
</Tooltip> </Tooltip>

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