Compare commits

...

169 Commits

Author SHA1 Message Date
Carlos Valente 2ef0b81743 refactor: extract midnight cross rules 2026-03-03 20:48:53 +01:00
Carlos Valente 5a5450d7ef refactor: use Day type in client 2026-03-03 20:48:53 +01:00
Carlos Valente 6f1f10ccff fix: backdate rundown start when rolling 2026-03-03 20:48:53 +01:00
Carlos Valente 5ff7861968 fix: use rundown day for calculations 2026-03-03 20:48:53 +01:00
Carlos Valente f1a1c358bc fix: infer day offset from epoch 2026-03-03 20:48:53 +01:00
Carlos Valente efd19353e9 fix: roll resets day 2026-03-03 20:48:53 +01:00
Carlos Valente a29a493678 fix: add current day when ticking over midnight 2026-03-03 20:48:53 +01:00
Carlos Valente 27b179276d fix: skip handles events across midnight 2026-03-03 20:48:53 +01:00
Carlos Valente 155cf48a17 fix: roll loads events that cross midnight 2026-03-03 20:48:53 +01:00
Alex Christoffer Rasmussen 358ad79ae4 Automation should use event store for data (#1962)
* fix: automation should use RuntimeStore for data

* add: aux timer template help
2026-03-02 20:54:46 +01:00
Alex Christoffer Rasmussen c1fcdf7065 Remove serverport from project file (#1957)
* feat: get server port from app satate or env

optional startup port from env will always override

function for parsing port from env

populate default port in app state

add test for migration

* bump version
2026-03-02 06:02:29 -08:00
Carlos Valente 1fe58e21be fix: onStart triggered by pending roll start 2026-02-28 10:32:19 +01:00
Carlos Valente 3c0e7ba4d5 chore: fix import across packages 2026-02-27 20:32:02 +01:00
Carlos Valente e8f159d894 feat: create branded types to determine different handling of time 2026-02-27 20:32:02 +01:00
Carlos Valente 168d7103b1 fix: prevent settings reflow on small width screens 2026-02-23 20:20:07 +01:00
Carlos Valente 63d7250a70 refactor: consistent focus styling 2026-02-23 20:19:49 +01:00
Carlos Valente 88f21e2186 refactor: improve timer styling in playback controls 2026-02-23 20:19:49 +01:00
Carlos Valente d9a18b0c42 fix: path comparison considers core params 2026-02-23 15:23:49 +01:00
Carlos Valente df588b8845 refactor: extract cuesheet permissions 2026-02-23 15:23:49 +01:00
Carlos Valente 5be504ce8f fix: prevent presets losing params 2026-02-23 15:23:49 +01:00
Carlos Valente 6268e327b9 refactor: mask cuesheet presets 2026-02-23 15:23:49 +01:00
Carlos Valente 1c81b3a23c refactor: hide edit button for users with no write permissions 2026-02-23 15:23:49 +01:00
Carlos Valente d28895ce9c refactor: prevent import map collision across stages 2026-02-23 15:22:56 +01:00
Carlos Valente 1a23287863 refactor: osc disabled by default to prevent collisions 2026-02-17 21:23:34 +01:00
Alex Christoffer Rasmussen 0b3ba65df3 feat: allow existing custom fields to keep its type on excel import (#1976) 2026-02-17 07:59:27 +08:00
Carlos Valente c3a7cd6a39 bump version to 4.4.2 2026-02-16 06:58:08 +01:00
Carlos Valente 39e6c15adf fix: hide now and next cards if there is no content 2026-02-16 06:58:08 +01:00
Carlos Valente aec6673b07 refactor: improve connection handling
- only invalidate caches on reconnect
- add jitter to avoid overwhelming server and allow cache to stabilise
- prevent multiple reconnect timers
2026-02-14 12:08:04 +01:00
Carlos Valente 9e8cb1c755 refactor: improve request cancellation and timeout handling 2026-02-14 12:08:04 +01:00
Carlos Valente cdf02f7c3f bump version to 4.4.1 2026-02-14 12:07:45 +01:00
Carlos Valente 77779a4648 refactor: debounce writing to disk 2026-02-14 09:30:24 +01:00
Carlos Valente e16ec3f388 refactor: prevent suspending page on first add 2026-02-14 08:56:16 +01:00
Carlos Valente 63cfc9ec19 refactor: optimistic add elements in UI 2026-02-14 08:56:16 +01:00
Carlos Valente 4827117163 refactor: extract entry creation logic 2026-02-14 08:56:16 +01:00
Carlos Valente 5777ef3532 refactor: extract parent resolution 2026-02-14 08:56:16 +01:00
Carlos Valente 80a3b04493 refactor: export rundown placement logic 2026-02-14 08:56:16 +01:00
Carlos Valente 7c5fdd3b19 refactor: remove unused exports 2026-02-14 08:56:16 +01:00
Carlos Valente 31fd8e5200 fix: prevent infinite render in settings forms 2026-02-13 21:12:29 +01:00
Carlos Valente b6a01a52e2 fix: table re-renders no longer lose cell focus 2026-02-08 17:41:05 +01:00
Carlos Valente f9563fca5f bump to version 4.4.0 2026-02-08 17:40:05 +01:00
Carlos Valente af1da58718 fix: update data in background rundowns 2026-02-08 17:39:53 +01:00
Carlos Valente 0f83ceaf01 fix: maintain lock on param changes 2026-02-08 17:17:25 +01:00
Carlos Valente aecf9575b1 refactor: link to settings section 2026-02-08 11:20:09 +01:00
Carlos Valente 773229ce4c refactor: align inputs with group, refs #1857 2026-02-08 11:20:09 +01:00
Carlos Valente da02ecd113 feat: editor layout 2026-02-08 11:20:09 +01:00
Carlos Valente 967e92e2c8 feat: extend rundown shortcuts 2026-02-08 11:20:09 +01:00
Carlos Valente d7af73d9ed feat: unify rundown features 2026-02-08 11:20:09 +01:00
Carlos Valente c5045ad82e refactor: table mode and context 2026-02-08 11:20:09 +01:00
Carlos Valente 8c22968bc9 fix: tsconfig references for utility tests 2026-02-08 11:20:09 +01:00
Carlos Valente 8aad271ffe refactor: create utility for enums 2026-02-08 11:20:09 +01:00
Carlos Valente b28573455a feat: create reusable scroll area 2026-02-08 11:20:09 +01:00
Carlos Valente e25725ea8b refactor: expose actions through context 2026-02-08 11:20:09 +01:00
Carlos Valente 1f8065143a refactor: improve context menu performance
- extract menu from rundown tree
- lazy create actions
2026-02-08 11:20:09 +01:00
Carlos Valente 8e32314667 refactor: improve performance in rundown
- leverage compiler
- correct subscriptions from zustand
2026-02-08 11:20:09 +01:00
Carlos Valente 0de55e74a8 feat: table mode in editor 2026-02-08 11:20:09 +01:00
Carlos Valente e5c5ec18d3 fix: automation form validation 2026-02-08 11:20:09 +01:00
Carlos Valente f9f6cb74ad chore: update npm link 2026-02-01 18:03:56 +01:00
Alex Christoffer Rasmussen 026e24bf84 Feat: unify view search params (#1947)
* feat: main src in backstage view

* feat: main src in timeline view

* feat: main src in countdown view

* feat: main src in studio view

* feat: hide past events in countdown view

* notes can not be set as main src

* feat: show group title as secondary src

* chore: spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-01-27 20:42:07 +01:00
Joel Wetzell ec1890c275 update docker actions and pin to commit hash 2026-01-26 14:10:09 +01:00
Joel Wetzell 046f1176eb add docker metadata action to setup tags 2026-01-26 14:10:09 +01:00
Alex Christoffer Rasmussen 04c5b67475 fix: missing demo logo (#1954) 2026-01-26 13:52:29 +01:00
google-labs-jules[bot] 7b5f797c95 refactor: move welcome modal logic to AppStateService
The logic for deciding whether to show the welcome modal has been moved from `app.ts` to `AppStateService.ts`. The `getShowWelcomeDialog` function now accepts a boolean indicating whether a restore point exists and returns `false` if it does. This keeps the business logic out of the main application file and in the relevant service.
2026-01-24 07:45:56 +01:00
google-labs-jules[bot] 14f9472250 fix: hide welcome modal on restore
The welcome modal is now hidden if the application is started from a restore point.
2026-01-24 07:45:56 +01:00
Alex Christoffer Rasmussen efede83271 Fix: automation (#1943)
* Fix(automation): Handles undefined values in conditions (#1933)

* Fix(automation): Handles undefined values in conditions

Addresses an issue where conditions with the 'not_equals' operator incorrectly evaluated undefined values. This change ensures that empty string comparisons correctly identify missing values in automation rules.

* Fixes automation "not equals" logic

Simplifies the 'not_equals' condition evaluation in automations by reusing the 'equals' condition, improving code readability and consistency.

Fixes #1932

* Test(automation): Expanding filter condition testing

Expanding test cases to cover various data types and edge case for each operators.
Unexpected behavior have been mark with a TO_DO.

* Fix(automation): Handles default filter case

- Ensures that their is a default scenario.
- Fixing Deepsource issue "No default cases in switch statements JS-0047"

* Test(automation): Remove abstraction in test

* move to test.each

* extract a isEquivalent function

* lowercase contains test

---------

Co-authored-by: Philippe Allard-Rousse <philrousse@gmail.com>
2026-01-21 16:07:21 +01:00
Carlos Valente 904db95f59 docs: update linux links 2026-01-16 07:52:56 +01:00
Carlos Valente 525999440a fix: inline elements should not wrap by default 2026-01-14 17:12:03 +01:00
Carlos Valente 6e5805977a refactor: hide logo on error 2026-01-11 17:43:53 +01:00
Carlos Valente eb897a2a23 refactor: set background colour from content 2026-01-11 17:43:53 +01:00
Carlos Valente 05d997a52d bump version to 4.3.1 2026-01-11 17:43:23 +01:00
Carlos Valente f8cde5d627 fix: restore process sets currentDay 2026-01-10 19:23:49 +01:00
Alex Christoffer Rasmussen 1b55254834 fix: Network Interface overflow (#1935) 2026-01-05 21:05:45 +01:00
Alex Christoffer Rasmussen 6436e3fda8 refactor: extract rundown switching to a single function in rundown.service (#1930) 2026-01-05 20:47:04 +01:00
Alex Christoffer Rasmussen 680273770c fix: crash-log not written on windows and add log entries to crash report
* fix: crashlog not written on windows

* feat: dump last 100 log entries to crash report
2026-01-01 14:59:21 +01:00
Carlos Valente 75e77de466 fix: prevent infinite loop in project data 2025-12-26 08:42:53 +01:00
Carlos Valente 75cbcd8e85 bump version to 4.3.0 2025-12-22 16:57:52 +01:00
Carlos Valente 701f24cece fix: automation form validation 2025-12-22 16:57:52 +01:00
Carlos Valente 166160dda9 chore: upgrade client dependencies 2025-12-22 16:57:52 +01:00
Carlos Valente 9c5116f197 refactor: handle prefix on login
- post-login redirect should account for prefix
- scope cookies to each path by prefix
2025-12-22 15:59:38 +01:00
Alex Christoffer Rasmussen deb1f043c7 fix: set not initialised custom field from api (#1926) 2025-12-22 15:27:06 +01:00
Carlos Valente cbba19a9b1 fix: user can quick start project from partial data 2025-12-21 20:25:14 +01:00
Carlos Valente a5daa76616 refactor: improve merge logic 2025-12-21 20:25:14 +01:00
Carlos Valente 08b8e73393 refactor: finish view directory migration 2025-12-21 13:32:12 +01:00
Carlos Valente 2f3cf9825a refactor(css): migrate use of deprecated import in messagecontrol 2025-12-21 13:32:12 +01:00
Carlos Valente b0f02e726d refactor(css): migrate use of deprecated import in cuesheet 2025-12-21 13:32:12 +01:00
Carlos Valente ac40292813 refactor(css): migrate use of deprecated import in operator 2025-12-21 13:32:12 +01:00
Carlos Valente 2ac00ee8cc refactor(css): migrate use of deprecated import in buttons 2025-12-21 13:32:12 +01:00
Carlos Valente 972f9e35fd refactor: create separation between editor wrapper and contents 2025-12-21 13:32:12 +01:00
Carlos Valente 1d7ee163a0 Revert "feat: display offline indicators in all views"
This reverts commit 71b2fa8e21.
2025-12-20 15:25:15 +01:00
Carlos Valente 1054ec0a5e refactor: expose missing types 2025-12-20 14:18:07 +01:00
Carlos Valente fc2e76db0a refactor: stabilise filtering function 2025-12-19 09:54:19 +01:00
Carlos Valente ef3d541eb3 refactor: simplify element lookup 2025-12-19 09:54:19 +01:00
Carlos Valente 8daf3313f2 refactor: simplify flat rundown 2025-12-19 09:54:19 +01:00
Carlos Valente 93c913ec5a refactor: remove placeholderData pattern 2025-12-19 09:54:19 +01:00
Carlos Valente 71b2fa8e21 feat: display offline indicators in all views 2025-12-19 08:51:54 +01:00
Carlos Valente f2b78e0f32 refactor: generate ids for new rundowns 2025-12-18 14:42:01 +01:00
Carlos Valente b70ef80320 refactor: patch does not load rundown 2025-12-18 14:42:01 +01:00
Carlos Valente 426d42ab63 refactor: add guards around trivial index checks 2025-12-18 14:42:01 +01:00
Carlos Valente 242cefd35c tooling: lax typechecking in tests 2025-12-18 14:42:01 +01:00
arc-alex 6beda1575a chore: marke change api call async 2025-12-16 21:56:39 +01:00
arc-alex ba75d52d29 chore: marking a functions as throwing 2025-12-16 21:56:39 +01:00
arc-alex 35a4361ae9 fix: expectedFinish flashing incorrect value when adding timer in overtime 2025-12-16 21:56:39 +01:00
arc-alex 77225383a4 fix: block uploading already existing file name 2025-12-16 21:56:39 +01:00
arc-alex f0e81c0cf0 fix: stop timer on rundown switch 2025-12-16 21:56:39 +01:00
Joel Wetzell 9355d7bf27 feat: add arm architectures to linux target 2025-12-15 20:03:57 +01:00
Carlos Valente cd39227af7 fix: prevent unsupported wakelock 2025-12-09 19:13:36 +01:00
Carlos Valente 14e21214af fix: subdue paused only for running timers 2025-12-09 19:13:36 +01:00
Carlos Valente 03184b0321 fix: prevent accessing undefined index 2025-12-09 19:13:36 +01:00
Carlos Valente cd5af769ab fix: undefined flatRundown 2025-12-09 19:13:36 +01:00
Carlos Valente d116670318 fix: safe copy to clipboard 2025-12-09 19:13:36 +01:00
Carlos Valente 9ba5ebd4bd bump version to 4.2.1 (#1896) 2025-11-28 16:26:53 +01:00
Carlos Valente 3f1f06f7c5 Improve Clone (#1897)
* refactor(rundown): optimise copy-paste performance

* chore: configure opt-in compiler

* refactor(rundown): stabilise frequently accessed data

* feat(clone): allow cloning any element

* remove paste above and cue increment from test

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-11-28 16:20:52 +01:00
arc-alex a78586d2fa allow count up to show negative value 2025-11-28 15:03:28 +01:00
arc-alex 8e93b0af25 fix: countToEnd should not override display options 2025-11-28 15:03:28 +01:00
Carlos Valente 9a5d50d933 fix(wakelock): check for feature support 2025-11-28 11:26:23 +01:00
Carlos Valente 1189a94bff refactor(wakelock): simplify logic into hook 2025-11-28 11:26:23 +01:00
Alex Christoffer Rasmussen f128f7acd2 fix: api change custom fields (#1888)
* fix: validate nested custom path

* chore: add test
2025-11-27 09:15:02 +01:00
Carlos Valente 80ec2d1186 refactor(pip): match timer formatting defaults 2025-11-26 20:10:01 +01:00
Carlos Valente 18e6f0d7c4 refactor(timer): allow multiline secondary text 2025-11-26 20:10:01 +01:00
Alex Christoffer Rasmussen 48f2511764 fix: custom fields not persisted (#1891) 2025-11-26 17:58:00 +01:00
Carlos Valente c20ea88021 docs: add link to subreddit 2025-11-20 18:44:23 +01:00
Carlos Valente ce2b2b9606 bump version to 4.2.0 2025-11-20 18:44:23 +01:00
Alex Christoffer Rasmussen f223766445 feat: pip for timer preview (#1862)
---------

Co-authored-by: Shobhit Nagpal <74096450+Shobhit-Nagpal@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-11-20 18:00:16 +01:00
Carlos Valente 3dfb49e63f refactor(overview): prevent vertical scrollbar 2025-11-20 17:58:53 +01:00
Carlos Valente a13d3dad93 refactor(op): style tweaks to tags and subscriptions 2025-11-20 17:58:53 +01:00
Carlos Valente fa9695873a refactor(timeline): style tweaks on status tags 2025-11-20 17:58:53 +01:00
Carlos Valente 70ce1d5553 feat(projects): allow sorting project table 2025-11-19 06:52:58 +01:00
Shobhit Nagpal 9974ce24cd refactor(operator): hide groups where all events have passed (#1878) 2025-11-18 06:12:02 +01:00
Carlos Valente 93e9c19e0d refactor(input): consistent placeholder colours 2025-11-16 07:44:49 +01:00
Carlos Valente 1694cca92c refactor(settings): improve error message on version fetch 2025-11-16 07:44:49 +01:00
Carlos Valente c1d9276dd5 refactor(timeline): style tweaks to small screens 2025-11-16 07:44:49 +01:00
Carlos Valente f5753a0f5a refactor(timeline): default to fixed size timeline 2025-11-16 07:44:49 +01:00
Carlos Valente 843f5a968d refactor(operator): style tweaks for small screens 2025-11-16 07:44:49 +01:00
Carlos Valente e09b6bbc16 refactor: close menu in small screen navigation 2025-11-16 07:44:49 +01:00
Carlos Valente 19d6b40bdb refactor: hide fullscreen button if not feature available 2025-11-16 07:44:49 +01:00
Carlos Valente 1f2706a063 refactor(operator): improvements to editing 2025-11-16 07:44:49 +01:00
Carlos Valente aaeadd13cf chore(operator): clarify edit prompt 2025-11-16 07:44:49 +01:00
Carlos Valente 72e3eb11d7 chore: upgrade mantine dependencies 2025-11-16 07:44:49 +01:00
Alex Christoffer Rasmussen 6f55d8a6c9 use data in <base> componenet to mark ontime cloud status (#1872) 2025-11-12 19:28:29 +08:00
Shobhit Nagpal 70846aefbe refactor: time input labels on hover (#1855) 2025-11-11 06:42:55 +01:00
Carlos Valente 47ba90f191 refactor: ensure preset names are url safe 2025-11-11 06:41:48 +01:00
Alex Christoffer Rasmussen cee3c9c070 Fix integration api (#1866)
* allow change api to set flag and countToEnd

* fix: custom fields can be uppercase

* check that prop exists and add target duration for group
2025-11-09 14:36:13 +01:00
Carlos Valente 15f3b9625c refactor: review overview styles 2025-11-09 06:44:24 +01:00
Carlos Valente d34a1f0fd8 chore: improve documentation on the 12 hour view 2025-11-09 06:44:24 +01:00
Carlos Valente 0e3b3bcf9a refactor: consistent time formatting for 12h mode 2025-11-09 06:44:24 +01:00
Carlos Valente 50c91f976b refactor: show welcome modal on connect 2025-11-02 18:58:13 +01:00
Carlos Valente 50599eccd2 refactor: show countdown if less than 10 min from start 2025-11-02 18:53:03 +01:00
Carlos Valente 9f2db10548 fix: prevent rounding error show 60s 2025-11-02 18:53:03 +01:00
Carlos Valente 5dce1a40bc refactor: prevent ids from being overriden 2025-11-02 13:27:53 +01:00
Carlos Valente b75b69c3b1 chore: cleanup directory structure 2025-11-02 13:23:05 +01:00
Carlos Valente e6070e6efd refactor: add rundown summary to import preview 2025-11-02 13:23:05 +01:00
Carlos Valente 3918664945 fix: ensure IDs on import from excel 2025-11-02 13:23:05 +01:00
Carlos Valente b65b3fa6d2 refactor: get worksheet names from excel upload 2025-11-02 13:23:05 +01:00
Carlos Valente 162693e19e refactor: allow user to review import map 2025-11-02 13:23:05 +01:00
Carlos Valente becd734ea8 refactor: simplify ux for sheet import 2025-11-02 13:23:05 +01:00
Carlos Valente 9dfc8083f0 style: run code formatting 2025-10-31 19:30:34 +01:00
Carlos Valente 7bd98c23a4 bump version to 4.1.0 2025-10-31 19:30:34 +01:00
arc-alex 6f19d78e53 addedTimer always updates the restore point 2025-10-31 19:26:30 +01:00
arc-alex 163baa752f keep restorpoint on container shutdown 2025-10-31 19:26:30 +01:00
mdavies1 6f708df6f6 feat: lock extracted rundown panels 2025-10-31 19:23:08 +01:00
Carlos Valente eb5c62cca1 chore: simplify prettier setup 2025-10-30 17:26:49 +01:00
Carlos Valente 99d2debca8 typo: incorrect title in delete confirmation 2025-10-30 17:26:49 +01:00
Carlos Valente 74e59dccdb feat: allow renaming rundown 2025-10-30 17:26:49 +01:00
Carlos Valente 812b17a221 refactor: create destructive menu type 2025-10-30 17:26:49 +01:00
Carlos Valente 32bf0f53f6 feat: allow user to duplicate a rundown 2025-10-30 17:26:49 +01:00
Carlos Valente 17f80baf48 chore: improve rundown API documentation 2025-10-30 17:26:49 +01:00
Carlos Valente e0377a7244 feat: implement simple healthcheck endpoint 2025-10-30 17:26:18 +01:00
Alex Christoffer Rasmussen b7e5723fb2 Fix multiday (#1850)
* chore: cleanup typeings

* fix: total gap should only accumulate after the curent loaded

* fix: add the current day of the event to start day offset
2025-10-30 14:22:14 +01:00
Alex Christoffer Rasmussen 897210d5e3 add timer type for css override selector (#1845) 2025-10-30 13:15:14 +01:00
Alex Christoffer Rasmussen ee3d38fce8 Fix link to host in local network (#1843)
* descripe testing for cloud

* use base url directly

* lint
2025-10-27 11:19:21 +01:00
461 changed files with 12291 additions and 4176 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"jest": true "jest": true
}, },
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"], "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"plugins": ["@typescript-eslint", "prettier"], "plugins": ["@typescript-eslint", "prettier"],
"overrides": [ "overrides": [
{ {
+15 -16
View File
@@ -37,32 +37,31 @@ jobs:
run: pnpm build:docker run: pnpm build:docker
- name: Docker Login - name: Docker Login
uses: docker/login-action@v2.1.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx - name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Setup Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/ontime
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=raw,value=nightly,enable=${{ github.event.release.prerelease == true }}
- name: Build and push stable release - name: Build and push stable release
if: github.event.release.prerelease == false uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v4.0.0
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry # Push is a shorthand for --output=type=registry
push: true push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- 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
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+4 -1
View File
@@ -103,6 +103,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-linux.AppImage' files: |
./apps/electron/dist/ontime-linux-x86_64.AppImage
./apps/electron/dist/ontime-linux-arm64.AppImage
./apps/electron/dist/ontime-linux-armv7l.AppImage
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -40,6 +40,7 @@ override.css
# working stuff # working stuff
**/TODO.md **/TODO.md
**.local.**
# docker utils # docker utils
ontime-db ontime-db
+6 -4
View File
@@ -10,13 +10,15 @@
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a> <a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a> - Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a> - Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">macOS (Apple Silicon)</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a> - Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">macOS (Intel)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a> - Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-x86_64.AppImage">Linux (Intel / AMD 64-bit)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-arm64.AppImage">Linux (ARM 64-bit, Raspberry Pi 4+)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-armv7l.AppImage">Linux (ARM 32-bit, older Raspberry Pi)</a>
... or ... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a> - Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a> - Install from <a href="https://www.npmjs.com/package/@getontime/cli">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a> - Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help? ## Need help?
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.0.2", "version": "4.5.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+9 -9
View File
@@ -1,15 +1,15 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.0.2", "version": "4.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui-components/react": "1.0.0-beta.4", "@base-ui/react": "1.0.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.6", "@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.2.8", "@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.2.0", "@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.85.9", "@tanstack/react-query": "^5.85.9",
@@ -17,21 +17,20 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.12.2", "axios": "^1.12.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0", "csv-stringify": "^6.6.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^19.1.1", "react": "^19.2.3",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^19.1.1", "react-dom": "^19.2.3",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"react-icons": "5.5.0", "react-icons": "5.5.0",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
"react-router": "^7.8.2", "react-router": "^7.11.0",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.14.0", "react-virtuoso": "^4.17.0",
"web-vitals": "^5.1.0", "web-vitals": "^5.1.0",
"zustand": "^5.0.8" "zustand": "^5.0.9"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -66,6 +65,7 @@
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1", "@vitejs/plugin-react": "4.5.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
+4 -1
View File
@@ -1,8 +1,11 @@
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router';
import { Tooltip } from '@base-ui-components/react/tooltip'; import { Tooltip } from '@base-ui/react/tooltip';
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';
// apply global axios config defaults
import './common/api/axios.config';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay'; import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
import { AppContextProvider } from './common/context/AppContext'; import { AppContextProvider } from './common/context/AppContext';
+5 -4
View File
@@ -4,14 +4,15 @@ import { TranslationObject } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants'; import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
import type { RequestOptions } from './requestOptions';
const assetsPath = `${apiEntryUrl}/assets`; const assetsPath = `${apiEntryUrl}/assets`;
/** /**
* HTTP request to get css contents * HTTP request to get css contents
*/ */
export async function getCSSContents(): Promise<string> { export async function getCSSContents(options?: RequestOptions): Promise<string> {
const res = await axios.get(`${assetsPath}/css`); const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal });
return res.data; return res.data;
} }
@@ -35,8 +36,8 @@ export async function restoreCSSContents(): Promise<string> {
/** /**
* HTTP request to get user translation * HTTP request to get user translation
*/ */
export async function getUserTranslation(): Promise<TranslationObject> { export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL); const res = await axios.get(customTranslationsURL, { signal: options?.signal });
return res.data; return res.data;
} }
+3 -2
View File
@@ -9,14 +9,15 @@ import type {
} from 'ontime-types'; } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const automationsPath = `${apiEntryUrl}/automations`; const automationsPath = `${apiEntryUrl}/automations`;
/** /**
* HTTP request to get the automations settings * HTTP request to get the automations settings
*/ */
export async function getAutomationSettings(): Promise<AutomationSettings> { export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> {
const res = await axios.get(automationsPath); const res = await axios.get(automationsPath, { signal: options?.signal });
return res.data; return res.data;
} }
+4 -1
View File
@@ -1,5 +1,8 @@
import axios from 'axios'; import axios from 'axios';
import { axiosConfig } from './requestTimeouts';
axios.defaults.validateStatus = (status) => { axios.defaults.validateStatus = (status) => {
return (status >= 200 && status < 300) || status === 304; return status >= 200 && status < 300;
}; };
axios.defaults.timeout = axiosConfig.shortTimeout;
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types'; import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const customFieldsPath = `${apiEntryUrl}/custom-fields`; const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/** /**
* Requests list of known custom fields * Requests list of known custom fields
*/ */
export async function getCustomFields(): Promise<CustomFields> { export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> {
const res = await axios.get(customFieldsPath); const res = await axios.get(customFieldsPath, { signal: options?.signal });
return res.data; return res.data;
} }
+9 -3
View File
@@ -2,6 +2,8 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
const dbPath = `${apiEntryUrl}/db`; const dbPath = `${apiEntryUrl}/db`;
@@ -10,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB * HTTP request to the current DB
*/ */
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> { export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download`, { filename }); return axios.post(`${dbPath}/download`, { filename }, { timeout: axiosConfig.longTimeout });
} }
/** /**
@@ -37,6 +39,7 @@ export async function uploadProjectFile(file: File): Promise<MessageResponse> {
const formData = new FormData(); const formData = new FormData();
formData.append('project', file); formData.append('project', file);
const response = await axios.post(`${dbPath}/upload`, formData, { const response = await axios.post(`${dbPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
@@ -76,8 +79,11 @@ export async function quickProject(data: QuickStartData): Promise<MessageRespons
/** /**
* HTTP request to get the list of available project files * HTTP request to get the list of available project files
*/ */
export async function getProjects(): Promise<ProjectFileListResponse> { export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`); const res = await axios.get(`${dbPath}/all`, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data; return res.data;
} }
+25 -16
View File
@@ -1,8 +1,10 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown } from 'ontime-types'; import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { downloadBlob } from './utils'; import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`; const excelPath = `${apiEntryUrl}/excel`;
@@ -11,42 +13,49 @@ const excelPath = `${apiEntryUrl}/excel`;
* upload Excel file to server * upload Excel file to server
* @return string - file ID op the uploaded file * @return string - file ID op the uploaded file
*/ */
export async function upload(file: File) { export async function upload(file: File, requestOptions?: RequestOptions): Promise<string[]> {
const formData = new FormData(); const formData = new FormData();
formData.append('excel', file); formData.append('excel', file);
await axios.post(`${excelPath}/upload`, formData, { const response = await axios.post(`${excelPath}/upload`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}); });
}
/**
* Get Worksheet names
* @return string[] - array of available worksheets
*/
export async function getWorksheetNames(): Promise<string[]> {
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
return response.data; return response.data;
} }
type PreviewSpreadsheetResponse = { type PreviewSpreadsheetResponse = {
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary;
}; };
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> { export async function importRundownPreview(
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, { options: ImportMap,
options, requestOptions?: RequestOptions,
}); ): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
`${excelPath}/preview`,
{
options,
},
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data; return response.data;
} }
/** /**
* Downloads a xlsx representation of the rundown from the server * Downloads a xlsx representation of the rundown from the server
*/ */
export async function downloadAsExcel(rundownId: string, fileName?: string) { export async function downloadAsExcel(rundownId: string, fileName?: string, requestOptions?: RequestOptions) {
try { try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, { const response = await axios.get(`${excelPath}/${rundownId}/export`, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
responseType: 'blob', responseType: 'blob',
}); });
+4 -2
View File
@@ -2,6 +2,8 @@ import axios from 'axios';
import { apiRepoLatest } from '../../externals'; import { apiRepoLatest } from '../../externals';
import type { RequestOptions } from './requestOptions';
export type HasUpdate = { export type HasUpdate = {
url: string; url: string;
version: string; version: string;
@@ -10,8 +12,8 @@ export type HasUpdate = {
/** /**
* HTTP request to get the latest version and url from github * HTTP request to get the latest version and url from github
*/ */
export async function getLatestVersion(): Promise<HasUpdate> { export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest); const res = await axios.get(apiRepoLatest, { signal: options?.signal });
return { return {
url: res.data.html_url as string, url: res.data.html_url as string,
version: res.data.tag_name as string, version: res.data.tag_name as string,
+8 -2
View File
@@ -2,14 +2,19 @@ import axios, { AxiosResponse } from 'axios';
import { ProjectData, ProjectLogoResponse } from 'ontime-types'; import { ProjectData, ProjectLogoResponse } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const projectPath = `${apiEntryUrl}/project`; const projectPath = `${apiEntryUrl}/project`;
/** /**
* HTTP request to fetch project data * HTTP request to fetch project data
*/ */
export async function getProjectData(): Promise<ProjectData> { export async function getProjectData(options?: RequestOptions): Promise<ProjectData> {
const res = await axios.get(projectPath); const res = await axios.get(projectPath, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data; return res.data;
} }
@@ -27,6 +32,7 @@ export async function uploadProjectLogo(file: File): Promise<AxiosResponse<Proje
const formData = new FormData(); const formData = new FormData();
formData.append('image', file); formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, { const response = await axios.post(`${projectPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
+3 -2
View File
@@ -4,14 +4,15 @@ import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants'; import { apiEntryUrl, REPORT } from './constants';
import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`; export const reportUrl = `${apiEntryUrl}/report`;
/** /**
* HTTP request to fetch all reports * HTTP request to fetch all reports
*/ */
export async function fetchReport(): Promise<OntimeReport> { export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
const res = await axios.get(reportUrl); const res = await axios.get(reportUrl, { signal: options?.signal });
return res.data; return res.data;
} }
@@ -0,0 +1,4 @@
export type RequestOptions = {
signal?: AbortSignal;
timeout?: number;
};
@@ -0,0 +1,8 @@
/**
* Keep a short global timeout for regular API requests, but allow
* longer windows for file transfer and heavy import/export operations.
*/
export const axiosConfig = {
shortTimeout: 20 * 1000, // 20 seconds
longTimeout: 3 * 60 * 1000, // 3 minutes
} as const;
+63 -20
View File
@@ -2,7 +2,9 @@ import axios, { AxiosResponse } from 'axios';
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types'; import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`; const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns ========================= // #region operations on project rundowns =========================
@@ -10,24 +12,40 @@ const rundownPath = `${apiEntryUrl}/rundowns`;
/** /**
* HTTP request to fetch a list of existing rundowns * HTTP request to fetch a list of existing rundowns
*/ */
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> { export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath); const res = await axios.get(rundownPath, { signal: options?.signal });
return res.data; return res.data;
} }
/** /**
* HTTP request to fetch all entries in the currently loaded rundown * HTTP request to fetch all entries in the currently loaded rundown
*/ */
export async function fetchCurrentRundown(): Promise<Rundown> { export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`); const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal });
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
return res.data; return res.data;
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
} }
/** /**
* HTTP request to switch the currently loaded rundown * HTTP request to switch the currently loaded rundown
*/ */
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> { export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${id}/load`); return axios.post(`${rundownPath}/${rundownId}/load`);
} }
/** /**
@@ -37,11 +55,25 @@ export async function createRundown(title: string): Promise<AxiosResponse<Projec
return axios.post(rundownPath, { title }); return axios.post(rundownPath, { title });
} }
/**
* HTTP request to duplicate an existing rundown
*/
export async function duplicateRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/duplicate`);
}
/**
* HTTP request to rename an existing rundown
*/
export async function renameRundown(rundownId: RundownId, title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.patch(`${rundownPath}/${rundownId}`, { title });
}
/** /**
* HTTP request to delete a rundown * HTTP request to delete a rundown
*/ */
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> { export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${id}`); return axios.delete(`${rundownPath}/${rundownId}`);
} }
// #endregion operations on project rundowns ====================== // #endregion operations on project rundowns ======================
@@ -51,7 +83,7 @@ export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRu
* HTTP request to post new entry * HTTP request to post new entry
*/ */
export async function postAddEntry( export async function postAddEntry(
rundownId: string, rundownId: RundownId,
data: TransientEventPayload, data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> { ): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data); return axios.post(`${rundownPath}/${rundownId}/entry`, data);
@@ -60,7 +92,10 @@ export async function postAddEntry(
/** /**
* HTTP request to edit an entry * HTTP request to edit an entry
*/ */
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> { export async function putEditEntry(
rundownId: RundownId,
data: Partial<OntimeEntry>,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data); return axios.put(`${rundownPath}/${rundownId}/entry`, data);
} }
@@ -72,7 +107,7 @@ export type BatchEditEntry = {
/** /**
* HTTP request to edit multiple events * HTTP request to edit multiple events
*/ */
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> { export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/${rundownId}/batch`, data); return axios.put(`${rundownPath}/${rundownId}/batch`, data);
} }
@@ -85,56 +120,64 @@ export type ReorderEntry = {
/** /**
* HTTP request to reorder an entry * HTTP request to reorder an entry
*/ */
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> { export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data); return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
} }
/** /**
* HTTP request to swap two events * HTTP request to swap two events
*/ */
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestEventSwap(
rundownId: RundownId,
from: EntryId,
to: EntryId,
): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to }); return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
} }
/** /**
* HTTP request to request application of delay * HTTP request to request application of delay
*/ */
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`); return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
} }
/** /**
* HTTP request for cloning an entry * HTTP request for cloning an entry
*/ */
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> { export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`); rundownId: RundownId,
entryId: EntryId,
options?: { before?: EntryId; after?: EntryId },
): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
} }
/** /**
* HTTP request for grouping a list of entries into a group * HTTP request for grouping a list of entries into a group
*/ */
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function requestGroupEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds }); return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
} }
/** /**
* HTTP request for dissolving of a group * HTTP request for dissolving of a group
*/ */
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestUngroup(rundownId: RundownId, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`); return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
} }
/** /**
* HTTP request to delete entries of a given rundown * HTTP request to delete entries of a given rundown
*/ */
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function deleteEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } }); return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
} }
/** /**
* HTTP request to delete all entries of a given rundown * HTTP request to delete all entries of a given rundown
*/ */
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> { export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/all`); return axios.delete(`${rundownPath}/${rundownId}/all`);
} }
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types'; import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`; const sessionPath = `${apiEntryUrl}/session`;
/** /**
* HTTP request to retrieve application info * HTTP request to retrieve application info
*/ */
export async function getInfo(): Promise<GetInfo> { export async function getInfo(options?: RequestOptions): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`); const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal });
return res.data; return res.data;
} }
+19 -3
View File
@@ -1,15 +1,16 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { Settings } from 'ontime-types'; import { PortInfo, Settings } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const settingsPath = `${apiEntryUrl}/settings`; const settingsPath = `${apiEntryUrl}/settings`;
/** /**
* HTTP request to retrieve application settings * HTTP request to retrieve application settings
*/ */
export async function getSettings(): Promise<Settings> { export async function getSettings(options?: RequestOptions): Promise<Settings> {
const res = await axios.get(settingsPath); const res = await axios.get(settingsPath, { signal: options?.signal });
return res.data; return res.data;
} }
@@ -26,3 +27,18 @@ export async function postSettings(data: Settings): Promise<AxiosResponse<Settin
export async function postShowWelcomeDialog(show: boolean) { export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show }); axios.post(`${settingsPath}/welcomedialog`, { show });
} }
/**
* HTTP request to retrieve server port
*/
export async function getServerPort(): Promise<PortInfo> {
const res = await axios.get(`${settingsPath}/serverport`);
return res.data;
}
/**
* HTTP request to set server port
*/
export async function postServerPort(serverPort: number): Promise<AxiosResponse<PortInfo>> {
return axios.post(`${settingsPath}/serverport`, { serverPort });
}
+38 -6
View File
@@ -1,8 +1,10 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const sheetsPath = `${apiEntryUrl}/sheets`; const sheetsPath = `${apiEntryUrl}/sheets`;
@@ -23,6 +25,7 @@ export const verifyAuthenticationStatus = async (): Promise<{
export const requestConnection = async ( export const requestConnection = async (
file: File, file: File,
sheetId: string, sheetId: string,
requestOptions?: RequestOptions,
): Promise<{ ): Promise<{
verification_url: string; verification_url: string;
user_code: string; user_code: string;
@@ -31,6 +34,8 @@ export const requestConnection = async (
formData.append('client_secret', file); formData.append('client_secret', file);
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, { const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
@@ -53,23 +58,50 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic
export const previewRundown = async ( export const previewRundown = async (
sheetId: string, sheetId: string,
options: ImportMap, options: ImportMap,
requestOptions?: RequestOptions,
): Promise<{ ): Promise<{
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary;
}> => { }> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); const response = await axios.post(
`${sheetsPath}/${sheetId}/read`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data; return response.data;
}; };
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => { export const getWorksheetNames = async (sheetId: string, requestOptions?: RequestOptions): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`); const response: AxiosResponse<string[]> = await axios.post(
`${sheetsPath}/${sheetId}/worksheets`,
undefined,
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data; return response.data;
}; };
/** /**
* HTTP request to upload the rundown to a google sheet * HTTP request to upload the rundown to a google sheet
*/ */
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => { export const uploadRundown = async (
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options }); sheetId: string,
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<void> => {
const response = await axios.post(
`${sheetsPath}/${sheetId}/write`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data; return response.data;
}; };
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { URLPreset } from 'ontime-types'; import { URLPreset } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const urlPresetsPath = `${apiEntryUrl}/url-presets`; const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/** /**
* HTTP request to retrieve all presets * HTTP request to retrieve all presets
*/ */
export async function getUrlPresets(): Promise<URLPreset[]> { export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath); const res = await axios.get(urlPresetsPath, { signal: options?.signal });
return res.data; return res.data;
} }
+3 -2
View File
@@ -2,6 +2,7 @@ import axios from 'axios';
import type { ViewSettings } from 'ontime-types'; import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const viewSettingsPath = `${apiEntryUrl}/view-settings`; const viewSettingsPath = `${apiEntryUrl}/view-settings`;
@@ -9,8 +10,8 @@ const viewSettingsPath = `${apiEntryUrl}/view-settings`;
* HTTP request to fetch view settings * HTTP request to fetch view settings
* @returns * @returns
*/ */
export async function getViewSettings(): Promise<ViewSettings> { export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath); const res = await axios.get(viewSettingsPath, { signal: options?.signal });
return res.data; return res.data;
} }
@@ -1,140 +0,0 @@
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
@@ -1,5 +1,6 @@
@use '@/theme/viewerDefs' as *; @use './buttonVariants' as base;
@import './BaseButtonStyles.module.scss';
@include base.button-variants;
.baseButton { .baseButton {
position: relative; position: relative;
@@ -81,4 +82,3 @@
.fluid { .fluid {
width: 100%; width: 100%;
} }
@@ -1,5 +1,6 @@
@use '@/theme/viewerDefs' as *; @use './buttonVariants' as base;
@import './BaseButtonStyles.module.scss';
@include base.button-variants;
.baseIconButton { .baseIconButton {
aspect-ratio: 1; aspect-ratio: 1;
@@ -0,0 +1,144 @@
@use '@/theme/themeTokens' as *;
@use '@/theme/viewerDefs' as *;
@mixin button-variants {
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
}
@@ -1,5 +1,5 @@
import { IoCheckmark } from 'react-icons/io5'; import { IoCheckmark } from 'react-icons/io5';
import { Checkbox as BaseCheckbox } from '@base-ui-components/react/checkbox'; import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
import style from './Checkbox.module.scss'; import style from './Checkbox.module.scss';
@@ -5,10 +5,10 @@ import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets'; import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog';
import Info from '../info/Info'; import Info from '../info/Info';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink'; import AppLink from '../link/app-link/AppLink';
import Modal from '../modal/Modal';
import Select from '../select/Select'; import Select from '../select/Select';
import style from './RedirectClientModal.module.scss'; import style from './RedirectClientModal.module.scss';
@@ -57,7 +57,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
]; ];
return ( return (
<Modal <Dialog
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
showCloseButton showCloseButton
@@ -98,7 +98,10 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
fluid fluid
options={viewOptions} options={viewOptions}
defaultValue={viewOptions[0].value} defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)} onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
disabled={enabledPresets.length === 0} disabled={enabledPresets.length === 0}
/> />
</label> </label>
@@ -1,7 +1,7 @@
import { PropsWithChildren, useRef, useState } from 'react'; import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark, IoCopy } from 'react-icons/io5'; import { IoCheckmark, IoCopy } from 'react-icons/io5';
import copyToClipboard from '../../utils/copyToClipboard'; import { copyToClipboard } from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -25,15 +25,19 @@ export default function CopyTag({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = () => { const handleClick = async () => {
copyToClipboard(copyValue); try {
setCopied(true); await copyToClipboard(copyValue);
setCopied(true);
// reset copied state // reset copied state
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// ignore errors
} }
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
}; };
return ( return (
@@ -6,7 +6,7 @@
transform: translateX(-50%); transform: translateX(-50%);
padding-inline: 1rem; padding-inline: 1rem;
min-width: min(420px, 90vw); min-width: min(600px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog'; import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -12,7 +12,7 @@ interface DialogProps {
showCloseButton?: boolean; showCloseButton?: boolean;
showBackdrop?: boolean; showBackdrop?: boolean;
bodyElements: ReactNode; bodyElements: ReactNode;
footerElements: ReactNode; footerElements?: ReactNode;
onClose: () => void; onClose: () => void;
} }
@@ -27,16 +27,16 @@
} }
} }
.item { .item {
outline: 0; outline: 0;
cursor: default; cursor: default;
padding-block: 0.5rem; padding-block: 0.5rem;
padding-inline: 1rem 2rem; padding-inline: 1rem;
display: flex; display: flex;
gap: 0.5rem; justify-content: space-between;
line-height: 1em; align-items: flex-start;
gap: 1rem;
svg { svg {
color: $gray-500; color: $gray-500;
@@ -61,6 +61,42 @@
border-radius: 3px; border-radius: 3px;
background-color: $gray-1000; background-color: $gray-1000;
} }
&[data-type='destructive'] {
color: $red-500;
svg {
color: $red-500;
}
}
}
.item .iconStrong {
color: $ui-white;
}
.content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
line-height: 1.2;
}
.description {
color: $gray-400;
font-size: calc(1rem - 2px);
line-height: 1.3;
}
.shortcut {
color: $gray-500;
font-size: calc(1rem - 4px);
letter-spacing: 0.02em;
} }
.separator { .separator {
@@ -1,16 +1,18 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IconType } from 'react-icons'; import { IconType } from 'react-icons';
import { Menu as BaseMenu } from '@base-ui-components/react/menu'; import { Menu as BaseMenu } from '@base-ui/react/menu';
import style from './DropdownMenu.module.scss'; import style from './DropdownMenu.module.scss';
type DropdownMenuItemDivider = { type: 'divider' }; type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = { type DropdownMenuItem = {
type: 'item'; type: 'item' | 'destructive';
label: string; label: string;
description?: string;
icon?: IconType; icon?: IconType;
disabled?: boolean; disabled?: boolean;
onClick: () => void; onClick: () => void;
shortcut?: string;
}; };
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem; export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
@@ -31,9 +33,21 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
return <BaseMenu.Separator key={index} className={style.separator} />; return <BaseMenu.Separator key={index} className={style.separator} />;
} }
return ( return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}> <BaseMenu.Item
{item.icon && <item.icon />} key={index}
{item.label} className={style.item}
onClick={item.onClick}
disabled={item.disabled}
data-type={item.type}
>
<span className={style.content}>
<span className={style.label}>
{item.icon && <item.icon />}
{item.label}
</span>
{item.description && <span className={style.description}>{item.description}</span>}
</span>
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
</BaseMenu.Item> </BaseMenu.Item>
); );
})} })}
@@ -69,8 +83,14 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
} }
return ( return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}> <BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />} <span className={style.content}>
{item.label} <span className={style.label}>
{item.icon && <item.icon />}
{item.label}
</span>
{item.description && <span className={style.description}>{item.description}</span>}
</span>
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
</BaseMenu.Item> </BaseMenu.Item>
); );
})} })}
@@ -1,6 +1,7 @@
.corner { .arrow {
transform: rotate(45deg); transform: rotate(45deg);
}
.corner {
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
right: 0.5rem; right: 0.5rem;
@@ -21,6 +22,10 @@
} }
} }
.offsetCorner {
right: 2rem;
}
.header { .header {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -51,6 +56,13 @@
&.vertical { &.vertical {
width: 1px; width: 1px;
height: 0.75em; height: 0.75em;
} }
} }
.panel {
position: relative;
border-radius: $panel-border-radius;
background-color: $bg-container-l2;
padding: 1rem;
}
@@ -1,13 +1,33 @@
import type { HTMLAttributes, LabelHTMLAttributes } from 'react'; import type { HTMLAttributes, JSX, LabelHTMLAttributes, MouseEventHandler } from 'react';
import { IconBaseProps } from 'react-icons'; import { IconBaseProps } from 'react-icons';
import { IoArrowUp } from 'react-icons/io5'; import { IoArrowUp } from 'react-icons/io5';
import { TbPictureInPictureOff } from 'react-icons/tb';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import style from './EditorUtils.module.scss'; import style from './EditorUtils.module.scss';
export function Corner({ className, ...elementProps }: IconBaseProps) { export function CornerExtract({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />; return <IoArrowUp className={cx([style.corner, style.arrow, className])} {...elementProps} />;
}
export function CornerPipButton({ className, ...elementProps }: IconBaseProps) {
return <TbPictureInPictureOff className={cx([style.corner, style.offsetCorner, className])} {...elementProps} />;
}
interface ExtractAndPip extends IconBaseProps {
onExtractClick: MouseEventHandler<SVGElement>;
pipElement: JSX.Element;
}
export function CornerWithPip({ className, pipElement, onExtractClick }: ExtractAndPip) {
return (
<>
<IoArrowUp className={cx([style.corner, style.arrow, className])} onClick={onExtractClick} />
{/* the pip element returns the icon button */}
{pipElement}
</>
);
} }
export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) { export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) {
@@ -35,3 +55,7 @@ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) { export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />; return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
} }
export function Panel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cx([style.panel, className])} {...props} />;
}
@@ -11,7 +11,7 @@
outline: none; outline: none;
&::placeholder { &::placeholder {
color: $gray-500; color: $gray-600;
letter-spacing: 0; letter-spacing: 0;
} }
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful'; import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover } from '@base-ui-components/react/popover'; import { Popover } from '@base-ui/react/popover';
import PopoverContents from '../../popover/Popover'; import PopoverContents from '../../popover/Popover';
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog'; import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -31,7 +31,7 @@ export default function Modal({
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
if (!isOpen) onClose(); if (!isOpen) onClose();
}} }}
dismissible={false} disablePointerDismissal
> >
<BaseDialog.Portal> <BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />} {showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
@@ -2,12 +2,13 @@ import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu'; import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks'; import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost } from '../../../externals'; import { isLocalhost, supportsFullscreen } from '../../../externals';
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
@@ -29,6 +30,7 @@ export default memo(NavigationMenu);
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) { function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const id = useClientStore((store) => store.id); const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name); const name = useClientStore((store) => store.name);
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false); const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
@@ -56,16 +58,18 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</IconButton> </IconButton>
</div> </div>
<div className={style.body}> <div className={style.body}>
<NavigationMenuItem active={fullscreen} onClick={toggle}> {supportsFullscreen && (
Toggle Fullscreen <NavigationMenuItem active={fullscreen} onClick={toggle}>
{fullscreen ? <IoContract /> : <IoExpand />} Toggle Fullscreen
</NavigationMenuItem> {fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
)}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}> <NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen Flip Screen
<IoSwapVertical /> <IoSwapVertical />
{mirror && <span className={style.note}>Active</span>} {mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem> </NavigationMenuItem>
{window.isSecureContext && ( {canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}> <NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake Keep Awake
<LuCoffee /> <LuCoffee />
@@ -77,11 +81,15 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} /> <hr className={style.separator} />
<EditorNavigation /> <EditorNavigation />
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}> <ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline /> <IoLockClosedOutline />
Cuesheet Cuesheet
</ClientLink> </ClientLink>
<ClientLink to='op' current={location.pathname === '/op'}> <ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline /> <IoLockClosedOutline />
Operator Operator
</ClientLink> </ClientLink>
@@ -89,7 +97,12 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} /> <hr className={style.separator} />
{navigatorConstants.map((route) => ( {navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}> <ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
@@ -11,15 +11,22 @@ import style from './ClientLink.module.scss';
interface ClientLinkProps { interface ClientLinkProps {
current: boolean; current: boolean;
to: string; to: string;
postAction?: () => void;
} }
export default function ClientLink({ current, to, children }: PropsWithChildren<ClientLinkProps>) { export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent(); const { isElectron } = useElectronEvent();
const navigate = useNavigate(); const navigate = useNavigate();
if (isElectron) { if (isElectron) {
return ( return (
<NavigationMenuItem active={current} onClick={() => handleLinks(to)}> <NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children} {children}
<IoArrowUp className={style.linkIcon} /> <IoArrowUp className={style.linkIcon} />
</NavigationMenuItem> </NavigationMenuItem>
@@ -27,7 +34,13 @@ export default function ClientLink({ current, to, children }: PropsWithChildren<
} }
return ( return (
<NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}> <NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children} {children}
</NavigationMenuItem> </NavigationMenuItem>
); );
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { Popover } from '@base-ui-components/react/popover'; import { Popover } from '@base-ui/react/popover';
import style from './Popover.module.scss'; import style from './Popover.module.scss';
@@ -1,5 +1,5 @@
import { Radio } from '@base-ui-components/react/radio'; import { Radio } from '@base-ui/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group'; import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -0,0 +1,61 @@
@use '../../../theme/ontimeColours' as *;
@use '../../../theme/ontimeStyles' as *;
// copied from index.scss
$track-color: $white-1;
$thumb-color: $white-20;
$thumb-color-hover: $white-60;
.root {
position: relative;
overflow: hidden;
}
.viewport {
height: 100%;
width: 100%;
overscroll-behavior: contain;
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
}
.scrollbar {
background: transparent;
opacity: 0;
transition: opacity 150ms;
&[data-orientation='vertical'] {
width: 6px;
}
&[data-orientation='horizontal'] {
height: 6px;
}
&[data-orientation='horizontal'][data-has-overflow-x] {
opacity: 1;
pointer-events: auto;
}
&[data-scrolling] {
transition-duration: 0ms;
}
&[data-hovering],
&[data-scrolling] {
opacity: 1;
pointer-events: auto;
}
}
.thumb {
background: $thumb-color;
border-radius: 2px;
&:hover {
background: $thumb-color-hover;
}
}
@@ -0,0 +1,38 @@
import { CSSProperties, PropsWithChildren, Ref } from 'react';
import { ScrollArea } from '@base-ui/react/scroll-area';
import { cx } from '../../utils/styleUtils';
import style from './ScrollArea.module.scss';
interface ScrollAreaProps {
className?: string;
viewportClassName?: string;
contentClassName?: string;
contentStyle?: CSSProperties;
ref?: Ref<HTMLDivElement>;
orientation?: 'vertical' | 'horizontal';
}
export default function StyledScrollArea({
className,
viewportClassName,
contentClassName,
contentStyle,
children,
ref,
orientation = 'vertical',
}: PropsWithChildren<ScrollAreaProps>) {
return (
<ScrollArea.Root className={cx([style.root, className])}>
<ScrollArea.Viewport ref={ref} className={cx([style.viewport, viewportClassName])}>
<ScrollArea.Content className={contentClassName} style={contentStyle}>
{children}
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar className={style.scrollbar} orientation={orientation}>
<ScrollArea.Thumb className={style.thumb} />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
);
}
@@ -1,6 +1,6 @@
import { IoCheckmark } from 'react-icons/io5'; import { IoCheckmark } from 'react-icons/io5';
import { LuChevronsUpDown } from 'react-icons/lu'; import { LuChevronsUpDown } from 'react-icons/lu';
import { Select as BaseSelect } from '@base-ui-components/react/select'; import { Select as BaseSelect } from '@base-ui/react/select';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,4 +1,4 @@
import { Switch as BaseSwitch } from '@base-ui-components/react/switch'; import { Switch as BaseSwitch } from '@base-ui/react/switch';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,7 +1,11 @@
import { ReactNode } from 'react'; import { PropsWithChildren } from 'react';
import style from './Tag.module.scss'; import style from './Tag.module.scss';
export default function Tag({ children }: { children: ReactNode }) { interface TagProps {
return <span className={style.tag}>{children}</span>; className?: string;
}
export default function Tag({ className, children }: PropsWithChildren<TagProps>) {
return <span className={`${style.tag} ${className || ''}`}>{children}</span>;
} }
@@ -16,6 +16,7 @@
.title-card__title { .title-card__title {
color: var(--color-override, $viewer-color); color: var(--color-override, $viewer-color);
padding-right: 1em; padding-right: 1em;
min-height: 1.2em;
} }
.title-card__placeholder { .title-card__placeholder {
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { Tooltip as BaseTooltip } from '@base-ui-components/react/tooltip'; import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip';
import style from './Tooltip.module.scss'; import style from './Tooltip.module.scss';
@@ -1,3 +1,5 @@
import { useRef } from 'react';
import { projectLogoPath } from '../../api/constants'; import { projectLogoPath } from '../../api/constants';
import './ViewLogo.scss'; import './ViewLogo.scss';
@@ -7,13 +9,19 @@ interface ViewLogoProps {
className: string; className: string;
} }
export default function ViewLogo(props: ViewLogoProps) { export default function ViewLogo({ name, className }: ViewLogoProps) {
const { name, className } = props; const imageRef = useRef<HTMLImageElement>(null);
const hideImage = () => {
if (!imageRef.current) return;
imageRef.current.style.display = 'none';
};
// we wrap the image in a div to help maintain the aspect ratio // we wrap the image in a div to help maintain the aspect ratio
return ( return (
<div className={className}> <div className={className}>
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' /> <img ref={imageRef} alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' onError={hideImage} />
</div> </div>
); );
} }
@@ -32,11 +32,11 @@
margin-left: 0.25rem; margin-left: 0.25rem;
width: 0.75em; width: 0.75em;
height: 0.75em; height: 0.75em;
background: var(--user-bg); background: var(--user-bg, $gray-900);
border-radius: 50%; border-radius: 50%;
} }
} }
.empty { .empty {
color: $ui-white; color: $ui-white;
} }
@@ -1,7 +1,7 @@
import { ComponentProps, useEffect, useState } from 'react'; import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../views/common/viewUtils';
import Checkbox from '../checkbox/Checkbox'; import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select'; import Select, { SelectOption } from '../select/Select';
@@ -160,7 +160,16 @@ function ControlledSelect({ id, initialValue, options }: ControlledSelectProps)
}, [initialValue]); }, [initialValue]);
return ( return (
<Select size='large' name={id} options={options} value={selected} onValueChange={(value) => setSelected(value)} /> <Select
size='large'
name={id}
options={options}
value={selected}
onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
/>
); );
} }
@@ -1,16 +1,17 @@
import { FormEvent, memo } from 'react'; import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import Info from '../info/Info'; import Info from '../info/Info';
import { ViewOption } from './viewParams.types'; import { ViewOption } from './viewParams.types';
import { getURLSearchParamsFromObj } from './viewParams.utils'; import { getPreservedSearchParams, getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store'; import { useViewParamsEditorStore } from './viewParamsEditor.store';
import { ViewParamsPresets } from './ViewParamsPresets'; import { ViewParamsPresets } from './ViewParamsPresets';
import ViewParamsSection from './ViewParamsSection'; import ViewParamsSection from './ViewParamsSection';
@@ -24,16 +25,19 @@ interface EditFormDrawerProps {
export default memo(ViewParamsEditor); export default memo(ViewParamsEditor);
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) { function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
const handleClose = () => { const handleClose = () => {
close(); close();
}; };
const resetParams = () => { const resetParams = () => {
setSearchParams(); setSearchParams(getPreservedParams());
}; };
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => { const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -41,7 +45,15 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
const preservedParams = getPreservedParams();
preservedParams.forEach((value, key) => {
newSearchParams.append(key, value);
});
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
if (isSmallScreen) {
close();
}
}; };
return ( return (
@@ -46,7 +46,7 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
options.push({ options.push({
value: key, value: key,
label: value.label, label: value.label,
colour: value.colour || 'transparent', colour: value.colour,
}); });
} }
@@ -191,3 +191,26 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
return newSearchParams; return newSearchParams;
} }
/**
* Extracts search params that are not managed by the view params editor
* @param currentParams - The current URL search params
* @param paramFields - The view options that define the managed parameters
* @returns A new URLSearchParams object with preserved params
*/
export function getPreservedSearchParams(currentParams: URLSearchParams, paramFields: ViewOption[]) {
const managedParamIds = new Set<string>();
paramFields.forEach((section) => {
section.options.forEach((option) => {
managedParamIds.add(option.id);
});
});
const preserved = new URLSearchParams();
currentParams.forEach((value, key) => {
if (!managedParamIds.has(key)) {
preserved.append(key, value);
}
});
return preserved;
}
@@ -0,0 +1,24 @@
import { createContext, PropsWithChildren, useContext } from 'react';
import { useEntryActions } from '../hooks/useEntryAction';
type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
interface EntryActionsProviderProps extends PropsWithChildren {
actions: EntryActionsContextValue;
}
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
}
export function useEntryActionsContext(): EntryActionsContextValue {
const context = useContext(EntryActionsContext);
if (!context) {
throw new Error('useEntryActionsContext must be used within EntryActionsProvider');
}
return context;
}
@@ -17,7 +17,7 @@ export default function useAppVersion() {
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: APP_VERSION, queryKey: APP_VERSION,
queryFn: getLatestVersion, queryFn: ({ signal }) => getLatestVersion({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
@@ -8,12 +8,9 @@ import { automationPlaceholderSettings } from '../models/AutomationSettings';
export default function useAutomationSettings() { export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: AUTOMATION, queryKey: AUTOMATION,
queryFn: getAutomationSettings, queryFn: ({ signal }) => getAutomationSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch }; return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
@@ -10,12 +10,9 @@ const placeholder: CustomFields = {};
export default function useCustomFields() { export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS, queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields, queryFn: ({ signal }) => getCustomFields({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
return { data: data ?? placeholder, status, isFetching, isError, refetch }; return { data: data ?? placeholder, status, isFetching, isError, refetch };
@@ -8,7 +8,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
export function useCustomTranslation() { export function useCustomTranslation() {
const { data, status, refetch } = useQuery({ const { data, status, refetch } = useQuery({
queryKey: TRANSLATION, queryKey: TRANSLATION,
queryFn: getUserTranslation, queryFn: ({ signal }) => getUserTranslation({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
@@ -9,12 +9,9 @@ import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() { export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({ const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO, queryKey: APP_INFO,
queryFn: getInfo, queryFn: ({ signal }) => getInfo({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching }; return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
@@ -8,7 +8,7 @@ import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() { export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA, queryKey: PROJECT_DATA,
queryFn: getProjectData, queryFn: ({ signal }) => getProjectData({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types'; import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants'; import { PROJECT_LIST } from '../api/constants';
@@ -14,32 +14,40 @@ const placeholderProjectList: ProjectFileListResponse = {
function useProjectList() { function useProjectList() {
const { data, status, refetch } = useQuery({ const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST, queryKey: PROJECT_LIST,
queryFn: getProjects, queryFn: ({ signal }) => getProjects({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
return { data: data ?? placeholderProjectList, status, refetch }; return { data: data ?? placeholderProjectList, status, refetch };
} }
export function useOrderedProjectList() { export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
type SortComparator = (a: ProjectFile, b: ProjectFile) => number;
const sortComparators: Record<ProjectSortMode, SortComparator> = {
'alphabetical-asc': (a, b) => a.filename.localeCompare(b.filename),
'alphabetical-desc': (a, b) => b.filename.localeCompare(a.filename),
'modified-asc': (a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
'modified-desc': (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
};
export function useOrderedProjectList(sort: ProjectSortMode = 'modified-desc') {
const response = useProjectList(); const response = useProjectList();
const { files, lastLoadedProject } = response.data; const { files, lastLoadedProject } = response.data;
const reorderedProjectFiles = useMemo(() => { const reorderedProjectFiles: ProjectFileList = useMemo(() => {
if (!files.length) return []; if (!files.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject); const sorted = [...files].sort(sortComparators[sort]);
if (currentlyLoadedIndex === -1) return files; // keep loaded always on top
const currentlyLoadedIndex = sorted.findIndex((project) => project.filename === lastLoadedProject);
if (currentlyLoadedIndex > 0) {
const [loaded] = sorted.splice(currentlyLoadedIndex, 1);
sorted.unshift(loaded);
}
const projectFiles = [...files]; return sorted;
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0]; }, [files, lastLoadedProject, sort]);
return [current, ...projectFiles];
}, [files, lastLoadedProject]);
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } }; return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
} }
@@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants'; import { PROJECT_RUNDOWNS } from '../api/constants';
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown'; import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList, loadRundown, renameRundown } from '../api/rundown';
/** /**
* Project rundowns * Project rundowns
@@ -11,7 +11,7 @@ import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } fr
export function useProjectRundowns() { export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({ const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS, queryKey: PROJECT_RUNDOWNS,
queryFn: fetchProjectRundownList, queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
@@ -30,6 +30,26 @@ export function useMutateProjectRundowns() {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data); ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
}, },
}); });
const { mutateAsync: duplicate } = useMutation({
mutationFn: duplicateRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: rename } = useMutation({
mutationFn: ([rundownId, title]: Parameters<typeof renameRundown>) => renameRundown(rundownId, title),
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: remove } = useMutation({ const { mutateAsync: remove } = useMutation({
mutationFn: deleteRundown, mutationFn: deleteRundown,
@@ -51,5 +71,5 @@ export function useMutateProjectRundowns() {
}, },
}); });
return { create, remove, load }; return { create, duplicate, remove, load, rename };
} }
@@ -8,11 +8,8 @@ import { fetchReport } from '../api/report';
export default function useReport() { export default function useReport() {
const { data, refetch } = useQuery<OntimeReport>({ const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT, queryKey: REPORT,
queryFn: fetchReport, queryFn: ({ signal }) => fetchReport({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
networkMode: 'always',
staleTime: MILLIS_PER_HOUR, staleTime: MILLIS_PER_HOUR,
}); });
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types'; import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
@@ -8,8 +8,6 @@ import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher // revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = { const cachedRundownPlaceholder: Rundown = {
id: 'default', id: 'default',
@@ -26,16 +24,16 @@ const cachedRundownPlaceholder: Rundown = {
export default function useRundown() { export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchCurrentRundown, queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
export function useRundownWithMetadata() { export function useRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata }; return { data, status, rundownMetadata };
} }
@@ -46,37 +44,20 @@ export function useRundownWithMetadata() {
*/ */
export function useFlatRundown() { export function useFlatRundown() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { data: projectData } = useProjectData();
const loadedProject = useRef<string>(''); const flatRundown = useMemo(() => {
const [prevRevision, setPrevRevision] = useState<number>(-1); if (data.revision === -1) {
const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]); return [];
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 || data.revision !== prevRevision) {
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
setFlatRundown(flatRundown);
setPrevRevision(data.revision);
} }
}, [data.entries, data.flatOrder, data.revision, prevRevision]); return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
// TODO: should we have a project id field?
// TODO(v4): cleanup as part of load multiple rundowns
// invalidate current version if project changes
useEffect(() => {
if (projectData?.title !== loadedProject.current) {
setPrevRevision(-1);
loadedProject.current = projectData?.title ?? '';
}
}, [projectData]);
return { data: flatRundown, rundownId: data.id, status }; return { data: flatRundown, rundownId: data.id, status };
} }
export function useFlatRundownWithMetadata() { export function useFlatRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status }; return { data: rundownWithMetadata, status };
@@ -84,6 +65,10 @@ export function useFlatRundownWithMetadata() {
/** /**
* Provides access to a partial rundown based on a filter callback * Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*
*/ */
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) { export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata(); const { data, status } = useFlatRundownWithMetadata();
@@ -100,11 +85,6 @@ export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boo
export function useEntry(entryId: EntryId | null): OntimeEntry | null { export function useEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown(); const { data: rundown } = useRundown();
// track the specific entry we care about if (entryId === null) return null;
const entry = useMemo(() => { return rundown.entries[entryId] ?? null;
if (entryId === null) return null;
return rundown.entries[entryId];
}, [entryId, rundown.entries]);
return entry;
} }
@@ -8,7 +8,7 @@ import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() { export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS, queryKey: APP_SETTINGS,
queryFn: getSettings, queryFn: ({ signal }) => getSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
select: (data) => { select: (data) => {
const unobfuscated = { ...data }; const unobfuscated = { ...data };
@@ -13,7 +13,7 @@ interface FetchProps {
export default function useUrlPresets({ skip = false }: FetchProps = {}) { export default function useUrlPresets({ skip = false }: FetchProps = {}) {
const { data, status, isError, refetch } = useQuery({ const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS, queryKey: URL_PRESETS,
queryFn: getUrlPresets, queryFn: ({ signal }) => getUrlPresets({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
enabled: !skip, enabled: !skip,
@@ -9,7 +9,7 @@ import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() { export default function useViewSettings() {
const { data, status } = useQuery({ const { data, status } = useQuery({
queryKey: VIEW_SETTINGS, queryKey: VIEW_SETTINGS,
queryFn: getViewSettings, queryFn: ({ signal }) => getViewSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR, staleTime: MILLIS_PER_HOUR,
}); });
@@ -1,18 +1,24 @@
import { MouseEvent } from 'react'; import { MouseEvent, useCallback } from 'react';
import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu'; import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu';
import { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu'; import { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu';
export const useContextMenu = <T extends HTMLElement>(options: DropdownMenuOption[]) => { type ContextMenuOptions = () => DropdownMenuOption[];
export const useContextMenu = <T extends HTMLElement>(options: ContextMenuOptions) => {
const setContextMenu = useContextMenuStore((state) => state.setContextMenu); const setContextMenu = useContextMenuStore((state) => state.setContextMenu);
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => { const localCreateContextMenu = useCallback(
// prevent browser default context menu from showing up (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
contextMenuEvent.preventDefault(); // prevent browser default context menu from showing up
contextMenuEvent.preventDefault();
const { pageX, pageY } = contextMenuEvent; const { pageX, pageY } = contextMenuEvent;
return setContextMenu({ x: pageX, y: pageY }, options); const menuOptions = options();
}; return setContextMenu({ x: pageX, y: pageY }, menuOptions);
},
[options, setContextMenu],
);
return [localCreateContextMenu]; return [localCreateContextMenu];
}; };
+151 -34
View File
@@ -1,19 +1,36 @@
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
InsertOptions,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
MaybeString, MaybeString,
OntimeDelay,
OntimeEntry, OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeGroup,
OntimeMilestone,
PatchWithId,
Rundown, Rundown,
SupportedEntry, SupportedEntry,
TimeField, TimeField,
TimeStrategy, TimeStrategy,
TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils'; import {
addToRundown,
createDelay,
createEvent,
createGroup,
createMilestone,
dayInMs,
generateId,
getInsertAfterId,
MILLIS_PER_SECOND,
parseUserTime,
resolveInsertParent,
swapEventData,
} from 'ontime-utils';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils'; import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
@@ -84,9 +101,64 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: addEntryMutation } = useMutation({ const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry), mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), onMutate: async ([_rundownId, entry]) => {
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), await queryClient.cancelQueries({ queryKey: RUNDOWN });
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) {
const optimisticEntry = createOptimisticEntry(entry);
const parentId = resolveInsertParent(previousData, entry);
const maybeParent = parentId ? previousData.entries[parentId] : null;
const parent = maybeParent && isOntimeGroup(maybeParent) ? maybeParent : null;
const afterId = getInsertAfterId(previousData, parent, entry.after, entry.before);
// create a mutable copy — addToRundown mutates in place using immutable array operations
const newRundown: Rundown = {
...previousData,
entries: { ...previousData.entries },
revision: -1,
};
// copy the parent group so we don't mutate the original
if (parent && parentId) {
newRundown.entries[parentId] = { ...parent, entries: [...parent.entries] };
}
addToRundown(
newRundown,
optimisticEntry,
afterId,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
);
queryClient.setQueryData<Rundown>(RUNDOWN, newRundown);
}
return { previousData };
},
onSuccess: (response) => {
if (!response.data) return;
const serverEntry = response.data;
const currentData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (currentData) {
queryClient.setQueryData<Rundown>(RUNDOWN, {
...currentData,
entries: { ...currentData.entries, [serverEntry.id]: serverEntry },
});
}
},
onError: (_error, _variables, context) => {
if (context?.previousData) {
queryClient.setQueryData<Rundown>(RUNDOWN, context.previousData);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
}); });
/** /**
@@ -101,7 +173,15 @@ export const useEntryActions = () => {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
const newEntry: TransientEventPayload = { ...entry, id: generateId() }; const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types
if (options?.after) {
newEntry.after = options.after;
}
if (options?.before) {
newEntry.before = options.before;
}
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) { if (isOntimeEvent(newEntry)) {
@@ -141,14 +221,6 @@ export const useEntryActions = () => {
} }
} }
// handle adding options that concern all event type
if (options?.after) {
(newEntry as TransientEventPayload).after = options.after;
}
if (options?.before) {
(newEntry as TransientEventPayload).before = options.before;
}
try { try {
await addEntryMutation([rundownId, newEntry]); await addEntryMutation([rundownId, newEntry]);
} catch (error) { } catch (error) {
@@ -173,7 +245,8 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: cloneEntryMutation } = useMutation({ const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId), mutationFn: ([rundownId, entryId, options]: Parameters<typeof postCloneEntry>) =>
postCloneEntry(rundownId, entryId, options),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
@@ -182,14 +255,14 @@ export const useEntryActions = () => {
* Clone an entry * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId) => { async (entryId: EntryId, options?: InsertOptions) => {
try { try {
const rundownId = getCurrentRundownData()?.id; const rundownId = getCurrentRundownData()?.id;
if (!rundownId) { if (!rundownId) {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
await cloneEntryMutation([rundownId, entryId]); await cloneEntryMutation([rundownId, entryId, options]);
} catch (error) { } catch (error) {
logAxiosError('Error cloning entry', error); logAxiosError('Error cloning entry', error);
} }
@@ -838,22 +911,40 @@ export const useEntryActions = () => {
[getCurrentRundownData, swapEventsMutation], [getCurrentRundownData, swapEventsMutation],
); );
return { return useMemo(
addEntry, () => ({
applyDelay, addEntry,
batchUpdateEvents, applyDelay,
clone, batchUpdateEvents,
deleteEntry, clone,
deleteAllEntries, deleteEntry,
ungroup, deleteAllEntries,
getEntryById, ungroup,
groupEntries, getEntryById,
move, groupEntries,
reorderEntry, move,
swapEvents, reorderEntry,
updateEntry, swapEvents,
updateTimer, updateEntry,
}; updateTimer,
}),
[
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
updateTimer,
],
);
}; };
/** /**
@@ -885,3 +976,29 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
return { entries, order, flatOrder }; return { entries, order, flatOrder };
} }
/**
* Utility to create an optimistic entry for immediate cache insertion
*/
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id;
let parent: EntryId | null = null;
if ('parent' in entryData && entryData.parent) {
parent = entryData.parent;
}
switch (entryData.type) {
case SupportedEntry.Event: {
return createEvent({ ...entryData, id, parent }) as OntimeEvent;
}
case SupportedEntry.Delay:
return createDelay({ id, duration: (entryData as Partial<OntimeDelay>).duration ?? 0, parent });
case SupportedEntry.Group:
return createGroup({ ...(entryData as Partial<OntimeGroup>), id });
case SupportedEntry.Milestone:
return createMilestone({ ...(entryData as Partial<OntimeMilestone>), id, parent });
default:
throw new Error('Unknown entry type');
}
}
@@ -0,0 +1,95 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { throttle } from '../utils/throttle';
export interface UseLongPressOptions {
/** Time in milliseconds to trigger the long press, default is 400ms */
threshold?: number;
/** Callback triggered when the long press starts */
onStart?: (event: React.MouseEvent | React.TouchEvent) => void;
/** Callback triggered when the long press finishes */
onFinish?: (event: React.MouseEvent | React.TouchEvent) => void;
/** Callback triggered when the long press is canceled */
onCancel?: (event: React.MouseEvent | React.TouchEvent) => void;
}
export interface UseLongPressReturnValue {
onMouseDown: (event: React.MouseEvent) => void;
onMouseUp: (event: React.MouseEvent) => void;
onMouseLeave: (event: React.MouseEvent) => void;
onTouchStart: (event: React.TouchEvent) => void;
onTouchEnd: (event: React.TouchEvent) => void;
}
export function useLongPress(
onLongPress: (event: React.MouseEvent | React.TouchEvent) => void,
options: UseLongPressOptions = {},
): UseLongPressReturnValue {
const { threshold = 700, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timeout = useRef<number>(-1);
useEffect(() => () => window.clearTimeout(timeout.current), []);
return useMemo(() => {
if (typeof onLongPress !== 'function') {
return {} as UseLongPressReturnValue;
}
const start = (event: React.MouseEvent | React.TouchEvent) => {
if (!isMouseEvent(event) && !isTouchEvent(event)) {
return;
}
if (onStart) {
onStart(event);
}
isPressed.current = true;
timeout.current = window.setTimeout(() => {
onLongPress(event);
isLongPressActive.current = true;
}, threshold);
};
const cancel = (event: React.MouseEvent | React.TouchEvent) => {
if (!isMouseEvent(event) && !isTouchEvent(event)) {
return;
}
if (isLongPressActive.current) {
onFinish?.(event);
} else if (isPressed.current) {
onCancel?.(event);
}
isLongPressActive.current = false;
isPressed.current = false;
if (timeout.current) {
window.clearTimeout(timeout.current);
}
};
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
onTouchMove: throttle(cancel, 150),
};
}, [onLongPress, threshold, onCancel, onFinish, onStart]);
}
function isTouchEvent(event: React.MouseEvent | React.TouchEvent): event is React.TouchEvent {
return window.TouchEvent ? event.nativeEvent instanceof TouchEvent : 'touches' in event.nativeEvent;
}
function isMouseEvent(event: React.MouseEvent | React.TouchEvent): event is React.MouseEvent {
return event.nativeEvent instanceof MouseEvent;
}
+13 -30
View File
@@ -128,13 +128,9 @@ export const setAuxTimer = {
setDuration: (index: number, time: number) => sendSocket('auxtimer', { [index]: { duration: time } }), setDuration: (index: number, time: number) => sendSocket('auxtimer', { [index]: { duration: time } }),
}; };
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({ export const useSelectedEventId = createSelector((state: RuntimeStore) => state.eventNow?.id ?? null);
selectedEventId: state.eventNow?.id ?? null,
}));
export const useCurrentGroupId = createSelector((state: RuntimeStore) => ({ export const useCurrentGroupId = createSelector((state: RuntimeStore) => state.groupNow?.id ?? null);
currentGroupId: state.groupNow?.id ?? null,
}));
export const setEventPlayback = { export const setEventPlayback = {
loadEvent: (id: string) => sendSocket('load', { id }), loadEvent: (id: string) => sendSocket('load', { id }),
@@ -147,9 +143,7 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer, ...state.timer,
})); }));
export const useClock = createSelector((state: RuntimeStore) => ({ export const useClock = createSelector((state: RuntimeStore) => state.clock);
clock: state.clock,
}));
export const useNextFlag = createSelector((state: RuntimeStore) => ({ export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.eventFlag?.id ?? null, id: state.eventFlag?.id ?? null,
@@ -173,28 +167,16 @@ export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
})); }));
export const usePing = createSelector((state: RuntimeStore) => ({ export const usePing = createSelector((state: RuntimeStore) => state.ping);
ping: state.ping,
}));
/** convert ping into a derived value which changes less often */ /** convert ping into a derived value which changes less often */
export const useIsOnline = createSelector((state: RuntimeStore) => ({ export const useIsOnline = createSelector((state: RuntimeStore) => state.ping > 0);
isOnline: state.ping > 0,
}));
export const useOffsetMode = createSelector((state: RuntimeStore) => ({ export const useOffsetMode = createSelector((state: RuntimeStore) => state.offset.mode);
offsetMode: state.offset.mode,
}));
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload); export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload);
export const usePlayback = () => { export const usePlayback = createSelector((state: RuntimeStore) => state.timer.playback);
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
});
return useRuntimeStore(featureSelector);
};
/* ======================= Overview data subscriptions ======================= */ /* ======================= Overview data subscriptions ======================= */
@@ -202,9 +184,10 @@ export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart, plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart, actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd, plannedEnd: state.rundown.plannedEnd,
expectedEnd: state.offset.expectedRundownEnd,
})); }));
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => state.offset.expectedRundownEnd);
export const useProgressOverview = createSelector((state: RuntimeStore) => ({ export const useProgressOverview = createSelector((state: RuntimeStore) => ({
numEvents: state.rundown.numEvents, numEvents: state.rundown.numEvents,
selectedEventIndex: state.rundown.selectedEventIndex, selectedEventIndex: state.rundown.selectedEventIndex,
@@ -219,19 +202,19 @@ export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
mode: state.offset.mode, mode: state.offset.mode,
groupExpectedEnd: state.offset.expectedGroupEnd, groupExpectedEnd: state.offset.expectedGroupEnd,
// we can force these numbers to 0 fo this use case to avoid null checks // we can force these numbers to 0 for this use case to avoid null checks
actualGroupStart: state.rundown.actualGroupStart ?? 0, actualGroupStart: state.rundown.actualGroupStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0, currentDay: state.rundown.currentDay ?? 0,
playback: state.timer.playback, playback: state.timer.playback,
})); }));
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({ export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
mode: state.offset.mode, mode: state.offset.mode,
// we can force these numbers to 0 fo this use case to avoid null checks // we can force these numbers to 0 for this use case to avoid null checks
actualStart: state.rundown.actualStart ?? 0, actualStart: state.rundown.actualStart ?? 0,
plannedStart: state.rundown.plannedStart ?? 0, plannedStart: state.rundown.plannedStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0, currentDay: state.rundown.currentDay ?? 0,
playback: state.timer.playback, playback: state.timer.playback,
})); }));
@@ -2,7 +2,6 @@ import { Settings } from 'ontime-types';
export const ontimePlaceholderSettings: Settings = { export const ontimePlaceholderSettings: Settings = {
version: '4.0.0', version: '4.0.0',
serverPort: 4001,
editorKey: null, editorKey: null,
operatorKey: null, operatorKey: null,
timeFormat: '24', timeFormat: '24',
+10 -3
View File
@@ -1,4 +1,5 @@
import { QueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { isOntimeCloud } from '../externals'; import { isOntimeCloud } from '../externals';
@@ -8,10 +9,15 @@ export const ontimeQueryClient = new QueryClient({
queries: { queries: {
gcTime: 10 * MILLIS_PER_MINUTE, gcTime: 10 * MILLIS_PER_MINUTE,
// staleTime: MILLIS_PER_HOUR, //TODO: when all routes have implemented refetch signal from server, we can the assume that the data is not stale until we get the signal // staleTime: MILLIS_PER_HOUR, //TODO: when all routes have implemented refetch signal from server, we can the assume that the data is not stale until we get the signal
networkMode: 'always', networkMode: isOntimeCloud ? 'online' : 'always',
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: 5, retry: (failureCount, error) => {
retryDelay: (attempt) => attempt * 2500, if (axios.isCancel(error)) {
return false;
}
return failureCount < 5;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
}, },
mutations: { mutations: {
/** /**
@@ -21,6 +27,7 @@ export const ontimeQueryClient = new QueryClient({
* - use 'online' for clients that are connected to the cloud * - use 'online' for clients that are connected to the cloud
*/ */
networkMode: isOntimeCloud ? 'online' : 'always', networkMode: isOntimeCloud ? 'online' : 'always',
retry: 0,
}, },
}, },
}); });
@@ -2,10 +2,13 @@ import { create } from 'zustand';
type EntryCopyStore = { type EntryCopyStore = {
entryCopyId: string | null; entryCopyId: string | null;
setEntryCopyId: (eventId: string | null) => void; entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
}; };
export const useEntryCopy = create<EntryCopyStore>()((set) => ({ export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null, entryCopyId: null,
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }), entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
})); }));
@@ -1,68 +0,0 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../clone';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEntry.Event,
flag: false,
title: 'title',
cue: 'cue',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
parent: 'test',
linkStart: false,
countToEnd: false,
endAction: EndAction.None,
skip: false,
colour: 'F00',
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
triggers: [],
custom: {
lighting: '3',
} as EntryCustomFields,
};
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned.triggers).not.toBe(original.triggers);
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
flag: original.flag,
title: original.title,
note: original.note,
timeStart: original.timeStart,
duration: original.duration,
timeEnd: original.timeEnd,
timerType: original.timerType,
timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd,
linkStart: original.linkStart,
endAction: original.endAction,
skip: original.skip,
colour: original.colour,
revision: 0,
delay: original.delay,
dayOffset: original.dayOffset,
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
});
});
});
@@ -14,4 +14,11 @@ describe('linkToOTherHost', () => {
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri); const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://cloud.getontime.no/user-hash/path'); expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
}); });
it('should handle ontime app links', () => {
const serverUrl = 'https://app.getontime.no/user-hash';
const baseUri = 'user-hash';
const destination = linkToOtherHost('app.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://app.getontime.no/user-hash/path');
});
}); });
@@ -140,7 +140,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: 'group', groupId: 'group',
@@ -156,7 +156,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: 'group', groupId: 'group',
@@ -172,7 +172,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 3, eventIndex: 3,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: true, isLoaded: true,
groupId: 'group', groupId: 'group',
@@ -188,7 +188,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 4, eventIndex: 4,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 10, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: false, isLoaded: false,
groupId: 'group', groupId: 'group',
@@ -204,7 +204,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 5, eventIndex: 5,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
totalGap: 17, totalGap: 7,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
groupId: null, groupId: null,
@@ -1,4 +1,6 @@
import { formatTime, nowInMillis } from '../time'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { formatDuration, formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => { describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => { it('should return the current time in milliseconds', () => {
@@ -38,3 +40,18 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('-01:00'); expect(time).toStrictEqual('-01:00');
}); });
}); });
describe('formatDuration()', () => {
it('formats durations correctly', () => {
expect(formatDuration(0)).toBe('0m');
expect(formatDuration(-5000)).toBe('0m');
expect(formatDuration(MILLIS_PER_MINUTE)).toBe('1m');
expect(formatDuration(6 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND)).toBe('6m');
expect(formatDuration(MILLIS_PER_MINUTE * 10)).toBe('10m');
expect(formatDuration(MILLIS_PER_MINUTE * 10 + 100)).toBe('10m');
expect(formatDuration(MILLIS_PER_MINUTE * 10 - 100)).toBe('9m');
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE)).toBe('2h6m');
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
expect(formatDuration(599702, false)).toBe('9m59s');
});
});
@@ -82,6 +82,57 @@ describe('getRouteFromPreset()', () => {
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123'); expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
}); });
}); });
describe('cuesheet presets', () => {
const cuesheetPreset: URLPreset[] = [
{
enabled: true,
alias: 'cuesheet-4685d6',
target: OntimeView.Cuesheet,
search: '',
options: {
read: 'full',
write: '-',
},
},
];
const cuesheetPresetWithoutOptions: URLPreset[] = [
{
enabled: true,
alias: 'cuesheet-basic',
target: OntimeView.Cuesheet,
search: '',
},
];
const cuesheetPresetWithNavLock: URLPreset[] = [
{
enabled: true,
alias: 'cuesheet-locked',
target: OntimeView.Cuesheet,
search: 'n=1',
},
];
it('keeps cuesheet aliases masked when permissions are stored in preset options', () => {
const location = resolvePath('/cuesheet-4685d6');
expect(getRouteFromPreset(location, cuesheetPreset)).toBe('preset/cuesheet-4685d6');
});
it('preserves feature params when redirecting masked cuesheet aliases', () => {
const location = resolvePath('/cuesheet-4685d6?n=1&token=123');
expect(getRouteFromPreset(location, cuesheetPreset)).toBe('preset/cuesheet-4685d6?n=1&token=123');
});
it('keeps cuesheet aliases masked even when preset options are absent', () => {
const location = resolvePath('/cuesheet-basic');
expect(getRouteFromPreset(location, cuesheetPresetWithoutOptions)).toBe('preset/cuesheet-basic');
});
it('applies navigation lock from preset search params when alias is opened', () => {
const location = resolvePath('/cuesheet-locked');
expect(getRouteFromPreset(location, cuesheetPresetWithNavLock)).toBe('preset/cuesheet-locked?n=1');
});
});
}); });
describe('generatePathFromPreset()', () => { describe('generatePathFromPreset()', () => {
@@ -115,6 +166,14 @@ describe('arePathsEquivalent()', () => {
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy(); expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy();
}); });
it('distinguishes preset paths with different lock or token params', () => {
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?n=1')).toBeFalsy();
expect(arePathsEquivalent('preset/minimal?n=1', 'preset/minimal?n=1')).toBeTruthy();
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?token=abc')).toBeFalsy();
expect(arePathsEquivalent('preset/minimal?n=1&token=abc', 'preset/minimal?n=1')).toBeFalsy();
expect(arePathsEquivalent('preset/minimal?n=1&token=abc', 'preset/minimal?n=1&token=abc')).toBeTruthy();
});
it('considers edge cases for the url sharing feature', () => { it('considers edge cases for the url sharing feature', () => {
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy(); expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy();
-36
View File
@@ -1,36 +0,0 @@
import { OntimeEvent, SupportedEntry } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
* @param {OntimeEvent} event
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return {
type: SupportedEntry.Event,
flag: event.flag,
title: event.title,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart,
endAction: event.endAction,
skip: event.skip,
colour: event.colour,
parent: event.parent,
revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
triggers: structuredClone(event.triggers),
custom: structuredClone(event.custom),
};
};
@@ -1,4 +1,18 @@
// we need to this as a promise because safari /**
export default async function copyToClipboard(text: string) { * copy text to clipboard
setTimeout(async () => await navigator.clipboard?.writeText(text)); * @throws if not supported or permission denied
*/
export async function copyToClipboard(text: string) {
await navigator.clipboard?.writeText(text);
}
/**
* Copy to clipboard but safely ignore errors
*/
export async function safeCopyToClipboard(text: string): Promise<void> {
try {
await copyToClipboard(text);
} catch {
// Silently ignore errors
}
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString, removeLeadingZero } from 'ontime-utils';
import { enDash } from './styleUtils'; import { enDash } from './styleUtils';
@@ -14,7 +14,7 @@ export function getOffsetText(offset: MaybeNumber): string {
let offsetText = ''; let offsetText = '';
if (offset < 0) offsetText += '-'; if (offset < 0) offsetText += '-';
if (offset > 0) offsetText += '+'; if (offset > 0) offsetText += '+';
offsetText += millisToString(Math.abs(offset)); offsetText += removeLeadingZero(millisToString(Math.abs(offset)));
return offsetText; return offsetText;
} }
@@ -151,14 +151,14 @@ function processEntry(
if (isPlayableEvent(entry)) { if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent); processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast && !processedData.isLoaded) { if (!processedData.isPast && !processedData.isLoaded) {
/** /**
* isLinkToLoaded is a chain value that we maintain until we * isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event * a) find an unlinked event
* b) find a countToEnd event * b) find a countToEnd event
*/ */
processedData.totalGap += entry.gap;
processedData.isLinkedToLoaded = processedData.isLinkedToLoaded =
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded; entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
} }
+31 -7
View File
@@ -38,7 +38,13 @@ import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
let websocket: WebSocket | null = null; let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000; const socketConfig = {
reconnectBaseInterval: 1000, // 1 second
reconnectMaxInterval: 30000, // 30 seconds
reconnectMinInterval: 500, // 0.5 seconds
reconnectJitter: 0.25,
offlineAttemptsThreshold: 2, // when we consider the client disconnected
} as const;
export let hasConnected = false; export let hasConnected = false;
export let reconnectAttempts = 0; export let reconnectAttempts = 0;
@@ -49,7 +55,11 @@ export const connectSocket = () => {
const preferredClientName = getClientName(); const preferredClientName = getClientName();
websocket.onopen = () => { websocket.onopen = () => {
clearTimeout(reconnectTimeout as NodeJS.Timeout); const isReconnect = hasConnected;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
hasConnected = true; hasConnected = true;
reconnectAttempts = 0; reconnectAttempts = 0;
@@ -59,24 +69,38 @@ export const connectSocket = () => {
path: window.location.pathname + window.location.search, path: window.location.pathname + window.location.search,
name: preferredClientName, name: preferredClientName,
}); });
invalidateAllCaches(); // assume all data to be stale after a reconnect
if (isReconnect) {
invalidateAllCaches();
}
setOnlineStatus(true); setOnlineStatus(true);
}; };
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
const exponentialDelay = Math.min(
socketConfig.reconnectBaseInterval * 2 ** reconnectAttempts,
socketConfig.reconnectMaxInterval,
);
const jitterOffset = exponentialDelay * socketConfig.reconnectJitter * (Math.random() * 2 - 1);
const delay = Math.max(socketConfig.reconnectMinInterval, Math.round(exponentialDelay + jitterOffset));
// we decide to allows reconnect
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) { reconnectTimeout = null;
if (reconnectAttempts > socketConfig.offlineAttemptsThreshold) {
setOnlineStatus(false); setOnlineStatus(false);
} }
console.warn('WebSocket: attempting reconnect'); console.warn(`WebSocket: reconnecting now (#${reconnectAttempts + 1}, waited ${delay}ms)`);
if (websocket && websocket.readyState === WebSocket.CLOSED) { if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1; reconnectAttempts += 1;
connectSocket(); connectSocket();
} }
}, reconnectInterval); }, delay);
}; };
websocket.onerror = (error) => { websocket.onerror = (error) => {
+8 -2
View File
@@ -112,11 +112,12 @@ export const formatTime = (
export function formatDuration(duration: number, hideSeconds = true): string { export function formatDuration(duration: number, hideSeconds = true): string {
// durations should never be negative, we handle it here to flag if there is an issue in future // durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) { if (duration <= 0) {
return '0h 0m'; return '0m';
} }
const hours = Math.floor(duration / MILLIS_PER_HOUR); const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE); const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = ''; let result = '';
if (hours > 0) { if (hours > 0) {
result += `${hours}h`; result += `${hours}h`;
@@ -126,11 +127,16 @@ export function formatDuration(duration: number, hideSeconds = true): string {
} }
if (!hideSeconds) { if (!hideSeconds) {
const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND); const remainingMs = duration % MILLIS_PER_MINUTE;
const exactSeconds = remainingMs / MILLIS_PER_SECOND;
// cap at 59 to avoid showing 60s
const seconds = Math.min(59, Math.ceil(exactSeconds));
if (seconds > 0) { if (seconds > 0) {
result += `${seconds}s`; result += `${seconds}s`;
} }
} }
return result; return result;
} }
+40 -9
View File
@@ -39,11 +39,13 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
// NOTE: verify that this resolves correctly in cloud // NOTE: verify that this resolves correctly in cloud
const currentPath = `${location.pathname}${location.search}`.substring(1); const currentPath = `${location.pathname}${location.search}`.substring(1);
const currentURL = getCurrentPath(location); const currentURL = getCurrentPath(location);
const token = new URLSearchParams(location.search).get('token'); const locationParams = new URLSearchParams(location.search);
const isLocked = location.search.includes('n=1'); const token = locationParams.get('token');
for (const preset of urlPresets) { for (const preset of urlPresets) {
if (!preset.enabled) continue; if (!preset.enabled) continue;
const presetParams = new URLSearchParams(preset.search);
const isLocked = locationParams.get('n') === '1' || presetParams.get('n') === '1';
/** /**
* If the page is a known alias it would be like * If the page is a known alias it would be like
* /preset/{alias} <- locked to a preset * /preset/{alias} <- locked to a preset
@@ -53,7 +55,9 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
* we need to compare the saved preset to the current path to see if we need to redirect * we need to compare the saved preset to the current path to see if we need to redirect
*/ */
if (preset.alias === currentURL || preset.target === currentURL) { if (preset.alias === currentURL || preset.target === currentURL) {
const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token); const newPath = shouldMaskPresetPath(preset)
? generateMaskedPathFromPreset(preset.alias, isLocked, token)
: generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
/** /**
* if the current path is equivalent to the new path, we return null * if the current path is equivalent to the new path, we return null
* this means we will not redirect * this means we will not redirect
@@ -114,6 +118,30 @@ export function generatePathFromPreset(
return `${path.pathname}?${searchParams}`.substring(1); return `${path.pathname}?${searchParams}`.substring(1);
} }
function generateMaskedPathFromPreset(alias: string, locked: boolean, token: string | null): string {
const searchParams = new URLSearchParams();
if (locked) {
searchParams.set('n', '1');
}
if (token) {
searchParams.set('token', token);
}
const path = `preset/${alias}`;
const search = searchParams.toString();
if (!search) {
return path;
}
return `${path}?${search}`;
}
function shouldMaskPresetPath(preset: URLPreset): boolean {
return preset.target === OntimeView.Cuesheet;
}
/** /**
* Utility checks if two paths are equivalent * Utility checks if two paths are equivalent
* For preset paths, only compares the path (since params are stored in session) * For preset paths, only compares the path (since params are stored in session)
@@ -123,16 +151,19 @@ export function arePathsEquivalent(currentPath: string, newPath: string): boolea
const currentUrl = new URL(currentPath, document.location.origin); const currentUrl = new URL(currentPath, document.location.origin);
const newUrl = new URL(newPath, document.location.origin); const newUrl = new URL(newPath, document.location.origin);
// For preset paths, only compare the path
if (currentUrl.pathname.startsWith('/preset/') || newUrl.pathname.startsWith('/preset/')) {
return currentUrl.pathname === newUrl.pathname;
}
// For regular paths, compare path and search params (ignoring token)
if (currentUrl.pathname !== newUrl.pathname) { if (currentUrl.pathname !== newUrl.pathname) {
return false; return false;
} }
// For preset paths, only n and token are meaningful — ignore everything else
if (currentUrl.pathname.startsWith('/preset/')) {
return (
currentUrl.searchParams.get('n') === newUrl.searchParams.get('n') &&
currentUrl.searchParams.get('token') === newUrl.searchParams.get('token')
);
}
// For regular paths, compare all search params except n and token
currentUrl.searchParams.delete('token'); currentUrl.searchParams.delete('token');
currentUrl.searchParams.delete('n'); currentUrl.searchParams.delete('n');
newUrl.searchParams.delete('token'); newUrl.searchParams.delete('token');
+5
View File
@@ -19,6 +19,11 @@ declare global {
process: { process: {
type: string; type: string;
}; };
// Experimental browser feature
documentPictureInPicture: {
requestWindow: () => Promise<Window>;
window: Window;
};
} }
} }
+5 -1
View File
@@ -8,6 +8,7 @@ export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest'; export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no'; export const websiteUrl = 'https://www.getontime.no';
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm'; export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
export const documentationUrl = 'https://docs.getontime.no'; export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/'; export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
@@ -22,7 +23,10 @@ export const isProduction = import.meta.env.PROD;
export const isDev = import.meta.env.DEV; export const isDev = import.meta.env.DEV;
export const currentHostName = window.location.hostname; export const currentHostName = window.location.hostname;
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1'; export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no'); export const isOntimeCloud = document.querySelector('base')?.hasAttribute('data-is-cloud')
export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
export const supportsFullscreen = document.fullscreenEnabled;
// resolve entrypoint URLs // resolve entrypoint URLs
@@ -9,7 +9,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
width: 100%; flex: 1 1 auto;
min-width: 0;
position: relative; position: relative;
} }
@@ -7,6 +7,8 @@ ul {
.tabs { .tabs {
width: min(30vw, 300px); width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
@@ -212,6 +212,11 @@ $inner-padding: 1rem;
&.apart { &.apart {
justify-content: space-between; justify-content: space-between;
} }
&.wrap {
flex-wrap: wrap;
row-gap: 0.5rem;
}
} }
@keyframes animloader { @keyframes animloader {
@@ -107,7 +107,7 @@ export function BlockQuote({ children }: { children: ReactNode }) {
return <blockquote className={style.blockquote}>{children}</blockquote>; return <blockquote className={style.blockquote}>{children}</blockquote>;
} }
export function Error({ children, className }: { children: ReactNode } & React.JSX.IntrinsicElements['div']) { export function Error({ children, className }: PropsWithChildren & React.JSX.IntrinsicElements['div']) {
return <div className={cx([style.fieldError, className])}>{children}</div>; return <div className={cx([style.fieldError, className])}>{children}</div>;
} }
@@ -131,16 +131,25 @@ type InlineProps<C extends AllowedInlineTags> = {
as?: C; as?: C;
relation?: 'inner' | 'component' | 'section'; relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end' | 'apart'; align?: 'start' | 'end' | 'apart';
className?: string; wrap?: 'wrap' | 'nowrap';
}; } & Omit<React.JSX.IntrinsicElements[C], 'align'>;
export function InlineElements<C extends AllowedInlineTags = 'div'>({ export function InlineElements<C extends AllowedInlineTags = 'div'>({
children, children,
as, as,
relation = 'component', relation = 'component',
align = 'start', align = 'start',
wrap = 'nowrap',
className, className,
...elementProps
}: PropsWithChildren<InlineProps<C>>) { }: PropsWithChildren<InlineProps<C>>) {
const Element = as ?? 'div'; const Element = as ?? 'div';
return <Element className={cx([style.inlineElements, style[relation], style[align], className])}>{children}</Element>; return (
<Element
{...(elementProps as HTMLAttributes<HTMLElement>)}
className={cx([style.inlineElements, style[relation], style[align], style[wrap], className])}
>
{children}
</Element>
);
} }
@@ -5,6 +5,7 @@ import {
documentationUrl, documentationUrl,
githubSponsorUrl, githubSponsorUrl,
githubUrl, githubUrl,
subredditUrl,
websiteUrl, websiteUrl,
} from '../../../../externals'; } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -38,6 +39,7 @@ export default function AboutPanel() {
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink> <ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink> <ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink> <ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section> </Panel.Section>
</> </>
); );
@@ -10,7 +10,7 @@ export default function AppVersion() {
return ( return (
<Panel.Paragraph> <Panel.Paragraph>
{`You are currently using Ontime version ${appVersion}`} {`You are currently using Ontime version ${appVersion}`}
<Panel.Error>{`Could not fetch version information: ${isError}`}</Panel.Error> <Panel.Error>Could not fetch version information</Panel.Error>
</Panel.Paragraph> </Panel.Paragraph>
); );
} }
@@ -230,10 +230,18 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div key={key} className={style.filterSection}> <div key={key} className={style.filterSection}>
<label> <label>
Runtime data source Runtime data source
<Select <Select<string | null>
value={watch(`filters.${index}.field`)} // need to normalize '' to null for the Select to show the placeholder
onValueChange={(value) => setValue(`filters.${index}.field`, value, { shouldDirty: true })} value={watch(`filters.${index}.field`) || null}
options={fieldList.map(({ value, label }) => ({ value, label }))} onValueChange={(value) => {
if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true });
}}
options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field' aria-label='Event field'
/> />
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error> <Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
@@ -242,11 +250,14 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
Matching condition Matching condition
<Select <Select
value={watch(`filters.${index}.operator`)} value={watch(`filters.${index}.operator`)}
onValueChange={(value) => onValueChange={(value: string | null) => {
setValue(`filters.${index}.operator`, value as 'equals' | 'not_equals' | 'contains', { if (value === null) return;
shouldDirty: true, setValue(
}) `filters.${index}.operator`,
} value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
{ shouldDirty: true },
);
}}
options={[ options={[
{ value: 'equals', label: 'equals' }, { value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' }, { value: 'not_equals', label: 'not equals' },
@@ -282,7 +293,8 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Outputs</h3> <h3>Outputs</h3>
<Info> <Info>
Automation outputs can be used to send data from Ontime to external software. Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself.
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink> <ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
</Info> </Info>
@@ -44,8 +44,9 @@ export default function OntimeActionForm({
<label> <label>
Action Action
<Select <Select
onValueChange={(value) => { onValueChange={(value: OntimeActionKey | null) => {
handleSetAction(value as OntimeActionKey); if (value === null) return;
handleSetAction(value);
}} }}
value={watch(`outputs.${index}.action`)} value={watch(`outputs.${index}.action`)}
options={[ options={[
@@ -97,13 +98,13 @@ export default function OntimeActionForm({
Visibility Visibility
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
// we need to translate the undefined value to 'untouched' // we need to translate the null to undefined so it becomes 'untouched'
const translatedValue = value === 'untouched' ? undefined : (value as boolean | undefined); const translatedValue = value === null ? undefined : value;
setValue(`outputs.${index}.visible`, translatedValue, { shouldDirty: true }); setValue(`outputs.${index}.visible`, translatedValue, { shouldDirty: true });
}} }}
value={watch(`outputs.${index}.visible`) === undefined ? 'untouched' : watch(`outputs.${index}.visible`)} value={watch(`outputs.${index}.visible`)}
options={[ options={[
{ value: 'untouched', label: 'Untouched' }, { value: null, label: 'Untouched' },
{ value: true, label: 'Show' }, { value: true, label: 'Show' },
{ value: false, label: 'Hide' }, { value: false, label: 'Hide' },
]} ]}
@@ -116,23 +117,31 @@ export default function OntimeActionForm({
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<label> <label>
Timer secondary source Timer secondary source
<Select <Select<SecondarySource | 'null' | null>
onValueChange={(value) => { onValueChange={(value) => {
setValue(`outputs.${index}.secondarySource`, value as SecondarySource, { shouldDirty: true }); // null -> no selection
if (value === null) return;
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}} }}
value={watch(`outputs.${index}.secondarySource`)} value={watch(`outputs.${index}.secondarySource`)}
options={[ options={[
{ value: null, label: 'Select secondary source', disabled: true }, { value: null, label: 'Select secondary source' },
{ value: 'aux1', label: 'Auxiliary timer 1' }, { value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' }, { value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' }, { value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'external', label: 'External' }, { value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, { value: 'null', label: 'None' }, // allow the user to clear the secondary source
]} ]}
/> />
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error> <Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label> </label>
)} )}
<div className={style.test}>{children}</div> <div className={style.test}>{children}</div>
</div> </div>
); );

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