Compare commits

...

144 Commits

Author SHA1 Message Date
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
411 changed files with 10092 additions and 3733 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"jest": true
},
"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"],
"overrides": [
{
+15 -16
View File
@@ -37,32 +37,31 @@ jobs:
run: pnpm build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- 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
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.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:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# 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
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+4 -1
View File
@@ -103,6 +103,9 @@ jobs:
- name: Release
uses: softprops/action-gh-release@v1
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:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -40,6 +40,7 @@ override.css
# working stuff
**/TODO.md
**.local.**
# docker utils
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>
- 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-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</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 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
- 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>
## Need help?
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.2",
"version": "4.4.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+9 -9
View File
@@ -1,15 +1,15 @@
{
"name": "ontime-ui",
"version": "4.0.2",
"version": "4.4.2",
"private": true,
"type": "module",
"dependencies": {
"@base-ui-components/react": "1.0.0-beta.4",
"@base-ui/react": "1.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.2.8",
"@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.85.9",
@@ -17,21 +17,20 @@
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.12.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0",
"prismjs": "^1.30.0",
"react": "^19.1.1",
"react": "^19.2.3",
"react-colorful": "^5.6.1",
"react-dom": "^19.1.1",
"react-dom": "^19.2.3",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.62.0",
"react-icons": "5.5.0",
"react-qr-code": "^2.0.18",
"react-router": "^7.8.2",
"react-router": "^7.11.0",
"react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.14.0",
"react-virtuoso": "^4.17.0",
"web-vitals": "^5.1.0",
"zustand": "^5.0.8"
"zustand": "^5.0.9"
},
"scripts": {
"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/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0",
+4 -1
View File
@@ -1,8 +1,11 @@
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 { 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 IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
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 { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
import type { RequestOptions } from './requestOptions';
const assetsPath = `${apiEntryUrl}/assets`;
/**
* HTTP request to get css contents
*/
export async function getCSSContents(): Promise<string> {
const res = await axios.get(`${assetsPath}/css`);
export async function getCSSContents(options?: RequestOptions): Promise<string> {
const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal });
return res.data;
}
@@ -35,8 +36,8 @@ export async function restoreCSSContents(): Promise<string> {
/**
* HTTP request to get user translation
*/
export async function getUserTranslation(): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL);
export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL, { signal: options?.signal });
return res.data;
}
+3 -2
View File
@@ -9,14 +9,15 @@ import type {
} from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> {
const res = await axios.get(automationsPath, { signal: options?.signal });
return res.data;
}
+4 -1
View File
@@ -1,5 +1,8 @@
import axios from 'axios';
import { axiosConfig } from './requestTimeouts';
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 { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/**
* Requests list of known custom fields
*/
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(customFieldsPath);
export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> {
const res = await axios.get(customFieldsPath, { signal: options?.signal });
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 { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { createBlob, downloadBlob } from './utils';
const dbPath = `${apiEntryUrl}/db`;
@@ -10,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB
*/
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();
formData.append('project', file);
const response = await axios.post(`${dbPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: {
'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
*/
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`);
export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data;
}
+25 -16
View File
@@ -1,8 +1,10 @@
import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown } from 'ontime-types';
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`;
@@ -11,42 +13,49 @@ const excelPath = `${apiEntryUrl}/excel`;
* upload Excel file to server
* @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();
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: {
'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;
}
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options,
});
export async function importRundownPreview(
options: ImportMap,
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;
}
/**
* 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 {
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
responseType: 'blob',
});
+4 -2
View File
@@ -2,6 +2,8 @@ import axios from 'axios';
import { apiRepoLatest } from '../../externals';
import type { RequestOptions } from './requestOptions';
export type HasUpdate = {
url: string;
version: string;
@@ -10,8 +12,8 @@ export type HasUpdate = {
/**
* HTTP request to get the latest version and url from github
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest);
export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest, { signal: options?.signal });
return {
url: res.data.html_url 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 { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const projectPath = `${apiEntryUrl}/project`;
/**
* HTTP request to fetch project data
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectPath);
export async function getProjectData(options?: RequestOptions): Promise<ProjectData> {
const res = await axios.get(projectPath, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data;
}
@@ -27,6 +32,7 @@ export async function uploadProjectLogo(file: File): Promise<AxiosResponse<Proje
const formData = new FormData();
formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
+3 -2
View File
@@ -4,14 +4,15 @@ import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants';
import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(reportUrl);
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
const res = await axios.get(reportUrl, { signal: options?.signal });
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 { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns =========================
@@ -10,24 +12,40 @@ const rundownPath = `${apiEntryUrl}/rundowns`;
/**
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath);
export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to fetch all entries in the currently loaded rundown
*/
export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal });
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
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
*/
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${id}/load`);
export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/load`);
}
/**
@@ -37,11 +55,25 @@ export async function createRundown(title: string): Promise<AxiosResponse<Projec
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
*/
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${id}`);
export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${rundownId}`);
}
// #endregion operations on project rundowns ======================
@@ -51,7 +83,7 @@ export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRu
* HTTP request to post new entry
*/
export async function postAddEntry(
rundownId: string,
rundownId: RundownId,
data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
@@ -60,7 +92,10 @@ export async function postAddEntry(
/**
* 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);
}
@@ -72,7 +107,7 @@ export type BatchEditEntry = {
/**
* 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);
}
@@ -85,56 +120,64 @@ export type ReorderEntry = {
/**
* 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);
}
/**
* 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 });
}
/**
* 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}`);
}
/**
* HTTP request for cloning an entry
*/
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
export async function postCloneEntry(
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
*/
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 });
}
/**
* 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}`);
}
/**
* 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 } });
}
/**
* 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`);
}
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`);
export async function getInfo(options?: RequestOptions): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal });
return res.data;
}
+3 -2
View File
@@ -2,14 +2,15 @@ import axios, { AxiosResponse } from 'axios';
import { Settings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const settingsPath = `${apiEntryUrl}/settings`;
/**
* HTTP request to retrieve application settings
*/
export async function getSettings(): Promise<Settings> {
const res = await axios.get(settingsPath);
export async function getSettings(options?: RequestOptions): Promise<Settings> {
const res = await axios.get(settingsPath, { signal: options?.signal });
return res.data;
}
+38 -6
View File
@@ -1,8 +1,10 @@
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 { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const sheetsPath = `${apiEntryUrl}/sheets`;
@@ -23,6 +25,7 @@ export const verifyAuthenticationStatus = async (): Promise<{
export const requestConnection = async (
file: File,
sheetId: string,
requestOptions?: RequestOptions,
): Promise<{
verification_url: string;
user_code: string;
@@ -31,6 +34,8 @@ export const requestConnection = async (
formData.append('client_secret', file);
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -53,23 +58,50 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic
export const previewRundown = async (
sheetId: string,
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<{
rundown: Rundown;
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;
};
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
export const getWorksheetNames = async (sheetId: string, requestOptions?: RequestOptions): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(
`${sheetsPath}/${sheetId}/worksheets`,
undefined,
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
/**
* HTTP request to upload the rundown to a google sheet
*/
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
export const uploadRundown = async (
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;
};
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { URLPreset } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
* HTTP request to retrieve all presets
*/
export async function getUrlPresets(): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath);
export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath, { signal: options?.signal });
return res.data;
}
+3 -2
View File
@@ -2,6 +2,7 @@ import axios from 'axios';
import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
@@ -9,8 +10,8 @@ const viewSettingsPath = `${apiEntryUrl}/view-settings`;
* HTTP request to fetch view settings
* @returns
*/
export async function getViewSettings(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath);
export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath, { signal: options?.signal });
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 *;
@import './BaseButtonStyles.module.scss';
@use './buttonVariants' as base;
@include base.button-variants;
.baseButton {
position: relative;
@@ -81,4 +82,3 @@
.fluid {
width: 100%;
}
@@ -1,5 +1,6 @@
@use '@/theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
@use './buttonVariants' as base;
@include base.button-variants;
.baseIconButton {
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 { Checkbox as BaseCheckbox } from '@base-ui-components/react/checkbox';
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
import style from './Checkbox.module.scss';
@@ -5,10 +5,10 @@ import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog';
import Info from '../info/Info';
import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink';
import Modal from '../modal/Modal';
import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
@@ -57,7 +57,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
];
return (
<Modal
<Dialog
isOpen={isOpen}
onClose={onClose}
showCloseButton
@@ -98,7 +98,10 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)}
onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
disabled={enabledPresets.length === 0}
/>
</label>
@@ -1,7 +1,7 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark, IoCopy } from 'react-icons/io5';
import copyToClipboard from '../../utils/copyToClipboard';
import { copyToClipboard } from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
@@ -25,15 +25,19 @@ export default function CopyTag({
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = () => {
copyToClipboard(copyValue);
setCopied(true);
const handleClick = async () => {
try {
await copyToClipboard(copyValue);
setCopied(true);
// reset copied state
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
// reset copied state
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// ignore errors
}
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
};
return (
@@ -6,7 +6,7 @@
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(420px, 90vw);
min-width: min(600px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
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';
@@ -12,7 +12,7 @@ interface DialogProps {
showCloseButton?: boolean;
showBackdrop?: boolean;
bodyElements: ReactNode;
footerElements: ReactNode;
footerElements?: ReactNode;
onClose: () => void;
}
@@ -27,16 +27,16 @@
}
}
.item {
outline: 0;
cursor: default;
padding-block: 0.5rem;
padding-inline: 1rem 2rem;
padding-inline: 1rem;
display: flex;
gap: 0.5rem;
line-height: 1em;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
svg {
color: $gray-500;
@@ -61,6 +61,42 @@
border-radius: 3px;
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 {
@@ -1,16 +1,18 @@
import { PropsWithChildren } from 'react';
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';
type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = {
type: 'item';
type: 'item' | 'destructive';
label: string;
description?: string;
icon?: IconType;
disabled?: boolean;
onClick: () => void;
shortcut?: string;
};
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.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />}
{item.label}
<BaseMenu.Item
key={index}
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>
);
})}
@@ -69,8 +83,14 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />}
{item.label}
<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>
);
})}
@@ -1,6 +1,7 @@
.corner {
.arrow {
transform: rotate(45deg);
}
.corner {
position: absolute;
top: 0.5rem;
right: 0.5rem;
@@ -21,6 +22,10 @@
}
}
.offsetCorner {
right: 2rem;
}
.header {
font-size: 1.5rem;
}
@@ -51,6 +56,13 @@
&.vertical {
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 { IoArrowUp } from 'react-icons/io5';
import { TbPictureInPictureOff } from 'react-icons/tb';
import { cx } from '../../utils/styleUtils';
import style from './EditorUtils.module.scss';
export function Corner({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
export function CornerExtract({ className, ...elementProps }: IconBaseProps) {
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>) {
@@ -35,3 +55,7 @@ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
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;
&::placeholder {
color: $gray-500;
color: $gray-600;
letter-spacing: 0;
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
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';
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
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';
@@ -31,7 +31,7 @@ export default function Modal({
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
dismissible={false}
disablePointerDismissal
>
<BaseDialog.Portal>
{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 { LuCoffee } from 'react-icons/lu';
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 { isLocalhost } from '../../../externals';
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton';
@@ -29,6 +30,7 @@ export default memo(NavigationMenu);
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name);
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen();
@@ -56,16 +58,18 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</IconButton>
</div>
<div className={style.body}>
<NavigationMenuItem active={fullscreen} onClick={toggle}>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
{supportsFullscreen && (
<NavigationMenuItem active={fullscreen} onClick={toggle}>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
)}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{window.isSecureContext && (
{canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
@@ -77,11 +81,15 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} />
<EditorNavigation />
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
<ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Cuesheet
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'}>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline />
Operator
</ClientLink>
@@ -89,7 +97,12 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} />
{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}
</ClientLink>
))}
@@ -11,15 +11,22 @@ import style from './ClientLink.module.scss';
interface ClientLinkProps {
current: boolean;
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 navigate = useNavigate();
if (isElectron) {
return (
<NavigationMenuItem active={current} onClick={() => handleLinks(to)}>
<NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children}
<IoArrowUp className={style.linkIcon} />
</NavigationMenuItem>
@@ -27,7 +34,13 @@ export default function ClientLink({ current, to, children }: PropsWithChildren<
}
return (
<NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}>
<NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children}
</NavigationMenuItem>
);
@@ -1,5 +1,5 @@
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';
@@ -1,5 +1,5 @@
import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import { Radio } from '@base-ui/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group';
import { cx } from '../../utils/styleUtils';
@@ -0,0 +1,56 @@
@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;
}
.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 { 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';
@@ -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';
@@ -1,7 +1,11 @@
import { ReactNode } from 'react';
import { PropsWithChildren } from 'react';
import style from './Tag.module.scss';
export default function Tag({ children }: { children: ReactNode }) {
return <span className={style.tag}>{children}</span>;
interface TagProps {
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 {
color: var(--color-override, $viewer-color);
padding-right: 1em;
min-height: 1.2em;
}
.title-card__placeholder {
@@ -1,5 +1,5 @@
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';
@@ -1,3 +1,5 @@
import { useRef } from 'react';
import { projectLogoPath } from '../../api/constants';
import './ViewLogo.scss';
@@ -7,13 +9,19 @@ interface ViewLogoProps {
className: string;
}
export default function ViewLogo(props: ViewLogoProps) {
const { name, className } = props;
export default function ViewLogo({ name, className }: ViewLogoProps) {
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
return (
<div className={className}>
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
<img ref={imageRef} alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' onError={hideImage} />
</div>
);
}
@@ -32,11 +32,11 @@
margin-left: 0.25rem;
width: 0.75em;
height: 0.75em;
background: var(--user-bg);
background: var(--user-bg, $gray-900);
border-radius: 50%;
}
}
.empty {
color: $ui-white;
}
}
@@ -1,7 +1,7 @@
import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { isStringBoolean } from '../../../views/common/viewUtils';
import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select';
@@ -160,7 +160,16 @@ function ControlledSelect({ id, initialValue, options }: ControlledSelectProps)
}, [initialValue]);
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 { IoClose } from 'react-icons/io5';
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 { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
import Info from '../info/Info';
import { ViewOption } from './viewParams.types';
import { getURLSearchParamsFromObj } from './viewParams.utils';
import { getPreservedSearchParams, getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store';
import { ViewParamsPresets } from './ViewParamsPresets';
import ViewParamsSection from './ViewParamsSection';
@@ -24,16 +25,19 @@ interface EditFormDrawerProps {
export default memo(ViewParamsEditor);
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
const handleClose = () => {
close();
};
const resetParams = () => {
setSearchParams();
setSearchParams(getPreservedParams());
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -41,7 +45,15 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
const preservedParams = getPreservedParams();
preservedParams.forEach((value, key) => {
newSearchParams.append(key, value);
});
setSearchParams(newSearchParams);
if (isSmallScreen) {
close();
}
};
return (
@@ -46,7 +46,7 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
options.push({
value: key,
label: value.label,
colour: value.colour || 'transparent',
colour: value.colour,
});
}
@@ -191,3 +191,26 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
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,
} = useQuery({
queryKey: APP_VERSION,
queryFn: getLatestVersion,
queryFn: ({ signal }) => getLatestVersion({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@@ -8,12 +8,9 @@ import { automationPlaceholderSettings } from '../models/AutomationSettings';
export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: AUTOMATION,
queryFn: getAutomationSettings,
queryFn: ({ signal }) => getAutomationSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
@@ -10,12 +10,9 @@ const placeholder: CustomFields = {};
export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
queryFn: ({ signal }) => getCustomFields({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? placeholder, status, isFetching, isError, refetch };
@@ -8,7 +8,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
export function useCustomTranslation() {
const { data, status, refetch } = useQuery({
queryKey: TRANSLATION,
queryFn: getUserTranslation,
queryFn: ({ signal }) => getUserTranslation({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -9,12 +9,9 @@ import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO,
queryFn: getInfo,
queryFn: ({ signal }) => getInfo({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
@@ -8,7 +8,7 @@ import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: getProjectData,
queryFn: ({ signal }) => getProjectData({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types';
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants';
@@ -14,32 +14,40 @@ const placeholderProjectList: ProjectFileListResponse = {
function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: getProjects,
queryFn: ({ signal }) => getProjects({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
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 { files, lastLoadedProject } = response.data;
const reorderedProjectFiles = useMemo(() => {
const reorderedProjectFiles: ProjectFileList = useMemo(() => {
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];
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
return [current, ...projectFiles];
}, [files, lastLoadedProject]);
return sorted;
}, [files, lastLoadedProject, sort]);
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
}
@@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
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
@@ -11,7 +11,7 @@ import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } fr
export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS,
queryFn: fetchProjectRundownList,
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -30,6 +30,26 @@ export function useMutateProjectRundowns() {
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({
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() {
const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT,
queryFn: fetchReport,
queryFn: ({ signal }) => fetchReport({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
networkMode: 'always',
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 { EntryId, OntimeEntry, Rundown } from 'ontime-types';
@@ -8,8 +8,6 @@ import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
@@ -26,16 +24,16 @@ const cachedRundownPlaceholder: Rundown = {
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN,
queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId();
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
}
@@ -46,37 +44,20 @@ export function useRundownWithMetadata() {
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const { data: projectData } = useProjectData();
const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]);
// 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);
const flatRundown = useMemo(() => {
if (data.revision === -1) {
return [];
}
}, [data.entries, data.flatOrder, data.revision, prevRevision]);
// 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.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId();
const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
@@ -84,6 +65,10 @@ export function useFlatRundownWithMetadata() {
/**
* 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) {
const { data, status } = useFlatRundownWithMetadata();
@@ -100,11 +85,6 @@ export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boo
export function useEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown();
// track the specific entry we care about
const entry = useMemo(() => {
if (entryId === null) return null;
return rundown.entries[entryId];
}, [entryId, rundown.entries]);
return entry;
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
@@ -8,7 +8,7 @@ import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: getSettings,
queryFn: ({ signal }) => getSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
select: (data) => {
const unobfuscated = { ...data };
@@ -13,7 +13,7 @@ interface FetchProps {
export default function useUrlPresets({ skip = false }: FetchProps = {}) {
const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
queryFn: ({ signal }) => getUrlPresets({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
enabled: !skip,
@@ -9,7 +9,7 @@ import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
const { data, status } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: getViewSettings,
queryFn: ({ signal }) => getViewSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
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 { 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 localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up
contextMenuEvent.preventDefault();
const localCreateContextMenu = useCallback(
(contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up
contextMenuEvent.preventDefault();
const { pageX, pageY } = contextMenuEvent;
return setContextMenu({ x: pageX, y: pageY }, options);
};
const { pageX, pageY } = contextMenuEvent;
const menuOptions = options();
return setContextMenu({ x: pageX, y: pageY }, menuOptions);
},
[options, setContextMenu],
);
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 {
EntryId,
InsertOptions,
isOntimeEvent,
isOntimeGroup,
MaybeString,
OntimeDelay,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
PatchWithId,
Rundown,
SupportedEntry,
TimeField,
TimeStrategy,
TransientEventPayload,
} 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 { RUNDOWN } from '../api/constants';
@@ -84,9 +101,64 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => {
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');
}
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
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 {
await addEntryMutation([rundownId, newEntry]);
} catch (error) {
@@ -173,7 +245,8 @@ export const useEntryActions = () => {
* @private
*/
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 }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
@@ -182,14 +255,14 @@ export const useEntryActions = () => {
* Clone an entry
*/
const clone = useCallback(
async (entryId: EntryId) => {
async (entryId: EntryId, options?: InsertOptions) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await cloneEntryMutation([rundownId, entryId]);
await cloneEntryMutation([rundownId, entryId, options]);
} catch (error) {
logAxiosError('Error cloning entry', error);
}
@@ -838,22 +911,40 @@ export const useEntryActions = () => {
[getCurrentRundownData, swapEventsMutation],
);
return {
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
updateTimer,
};
return useMemo(
() => ({
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
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 };
}
/**
* 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;
}
+9 -26
View File
@@ -128,13 +128,9 @@ export const setAuxTimer = {
setDuration: (index: number, time: number) => sendSocket('auxtimer', { [index]: { duration: time } }),
};
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null,
}));
export const useSelectedEventId = createSelector((state: RuntimeStore) => state.eventNow?.id ?? null);
export const useCurrentGroupId = createSelector((state: RuntimeStore) => ({
currentGroupId: state.groupNow?.id ?? null,
}));
export const useCurrentGroupId = createSelector((state: RuntimeStore) => state.groupNow?.id ?? null);
export const setEventPlayback = {
loadEvent: (id: string) => sendSocket('load', { id }),
@@ -147,9 +143,7 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
export const useClock = createSelector((state: RuntimeStore) => ({
clock: state.clock,
}));
export const useClock = createSelector((state: RuntimeStore) => state.clock);
export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.eventFlag?.id ?? null,
@@ -173,28 +167,16 @@ export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
}));
export const usePing = createSelector((state: RuntimeStore) => ({
ping: state.ping,
}));
export const usePing = createSelector((state: RuntimeStore) => state.ping);
/** convert ping into a derived value which changes less often */
export const useIsOnline = createSelector((state: RuntimeStore) => ({
isOnline: state.ping > 0,
}));
export const useIsOnline = createSelector((state: RuntimeStore) => state.ping > 0);
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
offsetMode: state.offset.mode,
}));
export const useOffsetMode = createSelector((state: RuntimeStore) => state.offset.mode);
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload);
export const usePlayback = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
});
return useRuntimeStore(featureSelector);
};
export const usePlayback = createSelector((state: RuntimeStore) => state.timer.playback);
/* ======================= Overview data subscriptions ======================= */
@@ -202,9 +184,10 @@ export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd,
expectedEnd: state.offset.expectedRundownEnd,
}));
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => state.offset.expectedRundownEnd);
export const useProgressOverview = createSelector((state: RuntimeStore) => ({
numEvents: state.rundown.numEvents,
selectedEventIndex: state.rundown.selectedEventIndex,
+10 -3
View File
@@ -1,4 +1,5 @@
import { QueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { isOntimeCloud } from '../externals';
@@ -8,10 +9,15 @@ export const ontimeQueryClient = new QueryClient({
queries: {
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
networkMode: 'always',
networkMode: isOntimeCloud ? 'online' : 'always',
refetchOnWindowFocus: false,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
retry: (failureCount, error) => {
if (axios.isCancel(error)) {
return false;
}
return failureCount < 5;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
},
mutations: {
/**
@@ -21,6 +27,7 @@ export const ontimeQueryClient = new QueryClient({
* - use 'online' for clients that are connected to the cloud
*/
networkMode: isOntimeCloud ? 'online' : 'always',
retry: 0,
},
},
});
@@ -2,10 +2,13 @@ import { create } from 'zustand';
type EntryCopyStore = {
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) => ({
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);
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,
isPast: true,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
@@ -156,7 +156,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
@@ -172,7 +172,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 3,
isPast: false,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: true,
groupId: 'group',
@@ -188,7 +188,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 4,
isPast: false,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: false,
groupId: 'group',
@@ -204,7 +204,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 5,
isPast: false,
isNextDay: false,
totalGap: 17,
totalGap: 7,
isLinkedToLoaded: false,
isLoaded: false,
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()', () => {
it('should return the current time in milliseconds', () => {
@@ -38,3 +40,18 @@ describe('formatTime()', () => {
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');
});
});
-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) {
setTimeout(async () => await navigator.clipboard?.writeText(text));
/**
* copy text to clipboard
* @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 { millisToString } from 'ontime-utils';
import { millisToString, removeLeadingZero } from 'ontime-utils';
import { enDash } from './styleUtils';
@@ -14,7 +14,7 @@ export function getOffsetText(offset: MaybeNumber): string {
let offsetText = '';
if (offset < 0) offsetText += '-';
if (offset > 0) offsetText += '+';
offsetText += millisToString(Math.abs(offset));
offsetText += removeLeadingZero(millisToString(Math.abs(offset)));
return offsetText;
}
@@ -151,14 +151,14 @@ function processEntry(
if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast && !processedData.isLoaded) {
/**
* isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event
* b) find a countToEnd event
*/
*/
processedData.totalGap += entry.gap;
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 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 reconnectAttempts = 0;
@@ -49,7 +55,11 @@ export const connectSocket = () => {
const preferredClientName = getClientName();
websocket.onopen = () => {
clearTimeout(reconnectTimeout as NodeJS.Timeout);
const isReconnect = hasConnected;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
hasConnected = true;
reconnectAttempts = 0;
@@ -59,24 +69,38 @@ export const connectSocket = () => {
path: window.location.pathname + window.location.search,
name: preferredClientName,
});
invalidateAllCaches(); // assume all data to be stale after a reconnect
if (isReconnect) {
invalidateAllCaches();
}
setOnlineStatus(true);
};
websocket.onclose = () => {
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(() => {
if (reconnectAttempts > 2) {
reconnectTimeout = null;
if (reconnectAttempts > socketConfig.offlineAttemptsThreshold) {
setOnlineStatus(false);
}
console.warn('WebSocket: attempting reconnect');
console.warn(`WebSocket: reconnecting now (#${reconnectAttempts + 1}, waited ${delay}ms)`);
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket();
}
}, reconnectInterval);
}, delay);
};
websocket.onerror = (error) => {
+8 -2
View File
@@ -112,11 +112,12 @@ export const formatTime = (
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
if (duration <= 0) {
return '0h 0m';
return '0m';
}
const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = '';
if (hours > 0) {
result += `${hours}h`;
@@ -126,11 +127,16 @@ export function formatDuration(duration: number, hideSeconds = true): string {
}
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) {
result += `${seconds}s`;
}
}
return result;
}
+5
View File
@@ -19,6 +19,11 @@ declare global {
process: {
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 websiteUrl = 'https://www.getontime.no';
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 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 currentHostName = window.location.hostname;
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
@@ -212,6 +212,11 @@ $inner-padding: 1rem;
&.apart {
justify-content: space-between;
}
&.wrap {
flex-wrap: wrap;
row-gap: 0.5rem;
}
}
@keyframes animloader {
@@ -107,7 +107,7 @@ export function BlockQuote({ children }: { children: ReactNode }) {
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>;
}
@@ -131,16 +131,25 @@ type InlineProps<C extends AllowedInlineTags> = {
as?: C;
relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end' | 'apart';
className?: string;
};
wrap?: 'wrap' | 'nowrap';
} & Omit<React.JSX.IntrinsicElements[C], 'align'>;
export function InlineElements<C extends AllowedInlineTags = 'div'>({
children,
as,
relation = 'component',
align = 'start',
wrap = 'nowrap',
className,
...elementProps
}: PropsWithChildren<InlineProps<C>>) {
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,
githubSponsorUrl,
githubUrl,
subredditUrl,
websiteUrl,
} from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -38,6 +39,7 @@ export default function AboutPanel() {
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section>
</>
);
@@ -10,7 +10,7 @@ export default function AppVersion() {
return (
<Panel.Paragraph>
{`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>
);
}
@@ -230,10 +230,18 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div key={key} className={style.filterSection}>
<label>
Runtime data source
<Select
value={watch(`filters.${index}.field`)}
onValueChange={(value) => setValue(`filters.${index}.field`, value, { shouldDirty: true })}
options={fieldList.map(({ value, label }) => ({ value, label }))}
<Select<string | null>
// need to normalize '' to null for the Select to show the placeholder
value={watch(`filters.${index}.field`) || null}
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'
/>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
@@ -242,11 +250,14 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
Matching condition
<Select
value={watch(`filters.${index}.operator`)}
onValueChange={(value) =>
setValue(`filters.${index}.operator`, value as 'equals' | 'not_equals' | 'contains', {
shouldDirty: true,
})
}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(
`filters.${index}.operator`,
value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
{ shouldDirty: true },
);
}}
options={[
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
@@ -282,7 +293,8 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div className={style.innerColumn}>
<h3>Outputs</h3>
<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>
</Info>
@@ -44,8 +44,9 @@ export default function OntimeActionForm({
<label>
Action
<Select
onValueChange={(value) => {
handleSetAction(value as OntimeActionKey);
onValueChange={(value: OntimeActionKey | null) => {
if (value === null) return;
handleSetAction(value);
}}
value={watch(`outputs.${index}.action`)}
options={[
@@ -97,13 +98,13 @@ export default function OntimeActionForm({
Visibility
<Select
onValueChange={(value) => {
// we need to translate the undefined value to 'untouched'
const translatedValue = value === 'untouched' ? undefined : (value as boolean | undefined);
// we need to translate the null to undefined so it becomes 'untouched'
const translatedValue = value === null ? undefined : value;
setValue(`outputs.${index}.visible`, translatedValue, { shouldDirty: true });
}}
value={watch(`outputs.${index}.visible`) === undefined ? 'untouched' : watch(`outputs.${index}.visible`)}
value={watch(`outputs.${index}.visible`)}
options={[
{ value: 'untouched', label: 'Untouched' },
{ value: null, label: 'Untouched' },
{ value: true, label: 'Show' },
{ value: false, label: 'Hide' },
]}
@@ -116,23 +117,31 @@ export default function OntimeActionForm({
{selectedAction === 'message-secondary' && (
<label>
Timer secondary source
<Select
<Select<SecondarySource | 'null' | null>
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`)}
options={[
{ value: null, label: 'Select secondary source', disabled: true },
{ value: null, label: 'Select secondary source' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'external', label: 'External' },
{ value: 'null', label: 'None' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
)}
<div className={style.test}>{children}</div>
</div>
);
@@ -106,7 +106,10 @@ export default function TriggerForm({
Lifecycle trigger
<Select
value={watch('trigger')}
onValueChange={(value) => setValue('trigger', value as TimerLifeCycle, { shouldDirty: true })}
onValueChange={(value) => {
if (value === null) return;
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
}}
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
aria-label='Lifecycle trigger'
/>
@@ -116,7 +119,10 @@ export default function TriggerForm({
Automation title
<Select
value={watch('automationId')}
onValueChange={(value) => setValue('automationId', value, { shouldDirty: true })}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue('automationId', value, { shouldDirty: true });
}}
options={automationSelect}
aria-label='Automation title'
/>
@@ -27,6 +27,7 @@ export function isAutomation(automation: AutomationDTO | Automation): automation
}
const staticSelectProperties = [
{ value: null, label: 'Select field' },
{ value: 'eventNow.id', label: 'ID' },
{ value: 'eventNow.title', label: 'Title' },
{ value: 'eventNow.cue', label: 'Cue' },
@@ -42,7 +43,7 @@ const staticNextSelectProperties = [
];
type SelectableField = {
value: string; // string encodes path in runtime state object
value: string | null; // string encodes path in runtime state object
label: string;
};
@@ -9,6 +9,8 @@ import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import * as Panel from '../../../panel-utils/PanelUtils';
@@ -52,6 +54,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<URLPreset>({
defaultValues: urlPreset ?? defaultValues,
mode: 'onChange',
resetOptions: {
keepDirtyValues: true,
},
@@ -110,7 +113,15 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<Panel.InlineElements>
<div>
<Panel.Description>Alias</Panel.Description>
<Input {...register('alias', { required: 'Alias is required' })} />
<Input
{...register('alias', {
required: 'Alias is required',
pattern: {
value: isUrlSafe,
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
},
})}
/>
</div>
<div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
@@ -120,7 +131,10 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
</Panel.InlineElements>
</div>
</Panel.InlineElements>
<div> - or -</div>
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
<div>
{enDash} or {enDash}
</div>
<div>2. Choose a view and its parameters</div>
<div>
<Panel.Description>Target</Panel.Description>
@@ -128,7 +142,10 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
options={targetOptions}
{...register('target', { required: 'Target is required' })}
value={watch('target')}
onValueChange={(value) => setValue('target', value, { shouldDirty: true })}
onValueChange={(value: OntimeViewPresettable | null) => {
if (value === null) return;
setValue('target', value, { shouldDirty: true });
}}
/>
</div>
<div>
@@ -1,5 +1,13 @@
import { useState } from 'react';
import { IoAdd, IoDocumentOutline, IoDownloadOutline, IoEllipsisHorizontal, IoTrash } from 'react-icons/io5';
import {
IoAdd,
IoDocumentOutline,
IoDownloadOutline,
IoDuplicateOutline,
IoEllipsisHorizontal,
IoPencilOutline,
IoTrash,
} from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks';
import { downloadAsExcel } from '../../../../common/api/excel';
@@ -13,17 +21,19 @@ import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss';
export default function ManageRundowns() {
const { data } = useProjectRundowns();
const { remove, load } = useMutateProjectRundowns();
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure();
const [isOpenLoad, loadHandlers] = useDisclosure();
const [isNewLoad, newHandlers] = useDisclosure();
const [targetRundown, setTargetRundown] = useState('');
const [renamingRundown, setRenamingRundown] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const openLoad = (id: string) => {
@@ -38,6 +48,11 @@ export default function ManageRundowns() {
deleteHandlers.open();
};
const openRename = (id: string) => {
setActionError(null);
setRenamingRundown(id);
};
const submitRundownLoad = async () => {
try {
await load(targetRundown);
@@ -48,6 +63,27 @@ export default function ManageRundowns() {
}
};
const submitRundownDuplicate = async (id: string) => {
setActionError(null);
setRenamingRundown(null);
setTargetRundown('');
try {
await duplicate(id);
} catch (error) {
setActionError(`Failed to duplicate rundown. ${maybeAxiosError(error)}`);
}
};
const submitRundownRename = async (id: string, newTitle: string) => {
try {
await rename([id, newTitle]);
setRenamingRundown(null);
} catch (error) {
setActionError(`Failed to rename rundown. ${maybeAxiosError(error)}`);
}
};
const submitRundownDelete = async () => {
try {
await remove(targetRundown);
@@ -94,6 +130,22 @@ export default function ManageRundowns() {
<tbody>
{data?.rundowns?.map(({ id, numEntries, title }) => {
const isLoaded = data.loaded === id;
const isRenaming = renamingRundown === id;
if (isRenaming) {
return (
<tr key={id}>
<td colSpan={3}>
<RundownRenameForm
onCancel={() => setRenamingRundown(null)}
onSubmit={(newTitle: string) => submitRundownRename(id, newTitle)}
initialTitle={title}
/>
</td>
</tr>
);
}
return (
<tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td>
@@ -104,6 +156,12 @@ export default function ManageRundowns() {
<DropdownMenu
render={<IconButton variant='ghosted-white' />}
items={[
{
type: 'item',
icon: IoPencilOutline,
label: 'Rename',
onClick: () => openRename(id),
},
{
type: 'item',
icon: IoDownloadOutline,
@@ -119,6 +177,13 @@ export default function ManageRundowns() {
},
{
type: 'item',
icon: IoDuplicateOutline,
label: 'Duplicate',
onClick: () => submitRundownDuplicate(id),
},
{ type: 'divider' },
{
type: 'destructive',
icon: IoTrash,
label: 'Delete',
onClick: () => openDelete(id),
@@ -140,7 +205,7 @@ export default function ManageRundowns() {
<Dialog
isOpen={isOpenDelete}
onClose={deleteHandlers.close}
title='Load rundown'
title='Delete rundown'
showBackdrop
showCloseButton
bodyElements={
@@ -51,7 +51,10 @@ export default function RundownDefaultSettings() {
/>
<Select
value={defaultTimeStrategy}
onValueChange={(value) => setTimeStrategy(value as TimeStrategy)}
onValueChange={(value: TimeStrategy | null) => {
if (value === null) return;
setTimeStrategy(value);
}}
options={[
{ value: TimeStrategy.LockDuration, label: 'Duration' },
{ value: TimeStrategy.LockEnd, label: 'End Time' },
@@ -73,7 +76,10 @@ export default function RundownDefaultSettings() {
<Panel.Field title='Timer type' description='Default type of timer for new events' />
<Select
value={defaultTimerType}
onValueChange={(value) => setDefaultTimerType(value as TimerType)}
onValueChange={(value: TimerType | null) => {
if (value === null) return;
setDefaultTimerType(value);
}}
options={[
{ value: TimerType.CountDown, label: 'Count down' },
{ value: TimerType.CountUp, label: 'Count up' },
@@ -86,7 +92,10 @@ export default function RundownDefaultSettings() {
<Panel.Field title='End Action' description='Default end action for new events' />
<Select
value={defaultEndAction}
onValueChange={(value) => setDefaultEndAction(value as EndAction)}
onValueChange={(value: EndAction | null) => {
if (value === null) return;
setDefaultEndAction(value);
}}
options={[
{ value: EndAction.None, label: 'None' },
{ value: EndAction.LoadNext, label: 'Load next' },

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