Compare commits

..

308 Commits

Author SHA1 Message Date
Carlos Valente b60b1beeec bump version to 3.14.1 2025-03-09 22:06:40 +01:00
Alex Christoffer Rasmussen eab916f410 fix clock drift (#1531) 2025-03-08 18:13:02 +01:00
Carlos Valente 069a003787 refactor: do not send sentry errors if offline 2025-03-08 08:58:04 +01:00
Carlos Valente 9b08080b7a fix: pending card 2025-03-08 08:57:57 +01:00
Carlos Valente 8e7ab54d39 refactor: defer table while loading 2025-03-04 22:01:21 +01:00
arc-alex 4a16378786 refactor: pad block row with extra columns 2025-03-04 22:01:21 +01:00
Shobhit Nagpal e5b30770ce feat: add table navigation to cuesheet table (#1483)
* refactor: cuesheet table body
* feat: add table navigation
2025-03-04 22:01:21 +01:00
Carlos Valente 8465b04c89 bump version to 3.14.0 2025-03-04 21:54:29 +01:00
Carlos Valente 718a6ad2b7 refactor: improve editor overflow 2025-03-04 21:54:29 +01:00
Carlos Valente 6ca4bfc363 refactor: allow overriding styles of cuesheet 2025-03-03 15:58:52 +01:00
Carlos Valente 4859026989 refactor: op cannot subscribe to image fields 2025-03-03 15:58:52 +01:00
Carlos Valente 9167a50eda refactor: project info design review 2025-02-28 19:28:55 +01:00
Carlos Valente 8adca908b3 refactor: improve links inside editor 2025-02-28 19:21:48 +01:00
Carlos Valente bf99646ad5 refactor: restructure directories 2025-02-28 19:21:48 +01:00
Carlos Valente 308285ce3e chore: add missing variables 2025-02-28 19:21:48 +01:00
Carlos Valente dd781a32f6 bump version to 3.13.1 2025-02-27 08:28:21 +01:00
Carlos Valente c2a6de6195 fix: memoisation of row should allow changes 2025-02-27 08:28:21 +01:00
Carlos Valente 0ac2ce8e84 bump version to 3.13.0 2025-02-26 21:43:24 +01:00
Carlos Valente 3bdd8424eb chore: remove console statement 2025-02-26 21:43:24 +01:00
Carlos Valente b3a099255f fix: missing backstage prop 2025-02-26 21:43:24 +01:00
Carlos Valente 570ff59cfc feat: add custom field type of image 2025-02-26 21:42:24 +01:00
Alex Christoffer Rasmussen d0b4c6a279 import id's from sheet (#1516)
* import id's from sheet

* fix test

* fix test in utils

* remove comment

* ensure uri safe ids
2025-02-26 21:12:36 +01:00
Carlos Valente e90161aa33 refactor: consistent logo sizes 2025-02-25 21:25:28 +01:00
Carlos Valente e37254107e style: reword prompt 2025-02-25 21:25:28 +01:00
Carlos Valente 9d6bc0a137 style: clarify resource 2025-02-25 21:25:28 +01:00
Carlos Valente 4cd2dcbab0 refactor: move logic to service 2025-02-25 21:25:28 +01:00
Carlos Valente 8c593b3487 refactor: use memoised selector 2025-02-23 21:50:01 +01:00
Carlos Valente ba74622569 refactor: use native button 2025-02-23 21:50:01 +01:00
Carlos Valente f76c0d1a6d refactor: improve composition performance 2025-02-23 21:50:01 +01:00
Carlos Valente 8e8d3a7824 refactor: send client list on patch 2025-02-23 21:49:53 +01:00
Carlos Valente 6c3ae1f914 refactor: show schedule in smaller screens 2025-02-23 21:49:53 +01:00
Alex Christoffer Rasmussen b6507c27a6 Report (#1469)
* create report service

* write report from runtimeState

* use in UI

* clear report

* update types

* clear all from settings menu

* rearence rightclik menu

* refactor styling

* also report roll events

* ontime/under time is same colour

* refactor reporter

* add target to ontime-refetch

* remove menu

* fectch only on message from server

* memo useGetEventReport

* refactor

* use staleTime

* dont add to menu yet

* implement review

* clear all from menu

* fix merge

* start end show

* combine test for the go button text and action

* add report to menu

* extract csv utility

* report management

* unneeded async

* small refacort of triggerReportEntry

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-02-23 17:02:09 +01:00
Carlos Valente 21877e4bff refactor: allow redirecting to view 2025-02-22 16:15:24 +01:00
Carlos Valente b8d7324be0 feat: allow redirecting to preset 2025-02-22 16:15:24 +01:00
Carlos Valente 88b8e83494 Time until (#1497)
* Timeuntil in UI (#1461)

* cherry pick EventBlockChip

* pull data down throug components

* add comment

* cleanup css

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

---------

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

* fix: account for running day

* refactor: improve composition

* add partial test

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2025-02-19 13:17:53 +01:00
Carlos Valente abce0c498c bump version to 3.12.0 2025-02-18 21:43:16 +01:00
Carlos Valente 6b2e45e7de refactor: improve template input 2025-02-18 21:43:03 +01:00
Carlos Valente 5fb30c75c4 feat: ontime actions in automation 2025-02-18 21:43:03 +01:00
Carlos Valente 636f78e21e fix: unique reference for filter items 2025-02-18 21:43:03 +01:00
Carlos Valente 1636745fed refactor: improve automation flow logging 2025-02-18 21:43:03 +01:00
Carlos Valente e01ab0c88b fix: update navigation references 2025-02-18 21:43:03 +01:00
Carlos Valente 0ede753652 fix: missing reference on template input 2025-02-18 21:43:03 +01:00
Alex Christoffer Rasmussen 1f6f3df0f2 ensure extension during rename and delete (#1508) 2025-02-18 21:25:36 +01:00
Carlos Valente a5afb32b8a refactor: show projected time 2025-02-18 14:55:24 +01:00
Carlos Valente 9c41f8ff6b refactor: public design review 2025-02-18 14:55:24 +01:00
Carlos Valente 1edde03bb4 chore: directory restructure 2025-02-18 14:55:24 +01:00
Carlos Valente 946b6717d2 refactor: backstage design review
refactor: improve empty state

refactor: review schedule design

- fit as many elements as possible
- expose cycle interval as an option
- stabilise rundown reference
- style tweaks
2025-02-18 14:55:24 +01:00
Carlos Valente f99ad83049 chore: folder structure 2025-02-18 14:55:24 +01:00
Carlos Valente 665496907e refactor: improve overflow style 2025-02-18 14:14:14 +01:00
Carlos Valente 2867c8301d refactor: stabilise table options 2025-02-18 14:14:14 +01:00
Carlos Valente 84a5b4fe5b refactor: use a single menu for table 2025-02-18 14:14:14 +01:00
Carlos Valente 86bb739d90 chore: upgrade zustand 2025-02-18 14:14:14 +01:00
Carlos Valente 2c8b6180d7 refactor: show menu only if enabled 2025-02-18 14:14:14 +01:00
Carlos Valente 4cc138e3c0 chore: directory structure 2025-02-18 14:14:14 +01:00
Carlos Valente d21e1f996f refactor: simplify typing 2025-02-17 21:36:18 +01:00
Carlos Valente 11c1b472a2 refactor: do not test automations without outputs 2025-02-17 21:36:18 +01:00
Carlos Valente 891bda6661 refactor: improve async handling 2025-02-17 21:36:18 +01:00
Carlos Valente c65761448d refactor: simplify import paths 2025-02-17 21:36:18 +01:00
Carlos Valente 70558c5104 refactor: remove unused template literals 2025-02-17 21:36:18 +01:00
Carlos Valente c8bf74a719 refactor: migrate chakra alert to custom component 2025-02-17 14:45:23 +01:00
Carlos Valente 10bf4b93ee doc: add osc disabled warning for cloud users 2025-02-17 14:45:23 +01:00
Carlos Valente f6449a4326 refactor: tweak reusable info component 2025-02-17 14:45:23 +01:00
Carlos Valente 8d38f52b69 bump version to 3.11.1 2025-02-17 12:59:10 +01:00
Carlos Valente 3d40e31730 fix: error on reading malformed package 2025-02-17 12:59:10 +01:00
Carlos Valente 8ac5c0a9a2 Revert "Timeuntil in UI (#1461)"
This reverts commit 221ff7b3af.
2025-02-12 22:19:18 +01:00
Carlos Valente 649f0af2cf refactor: reword error 2025-02-12 22:19:18 +01:00
Carlos Valente fd9b944ecf bump version to 3.11.0 2025-02-12 22:19:18 +01:00
Alex Christoffer Rasmussen 0b36608134 Catch xlsx error (#1487)
* catch potential xlsx errors

* clear worksheet errors when starting new connections
2025-02-12 13:38:18 +01:00
Carlos Valente bac11adeb1 feat: add autocomplete to osc arguments 2025-02-10 20:01:36 +01:00
Carlos Valente 31799f8fab fix: project created without extension 2025-02-10 07:39:05 +01:00
Carlos Valente af177060ff refactor: add next to source fields 2025-02-06 21:20:25 +01:00
Carlos Valente d26a3cd393 refactor: change order of operations 2025-02-06 21:20:25 +01:00
Carlos Valente 38a3165a79 refactor: larger settings panels 2025-02-04 22:35:17 +01:00
Carlos Valente 1715c19747 refactor: clarify empty text 2025-02-04 22:35:17 +01:00
Carlos Valente 6be6e5fd8d refactor: handle overflow in menulist 2025-02-04 22:35:17 +01:00
Carlos Valente 44b0cfd928 refactor: allow locking cuesheet and operator 2025-02-04 22:35:00 +01:00
Carlos Valente 3d50b8ce49 refactor: simplify navigation lock 2025-02-04 22:35:00 +01:00
Carlos Valente c3d66f5bd1 fix: maintain locked and token params 2025-02-01 21:34:29 +01:00
Carlos Valente d08787a6ba refactor: allow presets for op and cuesheet 2025-02-01 21:34:29 +01:00
Carlos Valente f0e6231338 refactor: simplify url preset logic 2025-02-01 21:34:29 +01:00
Carlos Valente 430bfb38f2 chore: test is typescript 2025-02-01 21:34:29 +01:00
Carlos Valente 60354f8ea3 fix: prevent non alphanumeric in pincode 2025-02-01 11:38:58 +01:00
Carlos Valente bc12531729 refactor: show multiline text in custom fields 2025-02-01 11:38:58 +01:00
Carlos Valente 0b8a694e4f refactor: allow overriding operator styles 2025-02-01 11:38:58 +01:00
Carlos Valente 73ada99596 fix: update time in block (#1474)
* fix: update time in block

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-01-31 17:20:02 +01:00
Alex Christoffer Rasmussen f509655d31 update types/node and osc-min (#1475) 2025-01-30 22:25:25 +01:00
Carlos Valente fb1bbd6d5c refactor: fix roll next day 2025-01-27 19:24:11 +01:00
Alex Christoffer Rasmussen 221ff7b3af Timeuntil in UI (#1461)
* cherry pick EventBlockChip

* pull data down throug components

* add comment

* cleanup css

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-27 16:39:32 +01:00
Carlos Valente 5d5b48aca6 bump version to 3.11.0-beta.1 2025-01-26 20:22:01 +01:00
Carlos Valente 0f57689750 feat: generate authenticated url (#1471)
* refactor: extract info component

* feat: generate authenticated url

* refactor: add companion select

* refactor: ensure behaviour across environments
2025-01-26 16:51:28 +01:00
Alex Christoffer Rasmussen d34280c03d More batch (#1468)
* batch updates in client

* rearange updating logic

* utilety type

* guard undefinde in affectsLoaded

* fix: pause state not saved to restore point
2025-01-26 13:14:24 +01:00
Carlos Valente bb1e9072fd refactor: apply param structure 2025-01-25 18:54:25 +01:00
Carlos Valente 4503d1e411 refactor: param sections are collapsible 2025-01-25 18:54:25 +01:00
Carlos Valente b4caec0644 refactor: use type imports 2025-01-24 17:06:50 +01:00
Carlos Valente 960f0322ad refactor: improve Esc handling in forms 2025-01-24 16:48:42 +01:00
Carlos Valente a12694b186 refactor: use inline elements 2025-01-24 16:48:42 +01:00
Carlos Valente a1d34c8f6c refactor: remove > and < comparisons 2025-01-24 13:53:17 +01:00
Carlos Valente 7f8dbbfa87 refactor: simplify string comparisons 2025-01-24 13:53:17 +01:00
Carlos Valente 39e012452b refactor: improve form ux 2025-01-24 13:53:17 +01:00
Carlos Valente c333117347 refactor: start with small runtimedata subset 2025-01-24 13:53:17 +01:00
Carlos Valente eb0e5e6420 refactor: handle boolean comparisons 2025-01-24 13:53:17 +01:00
Carlos Valente 8ba65fa2bd refactor: handle non numeric comparisons 2025-01-24 13:53:17 +01:00
Carlos Valente efe5ac16f2 chore: rename feature elements
rename automations > triggers
rename blueprints > automations

chore: add link to documentation
2025-01-24 13:53:17 +01:00
arc-alex f4f266dbd4 refactor: migrate to OSC min 2025-01-24 13:53:17 +01:00
Carlos Valente bc9345199b feat: filtering logic 2025-01-24 13:53:17 +01:00
Carlos Valente 2cc434b0e9 refactor: remove legacy service 2025-01-24 13:53:17 +01:00
Carlos Valente 1f71d4578c refactor: migrate triggers to new service 2025-01-24 13:53:17 +01:00
Carlos Valente 6c4d80158c feat: send osc 2025-01-24 13:53:17 +01:00
Carlos Valente ab4dd300ca feat: send http 2025-01-24 13:53:17 +01:00
Carlos Valente 8b84963e17 feat: automation UI
merge with automation service
2025-01-24 13:53:17 +01:00
Carlos Valente 05e61e7bf8 feat: automation service 2025-01-24 13:53:17 +01:00
Carlos Valente a8fe4c8e68 refactor: allow both http and https 2025-01-24 13:53:17 +01:00
Alex Christoffer Rasmussen d175c788c5 Batch client updates (#1448) 2025-01-24 13:52:58 +01:00
Carlos Valente f1f628a48e bump version to 3.10.5 2025-01-24 13:31:18 +01:00
Carlos Valente d58335d1a1 refactor: allow overriding timer type in view 2025-01-24 13:13:53 +01:00
Carlos Valente 78c2bd79b5 refactor: card content on preload 2025-01-24 13:13:53 +01:00
Carlos Valente 28452d1e38 refactor: allow overriding progress bar phases 2025-01-24 13:13:53 +01:00
Carlos Valente 46790441e0 refactor: review view styles 2025-01-24 13:13:53 +01:00
Carlos Valente 3f35b5e297 refactor: extract and simplify logic 2025-01-24 13:13:53 +01:00
Carlos Valente 49cae647d6 refactor: larger progress bar 2025-01-24 13:13:53 +01:00
Carlos Valente 90700c85e8 refactor: subscript clock 2025-01-24 13:13:53 +01:00
Carlos Valente 8fac23c078 refactor: make function private 2025-01-24 13:13:53 +01:00
Carlos Valente b5cf6c4bd1 chore: directory restructure 2025-01-24 13:13:53 +01:00
Carlos Valente 0ea00ff6be chore: remove unused code 2025-01-24 12:41:42 +01:00
Carlos Valente 30e290c320 refactor: remove unnecessary handler 2025-01-22 20:51:12 +01:00
Carlos Valente ac52b3f0c1 chore: use smaller dependency 2025-01-22 20:51:12 +01:00
Carlos Valente 8e3db6c559 refactor: remove dom dependencies 2025-01-22 20:51:12 +01:00
Carlos Valente 1d2013ad41 fix: show correct state for welcome option 2025-01-19 22:18:03 +01:00
Carlos Valente 9c910c079d bump version to 3.10.4 2025-01-19 20:04:52 +01:00
Carlos Valente 420cef8b0d chore: add html files to docker 2025-01-19 19:57:10 +01:00
Carlos Valente b4c7b84f92 refactor: handle trailing slash in URL preset 2025-01-19 19:57:10 +01:00
Carlos Valente 0450eb9169 refactor: set token from query params 2025-01-19 19:57:10 +01:00
Carlos Valente 7fe5223af9 feat: add auth to server requests 2025-01-19 19:57:10 +01:00
arc-alex ce8d534953 feat: optional welcome modal
welcome modal is persisted in app state

created endpoints to set the visibility

added UI to support feature

Co-authored-by: Carlos Valente
<34649812+cpvalente@users.noreply.github.com>
2025-01-18 20:24:46 +01:00
Carlos Valente 4a6fbc2a92 refactor: add timezone data to session 2025-01-17 20:05:56 +01:00
Carlos Valente 50ebcf4e14 refactor: review welcome modal design 2025-01-17 20:04:00 +01:00
Carlos Valente f0f7d1cede docs: add homebrew links 2025-01-17 20:02:44 +01:00
Carlos Valente d2bdb1ea03 docs: add links to sponsor 2025-01-17 20:02:44 +01:00
Carlos Valente 9be6febcda chore: upgrade playwright 2025-01-17 19:57:01 +01:00
Carlos Valente aa929fd0dc refactor: create utility for nested templates 2025-01-12 10:20:24 +01:00
Carlos Valente 7baa6a4ab9 refactor: create mock data utilities 2025-01-12 10:20:24 +01:00
Carlos Valente 082fe8b8d7 chore: write feature spec 2025-01-12 10:14:44 +01:00
Alex Christoffer Rasmussen e9a7cd0400 More rundown metadata (#1429)
* add dayOffset to OntimeEvent

* refactor: isNewLatest

* calculate totalDays

* refactor: getTimeFromPrevious

* add gap as dataset in OntimeEvent

* use in ui

* format overlap is just a simple text formatting, big test is not needed

* add new fields where needed

* update apply delay

* show nex day eaven if there is no gap

* create test

* use buildin day offset in timeline

* remove todo

* consistent naming

* make a calculateDayOffset util for rundownCache

* refactor: checkIsNextDay to use dayOffset

* spelling

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

* remove unneeded test

* remove todo

* update test db

* use data-testid

* spelling

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

* update comments

* remove null option

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-09 13:35:11 +01:00
Carlos Valente 9ba40c35a1 refactor: avoid matching file versions 2025-01-06 22:58:06 +01:00
Carlos Valente f94db06812 refactor: wider sheet id length 2025-01-06 22:58:06 +01:00
Carlos Valente 35424cfff2 bump version to 3.10.3 2025-01-06 20:33:39 +01:00
Alex Christoffer Rasmussen 13eadeb0af fix download (#1434)
* fix download

* update test db

* create download test
2025-01-06 20:26:06 +01:00
Carlos Valente 9cfed5a150 fix: issue with persistent script 2025-01-06 20:11:15 +01:00
Alex Christoffer Rasmussen a22c2d380d bump: v3.10.2 (#1431) 2025-01-06 14:08:36 +01:00
Alex Christoffer Rasmussen cd913fc144 small strict improvements (#1428)
* small `strict` improvements

* fix type

* use isObject
2025-01-06 14:07:36 +01:00
Alex Christoffer Rasmussen 743d7fa782 fix: validateLinkStart (#1430)
* fix validateLinkStart

* update comment

* use fallback

* cast to MaybeString

* update comment

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-06 13:12:03 +01:00
Carlos Valente 891c43ba77 refactor: countToEnd does not cause rundown recalculation 2025-01-05 15:18:28 +01:00
Carlos Valente fba20b889e refactor: only allow testing saved URLs 2025-01-05 15:17:53 +01:00
Carlos Valente bc4d913ba7 refactor: always show client path 2025-01-05 15:17:53 +01:00
Alex Christoffer Rasmussen 533f9798e8 convert to and from database type (#1410)
* create db event type

* dont use it yet

* convert to and from database type

* type needed in one place in client

* use flatMap to filter out unknow stuff

* leave revision in save file

* move to utils

* make test

* destructur entry

* simple test

* throw in rundownToDatabaseRundown

* clerify comment

* Refactor: avoid shadowing the rundown variable in rundownCache (#1409)


---------

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

* rename to DAO

* add simple edit test

* only the generate function clears the state state

* strict guard for custom fields

* fix small strict issues

* ensure deep copy

* safely set isStale

* todo is done

* small type error

* these functions can work on DAO entries

* only reat rundown as DAO on initial load and the cast immediately and esure all fields are present

* eslint comment

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

* fix spelling

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

* fix spelling

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

* comment not needed

* setIsStale

* remove DAO

* clean up commits

* remove unneeded async/await

* revert test

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-05 14:35:58 +01:00
Carlos Valente 1b62ed5f2d bump version to 3.10.1 2025-01-04 23:12:57 +01:00
Carlos Valente bd0c951da8 declare env variables 2025-01-04 23:12:57 +01:00
Carlos Valente 2d5b86808f refactor: cleanup pin styles 2025-01-04 16:39:47 +01:00
Carlos Valente c69bce3e89 refactor: cleanup picker implementations 2025-01-03 13:02:26 +01:00
Carlos Valente d62e314d19 bump version to 3.10.0 2025-01-03 12:56:37 +01:00
Shobhit-Nagpal dbd56522f6 refactor: swatch picker and inline colour picker 2025-01-02 21:30:41 +01:00
Shobhit-Nagpal 1ba48c6a6f refactor: inline colour picker 2025-01-02 21:30:41 +01:00
Carlos Valente f4f24da47b refactor: stop port change in dev 2025-01-02 14:18:19 +01:00
Carlos Valente c3609b2aed chore: extract middleware config 2025-01-02 14:18:19 +01:00
Carlos Valente 260d326989 chore: add noindex to app entry 2024-12-31 23:20:28 +01:00
Carlos Valente a21e32a097 refactor: expose indent style 2024-12-31 15:01:34 +01:00
Carlos Valente 617e4b579b refactor: introduce empty placeholder 2024-12-31 15:01:34 +01:00
Carlos Valente 44f65b2fcc refactor: extract utility 2024-12-31 13:04:00 +01:00
Carlos Valente 760de608e3 refactor: simplify logic 2024-12-31 13:01:22 +01:00
Carlos Valente 0dfef732bf refactor: prevent race conditions on init 2024-12-29 22:53:41 +01:00
Carlos Valente f65ec0da3c chore: upgrade dependencies 2024-12-29 20:02:56 +01:00
Shobhit Nagpal 6bbf8ce795 refactor: event editor color picker (#1400)
* refactor: event editor color picker

* refactor: throttle and debounce function signature
2024-12-27 10:57:48 +01:00
Carlos Valente 7f39d035fd refactor: show delayed times 2024-12-22 21:43:37 +01:00
Carlos Valente bdd2a62cee feat: allow editing times in cuesheet 2024-12-22 21:43:37 +01:00
Carlos Valente b38a7e5c92 refactor: allow submitting same value 2024-12-22 21:43:37 +01:00
Carlos Valente b173b5a8e8 chore: update manifest 2024-12-22 20:06:35 +01:00
Alex Christoffer Rasmussen 29243e3f6c detect EADDRINUSE on server start (#1348)
* detect `EADDRINUSE` on server start

* @ts-expect-error

* send portError to electron

* use escalateErrorFn

* dont move in docker

* allow errors to be sendt to electron UI

* Log after the new port is found

* Update comment

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

* update comment

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

* update comment

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

* rename to escalate

* extract `serverTryDesiredPort`

* type check server.address()

* request shutdown on `unrecoverable` `escalateError`

* combine network utils

* reject promise

* not unrecoverable by default

* unneeded assignment

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-12-22 16:23:47 +01:00
Carlos Valente 1af394cd49 chore: upgrade turbo 2024-12-21 22:46:07 +01:00
Carlos Valente 1c85265623 fix: section can only be selected if parent is 2024-12-21 13:56:23 +01:00
Carlos Valente 6199d0ad10 refactor: improve section style and composition 2024-12-20 21:24:34 +01:00
Carlos Valente 2f2c26ed00 chore: rename time-to-end to count-to-end 2024-12-20 21:24:34 +01:00
Carlos Valente c9ef2465f8 refactor: visibility options 2024-12-20 21:15:26 +01:00
Carlos Valente 430b72c1d0 fix: missing return when data is handled 2024-12-20 21:15:26 +01:00
Carlos Valente 88b114020b feat: edit duration in cuesheet 2024-12-20 21:15:26 +01:00
Carlos Valente c445c42f8a refactor: extract edit element in table 2024-12-20 21:15:26 +01:00
Carlos Valente 2fc4e6da16 refactor: extract utility type 2024-12-20 21:15:26 +01:00
Carlos Valente 4cbe25a845 refactor: simplify update functions 2024-12-20 21:15:26 +01:00
Carlos Valente ab6765b332 refactor: tweak table element styles 2024-12-20 21:15:26 +01:00
Carlos Valente fb38d82855 refactor: show cuesheet as long as we have data 2024-12-20 21:15:26 +01:00
Carlos Valente 1061ed2a0e refactor: improve fallback timer consistency 2024-12-20 15:27:57 +01:00
Carlos Valente 3d37f42fe0 refactor: tweak styling 2024-12-20 09:03:02 +01:00
Carlos Valente 1bdea8e436 refactor: edit cue field 2024-12-20 09:03:02 +01:00
Carlos Valente 2c25910863 refactor: add table actions 2024-12-20 09:03:02 +01:00
Carlos Valente 8f30c0a7df refactor: restructure element composition 2024-12-20 09:03:02 +01:00
Carlos Valente 67fa747aae refactor: extract cuesheet dnd 2024-12-20 09:03:02 +01:00
Carlos Valente 8116b169b2 refactor: generalise event editor 2024-12-20 09:03:02 +01:00
Carlos Valente ec823e0095 refactor: improve cuesheet composition
refactor: simplify placing events in rundown

m
2024-12-20 09:03:02 +01:00
Carlos Valente c7faab00a3 feat: create offline indicator 2024-12-19 22:15:48 +01:00
Alex Christoffer Rasmussen cfcf6a5684 extract isPlaybackActive to a global util function (#1393) 2024-12-18 12:44:48 +01:00
Carlos Valente df1cf7a96b feat: parse timeToEnd from excel 2024-12-16 21:59:38 +01:00
Carlos Valente 0f3feca7ab refactor: extract time-to-end 2024-12-16 21:59:38 +01:00
Carlos Valente 41fe213ebb refactor: settings in params 2024-12-16 15:13:49 +01:00
Carlos Valente e91d5f5cd9 refactor: always show settings 2024-12-16 15:13:49 +01:00
Carlos Valente bedab221d9 chore: dedupe lock file 2024-12-16 15:13:49 +01:00
Carlos Valente 97208c052a chore: upgrade dependencies 2024-12-16 15:13:49 +01:00
Alex Christoffer Rasmussen b048d0f88d Edit in cuesheet (#1372)
* pass on all event edits

* MakePublic

* trim value in cell

* edit notes

* title

* add key check

* don't log error

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

* checkbox

* refactor

* remove log

* fix test

* use row number to test value

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-12-16 14:27:25 +01:00
asharonbaltazar d8f7d4bba6 feat(ui): add warning in view params when styles are overriden (#1384)
* feat: add warning in view params when styles are overriden

* refactor: style tweaks

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-12-15 10:40:27 -05:00
Carlos Valente ed65a4db67 refactor: smaller settings 2024-12-15 11:04:15 +01:00
Carlos Valente 4a70bcfe64 chore: remove unused 2024-12-15 11:04:15 +01:00
Carlos Valente 89e68c3b0e fix: correct aria label 2024-12-15 11:04:15 +01:00
Carlos Valente 976dc9cbde refactor: tweak header style 2024-12-15 11:04:15 +01:00
Carlos Valente 3701ba5174 refactor: handle missing data 2024-12-15 11:04:15 +01:00
Carlos Valente acc6ba63ba refactor: keep drawer open on submit 2024-12-15 11:04:15 +01:00
Carlos Valente 6c5afcb8d9 refactor: improve empty state for pages 2024-12-15 09:55:52 +01:00
Carlos Valente d46dfcf82e refactor: improve redirect 2024-12-14 08:25:53 +01:00
Carlos Valente 4c6acc4013 refactor: improve active styles for navigation buttons 2024-12-13 11:24:03 +01:00
Carlos Valente 26a24449de refactor: prevent modal close on click outside 2024-12-13 10:40:16 +01:00
Carlos Valente a1fb642441 bump version to 3.9.5 2024-12-13 10:40:16 +01:00
Carlos Valente b64b154330 refactor: disable shutdown 2024-12-13 10:39:58 +01:00
Carlos Valente 5032cbf65a refactor: prevent instantiating unavailable services 2024-12-13 10:39:58 +01:00
Carlos Valente 466360f9d1 chore: note service limitations 2024-12-13 10:39:58 +01:00
Carlos Valente ef2ea9a1da fix: recover edit menu and shortcuts 2024-12-13 10:39:45 +01:00
Carlos Valente fb83f48752 refactor: maintain an isOnline flag in client 2024-12-13 10:39:30 +01:00
Carlos Valente c1d53b0e55 refactor: improve empty state for project info 2024-12-13 10:12:06 +01:00
Alex Christoffer Rasmussen 7915cc822d Dockersafe rename (#1370)
* add dockerSafeRename function

* replace all rename functions

* add explanation to function
2024-12-10 23:27:51 +01:00
Carlos Valente 7dbf64d100 chore: restructure directory 2024-12-10 15:53:53 +01:00
Carlos Valente 6ffbf2af9d chore: move files to new structure 2024-12-10 15:53:53 +01:00
Carlos Valente fa7ec623f1 bump version to 3.9.4 2024-12-08 08:52:18 +01:00
Carlos Valente bfb9b51073 fix: secure protocol resolution 2024-12-08 08:52:18 +01:00
Carlos Valente 8bc3f5a56c Fix links (#1366)
* chore: remove unused code

* bump version to 3.9.3

* chore: use constant links

* refactor: build links

* chore: ignore ontime-data
2024-12-06 16:48:35 +01:00
Carlos Valente acf1afb5e9 fix: prevent overflow 2024-12-06 14:54:41 +01:00
Carlos Valente d8e2a8d092 bump version to 3.9.2 2024-12-04 21:41:49 +01:00
Carlos Valente 8e587128a1 refactor: ensure element height 2024-12-04 21:41:49 +01:00
Alex Christoffer Rasmussen 607bc40673 create addTime function in simple timer (#1363)
* create addTime function in simple timer
2024-12-04 20:25:49 +01:00
Carlos Valente e6aa7404f7 chore: correct documentation 2024-12-04 19:49:49 +01:00
Carlos Valente d622a7738f refactor: remove deprecated field 2024-12-04 19:49:49 +01:00
Carlos Valente b9ba416366 refactor: add prefix to relative assets
refactor: add prefix to server entrypoints
refactor: add prefix to express router
refactor: add prefix to router basename
2024-12-04 19:49:49 +01:00
Carlos Valente b3ce247f54 bump version to 3.9.1 2024-12-02 14:45:57 +01:00
Carlos Valente 82a2660fbc fix: router prefix 2024-12-02 14:45:57 +01:00
Carlos Valente 1e8706aa73 feat: allow add time to aux timer 2024-12-02 13:30:22 +01:00
Carlos Valente 7dec32aea7 bump version to 3.9.0 2024-12-02 13:30:11 +01:00
Carlos Valente 7e4cf6ffce refactor: improve time added display 2024-12-01 21:06:33 +01:00
Carlos Valente e6dfe9c363 fix: rolling over midnight wrongly classified as skip 2024-12-01 21:06:33 +01:00
Carlos Valente 29e682ee06 refactor: remove frozen implementation 2024-11-30 22:31:18 +01:00
Carlos Valente 55d349877b fix: prevent stale data in finder 2024-11-30 22:30:19 +01:00
Alex Christoffer Rasmussen 8f2a93d6a3 FIX: OSC TX and RX shutdown was swapped (#1352) 2024-11-30 22:27:36 +01:00
Carlos Valente 6529a05e2a fix: previous eventid is any entry 2024-11-30 21:59:53 +01:00
Alex Christoffer Rasmussen 0f5c47bc82 Small fixes (#1345)
* cleanup: remove console log

* use UI Index
2024-11-30 10:11:36 +01:00
Carlos Valente f561e52bb5 refactor: prevent caching html files 2024-11-29 19:36:39 +01:00
Carlos Valente 35f4472daa chore: define test workspaces 2024-11-29 19:36:39 +01:00
Carlos Valente 272f788c15 refactor: split vendor 2024-11-29 19:36:39 +01:00
Carlos Valente 85b71627a8 refactor: exclude html from compression 2024-11-29 19:36:39 +01:00
Carlos Valente 6e51e5083b refactor: allow route prefix 2024-11-29 19:36:39 +01:00
Ary Neto 1f998fda74 reverted unnecessary change 2024-11-28 22:10:48 +01:00
Ary Neto e7b5baa49a fix: file picker opening on delete 2024-11-28 22:10:48 +01:00
Carlos Valente f3f4cdb19f feat: add support for chinese 2024-11-25 09:55:07 +01:00
Carlos Valente b489214004 refactor: allow copy-paste in interface 2024-11-25 09:54:43 +01:00
Carlos Valente cc55414b57 refactor: show project description 2024-11-25 09:54:43 +01:00
Carlos Valente e694cb7556 refactor: style tweaks to cuesheet 2024-11-25 09:54:43 +01:00
Carlos Valente 988100c1d9 chore: remove extra character 2024-11-25 09:54:04 +01:00
Carlos Valente b4eabfae72 feat: trigger welcome modal from electron 2024-11-25 09:54:04 +01:00
Carlos Valente 22fe65c655 feat: show welcome screen on first login 2024-11-25 09:54:04 +01:00
Carlos Valente 7bf90d669a refactor: expose api for loading demo project 2024-11-25 09:54:04 +01:00
Carlos Valente a518dc4882 refactor: consolidate event editor styles 2024-11-25 09:54:04 +01:00
Carlos Valente dd5ffb2f47 refactor: improve app data 2024-11-25 09:54:04 +01:00
Carlos Valente c5a5eab225 refactor: extract utility hook 2024-11-25 09:54:04 +01:00
Carlos Valente 5d6345717f chore: update ontime assets 2024-11-25 09:54:04 +01:00
asharonbaltazar 4c88f90a15 feat(ui): disable menu buttons with locked param (#1332)
* feat: add lock icon when param `locked=true`

* chore: move `isViewLocked` logic up the component tree

* chore: remove extra container from lock icon

* chore: move fading logic into separate hook

* chore: move locked icon into separate component

* chore: rename variables
2024-11-18 09:49:49 -05:00
Carlos Valente 238eb49b5c bump version to 3.8.0 2024-11-18 14:57:40 +01:00
Carlos Valente 7aec3e0d73 feat: project info view 2024-11-18 14:56:30 +01:00
Carlos Valente 312b294a61 refactor: update test 2024-11-18 14:56:30 +01:00
Carlos Valente 9bfa3e9f8e feat: implement logo in all views 2024-11-18 13:48:05 +01:00
Carlos Valente 9a2cfb59e7 fix: issue with colour string sanitation 2024-11-18 13:12:50 +01:00
Ary a6e1cbbbce Upload corporate logo (#1314)
* feat: upload logo to project data

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-11-17 22:55:20 +01:00
Carlos Valente 8dd448db44 feat: implement naive client ping 2024-11-17 21:41:10 +01:00
Carlos Valente d87542dcf7 refactor: add cloud runtime 2024-11-17 21:41:10 +01:00
Carlos Valente 18575fc558 fix: separator collides with space indicator 2024-11-13 20:23:04 +01:00
Carlos Valente 04e2593939 style: clarify select component 2024-11-13 20:23:04 +01:00
Carlos Valente 73d6b97afa fix: issue with casing in custom fields 2024-11-13 20:23:04 +01:00
Carlos Valente b55724ad0e bump version to 3.7.1 2024-11-12 21:46:31 +01:00
Carlos Valente 3a7c2b8a55 refactor: refetch on load 2024-11-12 21:46:31 +01:00
Carlos Valente 420dfae652 refactor: clarify url preset form 2024-11-09 13:35:43 +01:00
Carlos Valente fb6896de76 refactor: show timer clock in preview 2024-11-07 21:39:04 +01:00
Carlos Valente f574fdbc02 refactor: hide progress bar in timer none 2024-11-07 21:39:04 +01:00
Carlos Valente 597be4e710 refactor: omit modifier in timer type none 2024-11-07 21:39:04 +01:00
Carlos Valente 6e8734c73f fix: prevent stale data on project change 2024-11-07 21:33:20 +01:00
Carlos Valente 9440ad17d2 refactor: show leading zeroes in minimal 2024-11-07 21:33:07 +01:00
Carlos Valente 9ba94288a1 refactor: remove style override 2024-11-01 15:42:32 +01:00
Carlos Valente 6c78d25af1 fix: issue with rem 2024-11-01 15:42:32 +01:00
Carlos Valente 0d8c186c77 feat: add time type none 2024-11-01 15:40:42 +01:00
Carlos Valente 7ccd41eeee fix: validation cycle 2024-10-29 20:23:12 +01:00
Carlos Valente 43b390a079 refactor: register modal in electron 2024-10-29 20:23:12 +01:00
Carlos Valente 80ff3251ea refactor: allow two NIF links in macbook screens 2024-10-29 20:23:12 +01:00
Carlos Valente 3591b93c2d refactor: handle thrown errors 2024-10-29 20:23:12 +01:00
Carlos Valente b637621871 refactor: guarantee minimum width 2024-10-29 20:23:12 +01:00
Carlos Valente ed2efe183b feat: quick project wizard 2024-10-29 20:23:12 +01:00
Carlos Valente 27f99b4c87 refactor: generalise modal footer style 2024-10-29 20:23:12 +01:00
Carlos Valente ed4d0f060b fix: reset rem value 2024-10-29 20:23:12 +01:00
Carlos Valente 7c19c52332 chore: directory restructure
merge with directory
2024-10-29 20:23:12 +01:00
Carlos Valente b1bff22b84 refactor: improve visibility of time-to-end 2024-10-29 20:02:23 +01:00
Carlos Valente 83b9fde45e refactor: improve visibility of extract element 2024-10-29 20:02:23 +01:00
Carlos Valente 37974df5c6 fix: allow changing custom field colour 2024-10-29 09:42:11 +01:00
Carlos Valente 161ec383fd refactor: unify localhost logic 2024-10-29 09:42:01 +01:00
Carlos Valente de38f9d467 refactor: show new app version 2024-10-29 09:42:01 +01:00
Carlos Valente 8ad1f2cb38 refactor: no style overrides in production views 2024-10-29 08:23:44 +01:00
Carlos Valente 143917f11a feat: event finder 2024-10-26 11:57:20 +02:00
Carlos Valente a8756c8afe refactor: clear state on sheet flows 2024-10-25 21:58:37 +02:00
Carlos Valente 5501f57b45 feat: add link to context menu 2024-10-25 19:44:55 +02:00
584 changed files with 19260 additions and 10766 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }}
run: pnpm dist-mac
run: pnpm dist-mac --env-mode=loose
timeout-minutes: 60
- name: Release
+2
View File
@@ -28,6 +28,7 @@ test-results
playwright-report
/playwright/.cache/
automated-screenshots
e2e/tests/fixtures/tmp/*
# production
build/
@@ -39,6 +40,7 @@ dist/
# docker utils
ontime-db
ontime-external/
ontime-data/
# versioning file
**/ONTIME_VERSION.js
+1 -1
View File
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Build docker image from__ by running `docker build -t getontime/ontime .`
- __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands
+1
View File
@@ -24,6 +24,7 @@ COPY --from=builder /app/apps/client/build ./client/
COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
+5
View File
@@ -2,6 +2,7 @@
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
![Homebrew Cask Version](https://img.shields.io/homebrew/cask/v/ontime)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
@@ -12,7 +13,11 @@
- 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>
... 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://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.6.2",
"version": "3.14.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
-1
View File
@@ -15,7 +15,6 @@
],
"plugins": [
"react",
"testing-library",
"simple-import-sort",
"@tanstack/query"
],
+26 -35
View File
@@ -1,37 +1,28 @@
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<link rel='icon' href='/favicon.ico' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta name='theme-color' content='#121212' />
<meta name='ontime' content='ontime - event timer manager' />
<link rel='apple-touch-icon' href='/logo192.png' />
<link
rel='icon'
type='image/png'
sizes='32x32'
href='/favicon-32x32.png'
/>
<link
rel='icon'
type='image/png'
sizes='16x16'
href='/favicon-16x16.png'
/>
<link rel='manifest' href='/site.webmanifest' />
<link rel='manifest' href='/manifest.json' />
<title>ontime</title>
<style>
body,
html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id='root'></div>
<script type='module' src='/src/index.tsx'></script>
</body>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="/" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" />
<link rel="apple-touch-icon" href="/ontime-logo.png" />
<link rel="icon" type="image/png" href="/ontime-logo.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="manifest" href="/manifest.json" />
<meta name="robots" content="noindex" />
<title>ontime</title>
<style>
body,
html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+14 -17
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.6.2",
"version": "3.14.1",
"private": true,
"type": "module",
"dependencies": {
@@ -8,15 +8,17 @@
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.6.2",
"@mantine/hooks": "^7.13.3",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.19.0",
"@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3",
"@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.20.5",
"autosize": "^6.0.1",
"axios": "^1.2.0",
"color": "^4.2.3",
@@ -26,11 +28,11 @@
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.49.2",
"react-hook-form": "^7.53.1",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1",
"zustand": "^4.5.2"
"zustand": "^5.0.3"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -39,7 +41,7 @@
"build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "vite build",
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"test": "vitest",
@@ -62,13 +64,9 @@
"devDependencies": {
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1",
@@ -79,17 +77,16 @@
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"eslint-plugin-testing-library": "^5.9.1",
"jsdom": "^21.1.0",
"happy-dom": "^16.7.2",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "catalog:",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "^5.2.11",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-compression2": "^1.3.3",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.6.0"
"vitest": "catalog:"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

+2 -9
View File
@@ -4,18 +4,11 @@
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
"src": "ontime-logo.png",
"type": "image/png"
}
],
"scope": "/",
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+10 -1
View File
@@ -1 +1,10 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
{
"name": "",
"short_name": "",
"icons": [
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
],
"theme_color": "#2B5ABC",
"background_color": "#101010",
"display": "standalone"
}
+2 -1
View File
@@ -11,6 +11,7 @@ import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
connectSocket();
@@ -19,7 +20,7 @@ function App() {
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
<BrowserRouter basename={baseURI}>
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
+25 -6
View File
@@ -19,29 +19,33 @@ import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
const TimerView = React.lazy(() => import('./views/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Public = React.lazy(() => import('./features/viewers/public/Public'));
const Public = React.lazy(() => import('./views/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
const STimer = withPreset(withData(TimerView));
const SMinimalTimer = withPreset(withData(MinimalTimerView));
const SClock = withPreset(withData(ClockView));
const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage));
const SProjectInfo = withPreset(withData(ProjectInfo));
const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const PCuesheet = withPreset(Cuesheet);
const POperator = withPreset(Operator);
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
@@ -142,11 +146,26 @@ export default function AppRouter() {
</ViewLoader>
}
/>
<Route
path='/info'
element={
<ViewLoader>
<SProjectInfo />
</ViewLoader>
}
/>
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />
<Route path='/cuesheet' element={<PCuesheet />} />
<Route
path='/op'
element={
<ViewLoader>
<POperator />
</ViewLoader>
}
/>
{/*/!* Protected Routes - Elements *!/*/}
<Route
+85
View File
@@ -0,0 +1,85 @@
import axios from 'axios';
import type {
Automation,
AutomationDTO,
AutomationOutput,
AutomationSettings,
Trigger,
TriggerDTO,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
return res.data;
}
/**
* HTTP request to edit the automations settings
*/
export async function editAutomationSettings(
automationSettings: Partial<AutomationSettings>,
): Promise<AutomationSettings> {
const res = await axios.post(automationsPath, automationSettings);
return res.data;
}
/**
* HTTP request to create a new automation trigger
*/
export async function addTrigger(trigger: TriggerDTO): Promise<Trigger> {
const res = await axios.post(`${automationsPath}/trigger`, trigger);
return res.data;
}
/**
* HTTP request to update an automation trigger
*/
export async function editTrigger(id: string, trigger: Trigger): Promise<Trigger> {
const res = await axios.put(`${automationsPath}/trigger/${id}`, trigger);
return res.data;
}
/**
* HTTP request to delete an automation trigger
*/
export function deleteTrigger(id: string): Promise<void> {
return axios.delete(`${automationsPath}/trigger/${id}`);
}
/**
* HTTP request to create a new automation
*/
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, automation);
return res.data;
}
/**
* HTTP request to update a automation
*/
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
return res.data;
}
/**
* HTTP request to delete a automation
*/
export function deleteAutomation(id: string): Promise<void> {
return axios.delete(`${automationsPath}/automation/${id}`);
}
/**
* HTTP request to test automation output
* The return is irrelevant as we care for the resolution of the promise
*/
export function testOutput(output: AutomationOutput): Promise<void> {
return axios.post(`${automationsPath}/test`, output);
}
+7 -15
View File
@@ -1,9 +1,11 @@
import { serverURL } from '../../externals';
// keys in tanstack store
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
export const APP_VERSION = ['appVersion'];
export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown'];
@@ -12,20 +14,9 @@ export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
// resolve location
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
export const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
// resolve port
const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
export const projectDataURL = `${serverURL}/project`;
@@ -35,3 +26,4 @@ export const ontimeURL = `${serverURL}/ontime`;
export const userAssetsPath = 'user';
export const cssOverridePath = 'styles/override.css';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
+21 -4
View File
@@ -1,7 +1,8 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -12,7 +13,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 });
}
/**
@@ -42,7 +43,7 @@ export async function downloadCSV(fileName: string = 'rundown') {
const { project, rundown, customFields } = data;
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSV(sheetData);
const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
@@ -86,6 +87,14 @@ export async function createProject(
return res.data;
}
/**
* HTTP request to create a project file
*/
export async function quickProject(data: QuickStartData): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/quick`, data);
return res.data;
}
/**
* HTTP request to get the list of available project files
*/
@@ -104,6 +113,14 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
return res.data;
}
/**
* HTTP request to load the demo project file
*/
export async function loadDemo(): Promise<MessageResponse> {
const res = await axios.post(`${dbPath}/demo`);
return res.data;
}
/**
* HTTP request to duplicate a project file
*/
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { HttpSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const httpPath = `${apiEntryUrl}/http`;
/**
* HTTP request to retrieve http settings
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(httpPath);
return res.data;
}
/**
* HTTP request to mutate http settings
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(httpPath, data);
}
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { OSCSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const oscPath = `${apiEntryUrl}/osc`;
/**
* HTTP request to retrieve osc settings
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(oscPath);
return res.data;
}
/**
* HTTP request to mutate osc settings
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(oscPath, data);
}
+16 -1
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { ProjectData } from 'ontime-types';
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
import { apiEntryUrl } from './constants';
@@ -19,3 +19,18 @@ export async function getProjectData(): Promise<ProjectData> {
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
return axios.post(projectPath, data);
}
/**
* HTTP request to upload a project logo
*/
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
const formData = new FormData();
formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
}
+26
View File
@@ -0,0 +1,26 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(`${reportUrl}/`);
return res.data;
}
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types';
import { apiEntryUrl } from './constants';
@@ -16,7 +16,7 @@ export async function fetchNormalisedRundown(): Promise<RundownCached> {
/**
* HTTP request to post new event
*/
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data);
}
+13
View File
@@ -12,3 +12,16 @@ export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`);
return res.data;
}
/**
* HTTP request to get a pre-authenticated URL
*/
export async function generateUrl(
baseUrl: string,
path: string,
lock: boolean,
authenticate: boolean,
): Promise<string> {
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
return res.data.url;
}
+7
View File
@@ -19,3 +19,10 @@ export async function getSettings(): Promise<Settings> {
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data);
}
/**
* Allows setting the welcome modal dialog state from the clients
*/
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
+2
View File
@@ -23,6 +23,8 @@ export function maybeAxiosError(error: unknown) {
}
}
return `${statusText}: ${data}`;
} else if (error instanceof Error) {
return error.message;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
@@ -1,12 +0,0 @@
@use "../../../theme/mixins" as *;
.link {
@include action-link;
font-size: $inner-section-text-size;
}
.linkIcon {
margin-left: $element-inner-spacing;
display: inline-block;
@include rotate-fourty-five;
}
@@ -1,26 +0,0 @@
import { MouseEvent, ReactNode } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { openLink } from '../../utils/linkUtils';
import style from './AppLink.module.scss';
interface AppLinkProps {
href: string;
children: ReactNode;
}
export default function AppLink(props: AppLinkProps) {
const { href, children } = props;
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
{children} <IoArrowUp className={style.linkIcon} />
</a>
);
}
@@ -0,0 +1,26 @@
.subtle {
aspect-ratio: 1;
height: 2rem;
height: 2rem;
display: grid;
place-content: center;
background: $gray-1050;
color: $blue-400;
border: 1px solid transparent;
border-radius: 3px;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
@@ -0,0 +1,14 @@
import { ButtonHTMLAttributes } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss';
export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
const { className, children, ...buttonProps } = props;
return (
<button className={cx([style.subtle, className])} {...buttonProps}>
{children}
</button>
);
}
@@ -1,6 +0,0 @@
.buttonSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $element-spacing;
margin-top: -$element-spacing;
}
@@ -0,0 +1,24 @@
.textEntry {
grid-area: input;
display: flex;
align-items: center;
gap: 0.5rem;
color: $label-gray;
}
.inlineEntry {
display: grid;
grid-template-areas:
'label label'
'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
}
.redirect {
grid-area: btn;
}
.label {
color: $label-gray;
}
@@ -1,68 +1,125 @@
import { useState } from 'react';
import {
Button,
IconButton,
Input,
InputGroup,
InputLeftAddon,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
} from '@chakra-ui/react';
import { IoArrowForward } from '@react-icons/all-files/io5/IoArrowForward';
import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import Info from '../info/Info';
import AppLink from '../link/app-link/AppLink';
import style from './ClientModal.module.scss';
import style from './RedirectClientModal.module.scss';
interface RedirectClientModalProps {
id: string;
name?: string;
path?: string;
name: string;
currentPath: string;
origin: string;
isOpen: boolean;
onClose: () => void;
}
export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
const { id, isOpen, name, currentPath, origin, onClose } = props;
const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/');
const { setRedirect } = setClientRemote;
const handleRedirect = () => {
if (path !== currentPath && path !== '') {
setRedirect({ target: id, redirect: path });
const handleRedirect = (newPath: string) => {
if (newPath === '/' || newPath === currentPath) {
return;
}
setRedirect({ target: id, redirect: newPath });
onClose();
};
const host = window.location.origin;
const canSubmit = path !== currentPath && path !== '';
const enabledPresets = data.filter((preset) => preset.enabled);
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent>
<ModalContent maxWidth='max(480px, 35vw)'>
<ModalHeader>Redirect: {name}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<InputGroup variant='ontime-filled' size='md'>
<InputLeftAddon>{host}</InputLeftAddon>
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
</InputGroup>
</ModalBody>
<ModalFooter>
<div className={style.buttonSection}>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
Submit
</Button>
<Info>
Remotely redirect the client to a different URL. <br />
Either by selecting a URL Preset or entering a custom path.
<br />
<br />
<AppLink search='settings=feature_settings__urlpresets'>Manage URL Presets</AppLink>
</Info>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.textEntry}>
<Select
size='md'
variant='ontime'
isDisabled={enabledPresets.length === 0}
onChange={(event) => setSelected(event.target.value)}
>
<option value='/'>Select view or preset</option>
{navigatorConstants.map((view) => {
return (
<option key={view.url} value={`/${view.url}`}>
{view.label}
</option>
);
})}
{enabledPresets.map((preset) => {
return (
<option key={preset.pathAndParams} value={preset.pathAndParams}>
{`Preset: ${preset.alias}`}
</option>
);
})}
</Select>
<IconButton
variant='ontime-filled'
size='md'
aria-label='Redirect to preset'
className={style.redirect}
icon={<IoArrowForward />}
isDisabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
/>
</div>
</div>
</ModalFooter>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
{origin}
<Input
variant='ontime-filled'
size='md'
placeholder='eg. /minimal?key=0000ffff'
value={path}
onChange={(event) => setPath(event.target.value)}
/>
</label>
<IconButton
variant='ontime-filled'
size='md'
aria-label='Redirect'
isDisabled={path === currentPath || path === ''}
className={style.redirect}
icon={<IoArrowForward />}
onClick={() => handleRedirect(path)}
/>
</div>
</ModalBody>
</ModalContent>
</Modal>
);
@@ -13,8 +13,6 @@ import {
import { setClientRemote } from '../../hooks/useSocket';
import style from './ClientModal.module.scss';
interface RenameClientModalProps {
id: string;
name?: string;
@@ -53,14 +51,12 @@ export function RenameClientModal(props: RenameClientModalProps) {
/>
</ModalBody>
<ModalFooter>
<div className={style.buttonSection}>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
Submit
</Button>
</div>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
Submit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
@@ -9,32 +9,23 @@ import style from './DelayIndicator.module.scss';
interface DelayIndicatorProps {
delayValue?: number;
tooltipPrefix?: string;
}
export default function DelayIndicator(props: DelayIndicatorProps) {
const { delayValue } = props;
const { delayValue, tooltipPrefix } = props;
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
if (typeof delayValue !== 'number' || delayValue === 0) {
return null;
}
return null;
const delayString = tooltipPrefix
? `${tooltipPrefix} ${millisToDelayString(delayValue)}`
: millisToDelayString(delayValue);
return (
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
</Tooltip>
);
}
@@ -26,17 +26,22 @@ class ErrorBoundary extends React.Component {
error,
errorInfo: info,
});
this.reportContent = `${error} ${info.componentStack}`;
const appState = runtimeStore.getState();
if (appState.ping < 0) {
return;
}
Sentry.withScope((scope) => {
scope.setExtras({
error,
store: runtimeStore.getState(),
store: appState,
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
});
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
});
this.reportContent = `${error} ${info.componentStack}`;
}
render() {
@@ -0,0 +1,21 @@
.infoLabel {
display: flex;
align-items: center;
gap: $element-spacing;
background-color: $gray-1200;
border-radius: 3px;
font-size: $text-body-size;
padding: 1rem;
.content {
color: $gray-200;
}
svg {
min-width: 1.5rem;
align-self: start;
font-size: 1.5rem;
color: $info-blue;
}
}
@@ -0,0 +1,21 @@
import { PropsWithChildren } from 'react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import { cx } from '../../utils/styleUtils';
import style from './Info.module.scss';
interface InfoProps {
className?: string;
}
export default function Info(props: PropsWithChildren<InfoProps>) {
const { className, children } = props;
return (
<div className={cx([style.infoLabel, className])}>
<IoAlertCircle />
<div>{children}</div>
</div>
);
}
@@ -16,11 +16,11 @@ export default function Swatch(props: SwatchProps) {
const handleClick = () => {
onClick?.(color);
};
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
if (!color) {
return (
<div className={`${classes} ${style.center}`} onClick={handleClick}>
<div className={classes} onClick={handleClick}>
<IoBan />
</div>
);
@@ -0,0 +1,60 @@
import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop';
import { ViewSettings } from 'ontime-types';
import { debounce } from '../../../utils/debounce';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import PopoverPicker from '../popover-picker/PopoverPicker';
import style from './SwatchSelect.module.scss';
interface SwatchPickerProps {
color: string;
isSelected?: boolean;
onChange: (name: string) => void;
alwaysDisplayColor?: boolean;
}
export default function SwatchPicker(props: SwatchPickerProps) {
const { color, onChange, isSelected, alwaysDisplayColor } = props;
const debouncedOnChange = useCallback(
debounce((newValue: string) => {
onChange(newValue);
}, 500),
[onChange],
);
const displayColor = alwaysDisplayColor || isSelected ? color : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={displayColor} onChange={debouncedOnChange}>
<div
className={cx([style.swatch, isSelected && style.selected, style.selectable])}
style={{ backgroundColor: displayColor }}
>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
export function SwatchPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
const displayColor = typeof value === 'string' ? value : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={value as string} onChange={onChange}>
<div className={cx([style.swatch, style.selectable])} style={{ backgroundColor: displayColor }}>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
@@ -1,15 +1,18 @@
.list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem
gap: 0.5rem;
}
.swatch {
width: 2rem;
height: 2rem;
aspect-ratio: 1;
border-radius: 99px;
border: 2px solid $gray-1200;
color: $ui-white;
display: grid;
place-content: center;
&.selected {
border: 2px solid $blue-500;
@@ -17,11 +20,9 @@
&.selectable {
cursor: pointer;
&:hover {
border: 2px solid $blue-300;
}
}
}
.center {
display: grid;
place-content: center;
color: $ui-white;
}
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import Swatch from './Swatch';
import SwatchPicker from './SwatchPicker';
import style from './SwatchSelect.module.scss';
@@ -43,6 +44,7 @@ export default function SwatchSelect(props: ColourInputProps) {
{colours.map((colour) => (
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
))}
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
</div>
);
}
@@ -1,16 +1,20 @@
.swatch {
width: 24px;
height: 24px;
border-radius: 12px;
border: 1px solid white;
box-shadow: 0 0 0 1px $gray-300;
cursor: pointer;
.input {
color: $gray-200;
border: 1px solid transparent;
border-radius: 0 0 8px 8px;
background-color: $gray-1200;
padding: 0 0.5rem;
font-size: 1rem;
height: 2rem;
outline: none;
transition-property: box-shadow;
transition-duration: $transition-time-action;
&:hover {
background-color: $gray-1100;
}
&:focus {
background-color: $gray-1000;
color: $gray-50;
border: 1px solid $blue-500;
}
}
.swatch:hover {
box-shadow: 0 0 0 1px $blue-300;
}
@@ -1,34 +1,22 @@
import { HexAlphaColorPicker } from 'react-colorful';
import { useController, UseControllerProps } from 'react-hook-form';
import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import style from './PopoverPicker.module.scss';
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
return <PopoverPicker color={value as string} onChange={onChange} />;
}
interface PopoverPickerProps {
color: string;
onChange: (color: string) => void;
}
export default function PopoverPicker(props: PopoverPickerProps) {
const { color, onChange } = props;
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
const { color, onChange, children } = props;
return (
<Popover>
<PopoverTrigger>
<div className={style.swatch} style={{ backgroundColor: color }} />
</PopoverTrigger>
<PopoverContent style={{ width: 'auto' }}>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
<HexAlphaColorPicker color={color} onChange={onChange} />
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
</PopoverContent>
</Popover>
);
@@ -15,6 +15,9 @@ export default function useReactiveTextInput(
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean;
},
): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText);
@@ -47,16 +50,25 @@ export default function useReactiveTextInput(
const handleSubmit = useCallback(
(valueToSubmit: string) => {
// No need to update if it hasn't changed
if (valueToSubmit !== initialText) {
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
options?.onCancelUpdate?.();
} else {
const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) {
setText(cleanVal);
}
}
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
setTimeout(() => {
if (options?.allowKeyboardNavigation) {
ref.current?.parentElement?.focus(); // Focus on parent element to continue keyboard navigation
} else {
ref.current?.blur();
}
}); // Immediate timeout to ensure text is set before blurring
},
[initialText, ref, submitCallback],
[initialText, options, ref, submitCallback],
);
/**
@@ -66,11 +78,25 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => {
// No need to update if it hasn't changed
setText(initialText);
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, [initialText, ref]);
// force the text to be the initial value
if (ref.current) {
ref.current.value = initialText;
}
options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
}, [initialText, options, ref]);
const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
const hotKeys: HotkeyItem[] = [
[
'Escape',
(event) => {
event.preventDefault();
handleEscape();
},
{ preventDefault: true },
],
];
if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => handleSubmit(text)]);
@@ -79,7 +105,22 @@ export default function useReactiveTextInput(
if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
}
return getHotkeyHandler(hotKeys);
const hotKeyHandler = getHotkeyHandler(hotKeys);
return (event: KeyboardEvent<HTMLElement>) => {
// allow moving in input field with arrow keys
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown'
) {
event.stopPropagation();
}
hotKeyHandler(event);
};
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return {
@@ -5,6 +5,7 @@ import { millisToString, parseUserTime } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger';
interface TimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
@@ -15,7 +16,7 @@ interface TimeInputProps<T extends string> {
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -54,6 +55,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
// we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) {
submitHandler(name, newValue);
return true;
}
const valueInMillis = parseUserTime(newValue);
@@ -117,6 +119,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
return (
<Input
id={id}
disabled={disabled}
size='sm'
ref={inputRef}
@@ -2,6 +2,8 @@ $input-delayed-border-color: $ontime-delay-text;
.timeInput {
border: 1px solid transparent;
color: $label-gray;
&.delayed {
border: 1px solid $input-delayed-border-color;
}
@@ -0,0 +1,10 @@
.link {
color: $label-gray;
font-size: $text-body-size;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: $blue-400;
}
}
@@ -0,0 +1,25 @@
import { type PropsWithChildren } from 'react';
import { useNavigate } from 'react-router-dom';
import style from './AppLink.module.scss';
interface AppLinkProps {
search: string;
}
/**
* Component used to navigate to an editor link inside the same window
* Handles the path to respect Ontime Clouds base URL
*/
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
const { search, children } = props;
const navigate = useNavigate();
const handleClick = () => navigate({ search });
return (
<button onClick={handleClick} className={style.link}>
{children}
</button>
);
}
@@ -1,8 +1,8 @@
import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils';
import style from './ExternalLink.module.scss';
@@ -14,7 +14,6 @@ interface ExternalLinkProps {
export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props;
const classes = cx([style.link, inline ? style.inline : null]);
const handleClick = (event: MouseEvent) => {
event.preventDefault();
@@ -22,8 +21,14 @@ export default function ExternalLink(props: ExternalLinkProps) {
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
{children} <IoOpenOutline />
<a
href='#!'
target='_blank'
rel='noreferrer'
className={cx([style.link, inline && style.inline])}
onClick={handleClick}
>
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
</a>
);
}
@@ -1,6 +1,6 @@
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 12px;
$progress-bar-size: 1.5rem;
$progress-bar-br: 3px;
.multiprogress-bar {
@@ -53,3 +53,6 @@ $progress-bar-br: 3px;
.multiprogress-bar__bg-danger {
flex-shrink: 0;
}
.multiprogress-bar__bg-danger--overtime {
background: var(--timer-overtime-color-override, $timer-finished-color);
}
@@ -1,6 +1,7 @@
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils';
import './MultiPartProgressBar.scss';
@@ -13,6 +14,7 @@ interface MultiPartProgressBar {
danger?: MaybeNumber;
dangerColor: string;
hidden?: boolean;
hideOvertime?: boolean;
ignoreCssOverride?: boolean;
className?: string;
}
@@ -27,6 +29,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
danger,
dangerColor,
hidden,
hideOvertime,
ignoreCssOverride,
className = '',
} = props;
@@ -34,12 +37,17 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0;
const showOvertime = isOvertime && !hideOvertime;
return (
<div
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
} ${className}`}
className={cx([
'multiprogress-bar',
hidden && 'multiprogress-bar--hidden',
ignoreCssOverride && 'multiprogress-bar--ignore-css-override',
className,
])}
>
{now !== null && (
<>
@@ -50,8 +58,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
className={cx(['multiprogress-bar__bg-danger', showOvertime && 'multiprogress-bar__bg-danger--overtime'])}
style={{ width: `${dangerWidth}%`, backgroundColor: showOvertime ? undefined : dangerColor }}
/>
</div>
<div className='multiprogress-bar__indicator'>
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { debounce } from '../../utils/debounce';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss';
@@ -13,32 +13,10 @@ interface FloatingNavigationProps {
export default function FloatingNavigation(props: FloatingNavigationProps) {
const { toggleMenu, toggleSettings } = props;
const [showButton, setShowButton] = useState(false);
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
setShowButton(true);
if (fadeOut) {
clearTimeout(fadeOut);
}
fadeOut = setTimeout(() => setShowButton(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
};
}, []);
const isButtonShown = useFadeOutOnInactivity();
return (
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
@@ -7,24 +7,38 @@ $icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 3rem;
.fadeable {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s;
&.hidden {
opacity: 0;
}
}
.lockIcon {
font-size: 1.5rem;
color: $active-indicator;
padding: 0.5em;
position: fixed;
left: 0;
top: 0;
z-index: 12;
}
.buttonContainer {
display: flex;
flex-direction: column;
row-gap: 1rem;
padding: 0.5em;
transition-property: opacity;
transition-duration: 0.3s;
opacity: 1;
position: fixed;
left: 0;
top: 0;
z-index: 12;
&.hidden {
opacity: 0;
}
}
.button {
@@ -1,4 +1,4 @@
import { memo, PropsWithChildren, useRef } from 'react';
import { memo, PropsWithChildren } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom';
import {
@@ -17,15 +17,14 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig';
import { isLocalhost, serverPort } from '../../api/constants';
import useClickOutside from '../../hooks/useClickOutside';
import useElectronEvent from '../../hooks/useElectronEvent';
import { useElectronEvent } from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, openLink } from '../../utils/linkUtils';
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import CopyTag from '../copy-tag/CopyTag';
@@ -45,19 +44,15 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
const { mirror, toggleMirror } = useViewOptionsStore();
const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => onClose);
return createPortal(
<div id='navigation-menu-portal' ref={menuRef}>
<div id='navigation-menu-portal'>
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay />
<DrawerContent maxWidth='21rem'>
<DrawerContent maxWidth='22rem'>
<DrawerHeader>
<DrawerCloseButton size='lg' />
Ontime
@@ -65,7 +60,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<DrawerBody padding={0}>
<div className={style.buttonsContainer}>
<div
className={style.link}
className={cx([style.link, fullscreen && style.current])}
tabIndex={0}
role='button'
onClick={toggle}
@@ -77,7 +72,7 @@ function NavigationMenu(props: NavigationMenuProps) {
{fullscreen ? <IoContract /> : <IoExpand />}
</div>
<div
className={style.link}
className={cx([style.link, mirror && style.current])}
tabIndex={0}
role='button'
onClick={() => toggleMirror()}
@@ -154,7 +149,8 @@ function OtherAddresses(props: OtherAddressesProps) {
return null;
}
const address = `http://${nif.address}:${serverPort}${currentLocation}`;
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag
key={nif.name}
@@ -0,0 +1,16 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss';
export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity();
return (
<div className={cx([style.fadeable, style.lockIcon, !isLockIconShown && style.hidden])}>
<IoLockClosedOutline />
</div>
);
}
@@ -1,21 +1,27 @@
import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { memo } from 'react';
import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu';
import useViewEditor from './useViewEditor';
import ViewLockedIcon from './ViewLockedIcon';
interface ViewNavigationMenuProps {
isLockable?: boolean;
}
function ViewNavigationMenu(props: ViewNavigationMenuProps) {
const { isLockable } = props;
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
if (isViewLocked) {
return <ViewLockedIcon />;
}
return (
<>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
@@ -0,0 +1,26 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface EditorVisibilityOptions {
isLockable?: boolean;
}
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
const [searchParams, setSearchParams] = useSearchParams();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const isViewLocked = useMemo(() => {
if (!isLockable) {
return false;
}
return isStringBoolean(searchParams.get('locked'));
}, [isLockable, searchParams]);
return { showEditFormDrawer, isViewLocked };
}
@@ -9,10 +9,6 @@ $progress-bar-br: 3px;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
overflow: clip;
&--hidden {
display: none;
}
}
.progress-bar__indicator {
@@ -7,16 +7,15 @@ import './ProgressBar.scss';
interface ProgressBarProps {
current: MaybeNumber;
duration: MaybeNumber;
hidden?: boolean;
className?: string;
}
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, hidden, className = '' } = props;
const { current, duration, className } = props;
const progress = getProgress(current, duration);
return (
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div className={`progress-bar__bg ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
</div>
);
@@ -1,9 +1,13 @@
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 25vh;
background: $ui-black;
color: $ontime-color;
font-weight: 200;
font-size: 3rem;
@@ -42,4 +46,4 @@
to {
background: rgba($red-500, 0);
}
}
}
@@ -1,5 +1,6 @@
import { PropsWithChildren, useCallback, useContext } from 'react';
import { isLocalhost } from '../../../externals';
import { AppContext } from '../../context/AppContext';
import PinPage from './PinPage';
@@ -9,7 +10,6 @@ interface ProtectRouteProps {
}
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const { editorAuth, operatorAuth, validate } = useContext(AppContext);
const handleValidation = useCallback(
@@ -29,7 +29,7 @@ export default function ProtectRoute({ permission, children }: PropsWithChildren
return false;
};
if (isLocal || hasRelevantAuth()) {
if (isLocalhost || hasRelevantAuth()) {
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
return <>{children}</>;
}
@@ -1,76 +0,0 @@
@use '../../../theme/viewerDefs' as *;
.schedule {
width: 100%;
border-spacing: 50px;
.entry {
font-size: clamp(16px, 1.5vw, 24px);
.entry-colour {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
height: clamp(8px, 0.75vw, 12px);
width: clamp(8px, 0.75vw, 12px);
border-radius: 6px;
display: inline-block;
}
.entry-times {
font-family: $viewer-font-family;
color: var(--secondary-color-override, $viewer-secondary-color);
font-weight: 300;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 8px;
}
.entry-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry-secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:last-child) {
padding-bottom: 8px;
}
&--past {
color: var(--secondary-color-override, $viewer-secondary-color);
}
&--now {
.entry-title {
color: var(--accent-color-override, $accent-color);
font-weight: 600;
}
}
&.skip {
text-decoration: line-through;
}
}
}
.schedule-nav {
display: flex;
justify-content: flex-end;
.schedule-nav__item {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
width: 12px;
height: 12px;
border-radius: 6px;
margin-left: 8px;
&--selected {
background-color: var(--color-override, $viewer-color);
}
}
}
@@ -1,52 +0,0 @@
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
import './Schedule.scss';
interface ScheduleProps {
isProduction?: boolean;
className?: string;
}
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
// TODO: design a nice placeholder for empty schedules
if (paginatedEvents?.length < 1) {
return null;
}
let selectedState: 'past' | 'now' | 'future' = 'past';
return (
<ul className={`schedule ${className}`}>
{paginatedEvents.map((event) => {
if (scheduleType === 'past' || scheduleType === 'future') {
selectedState = scheduleType;
} else {
if (event.id === selectedEventId) {
selectedState = 'now';
} else if (selectedState === 'now') {
selectedState = 'future';
}
}
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
return (
<ScheduleItem
key={event.id}
selected={selectedState}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic}
skip={event.skip}
/>
);
})}
</ul>
);
}
@@ -1,102 +0,0 @@
import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { useInterval } from '../../hooks/useInterval';
interface ScheduleContextState {
events: OntimeEvent[];
paginatedEvents: OntimeEvent[];
selectedEventId: string | null;
scheduleType: 'past' | 'now' | 'future';
numPages: number;
visiblePage: number;
isBackstage: boolean;
}
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
interface ScheduleProviderProps {
events: OntimeEvent[];
selectedEventId: string | null;
isBackstage?: boolean;
time?: number;
}
const numEventsPerPage = 8;
export const ScheduleProvider = ({
children,
events,
selectedEventId,
isBackstage = false,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
const [searchParams] = useSearchParams();
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
const viewEvents = [...events];
if (hidePast) {
// we want to show the event after the next
viewEvents.splice(0, selectedEventIndex + 2);
selectedEventIndex = 0;
}
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
const eventStart = eventsPerPage * visiblePage;
const eventEnd = eventsPerPage * (visiblePage + 1);
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
const resolveScheduleType = () => {
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
return 'now';
}
if (selectedEventIndex > eventEnd) {
return 'past';
}
return 'future';
};
const scheduleType = resolveScheduleType();
// every SCROLL_TIME go to the next array
useInterval(() => {
if (stopCycle) {
setVisiblePage(0);
} else if (events.length > eventsPerPage) {
const next = (visiblePage + 1) % numPages;
setVisiblePage(next);
}
}, time * 1000);
return (
<ScheduleContext.Provider
value={{
events,
paginatedEvents,
selectedEventId,
scheduleType,
numPages,
visiblePage,
isBackstage,
}}
>
{children}
</ScheduleContext.Provider>
);
};
export const useSchedule = () => {
const context = useContext(ScheduleContext);
if (!context) {
throw new Error('useSchedule() can only be used inside a ScheduleContext');
}
return context;
};
@@ -1,43 +0,0 @@
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { formatTime } from '../../utils/time';
import './Schedule.scss';
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
interface ScheduleItemProps {
selected: 'past' | 'now' | 'future';
timeStart: number;
timeEnd: number;
title: string;
backstageEvent: boolean;
colour: string;
skip: boolean;
}
export default function ScheduleItem(props: ScheduleItemProps) {
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
const userColour = colour !== '' ? colour : '';
const selectStyle = `entry--${selected}`;
return (
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: userColour }} />
<div style={{ display: 'flex' }}>
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent ? '*' : ''}
</div>
</div>
<div className='entry-title'>{title}</div>
</li>
);
}
@@ -1,23 +0,0 @@
import { useSchedule } from './ScheduleContext';
import './Schedule.scss';
interface ScheduleNavProps {
className?: string;
}
export default function ScheduleNav({ className }: ScheduleNavProps) {
const { numPages, visiblePage } = useSchedule();
return (
<div className={`schedule-nav ${className}`}>
{numPages > 1 &&
[...Array(numPages).keys()].map((i) => (
<div
key={i}
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
/>
))}
</div>
);
}
@@ -5,7 +5,7 @@
.empty {
width: 100%;
opacity: 0.6;
opacity: 0.8;
}
.text {
@@ -1,18 +1,20 @@
import { CSSProperties } from 'react';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
import style from './Empty.module.scss';
interface EmptyProps {
text?: string;
style?: CSSProperties;
className?: string;
}
export default function Empty(props: EmptyProps) {
const { text, ...rest } = props;
const { text, className, ...rest } = props;
return (
<div className={style.emptyContainer} {...rest}>
<div className={cx([style.emptyContainer, className])} {...rest}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
</div>
@@ -0,0 +1,20 @@
@use '../../../theme/viewerDefs' as *;
/* share the same style as a page layout */
.page {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
}
@@ -0,0 +1,20 @@
import { CSSProperties } from 'react';
import Empty from './Empty';
import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
style?: CSSProperties;
}
export default function EmptyPage(props: EmptyPageProps) {
const { text, ...rest } = props;
return (
<div className={style.page}>
<Empty text={text} {...rest} />
</div>
);
}
@@ -4,25 +4,26 @@
position: relative;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title-card__title,
.title-card__secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title-card__title {
.title-card__placeholder {
font-weight: 600;
font-size: clamp(1.5rem, 3vw, 3rem);
color: var(--color-override, $viewer-color);
font-size: $title-font-size;
line-height: 1.2em;
}
.title-card__title {
color: var(--color-override, $viewer-color);
padding-right: 1em;
}
.title-card__placeholder {
color: var(--label-color-override, $viewer-label-color);
}
.title-card__secondary {
font-size: clamp(1rem, 2vw, 2.25rem);
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
}
@@ -31,9 +32,8 @@
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: clamp(1rem, 1.5vw, 1.5rem);
font-size: $timer-label-size;
color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase;
&--accent {
@@ -1,11 +1,12 @@
import { ForwardedRef, forwardRef } from 'react';
import { useTranslation } from '../../../translation/TranslationProvider';
import { cx } from '../../utils/styleUtils';
import './TitleCard.scss';
interface TitleCardProps {
title: string;
title?: string;
label?: 'now' | 'next';
secondary?: string;
className?: string;
@@ -18,9 +19,9 @@ const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivEl
const accent = label === 'now';
return (
<div className={`title-card ${className}`} ref={ref}>
<div className={cx(['title-card', className])} ref={ref}>
<span className='title-card__title'>{title}</span>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
<span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
{label && getLocalizedString(`common.${label}`)}
</span>
<div className='title-card__secondary'>{secondary}</div>
@@ -0,0 +1,5 @@
.viewLogo {
max-width: 100%;
max-height: 75px;
display: block;
}
@@ -0,0 +1,19 @@
import { projectLogoPath } from '../../api/constants';
import './ViewLogo.scss';
interface ViewLogoProps {
name: string;
className: string;
}
export default function ViewLogo(props: ViewLogoProps) {
const { name, className } = props;
// we wrap the image in a div to help maintain the aspect ratio
return (
<div className={className}>
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
</div>
);
}
@@ -1,6 +1,6 @@
import { useState } from 'react';
import PopoverPicker from '../input/popover-picker/PopoverPicker';
import SwatchPicker from '../input/colour-input/SwatchPicker';
import style from './InlineColourPicker.module.scss';
@@ -20,13 +20,10 @@ export default function InlineColourPicker(props: InlineColourPickerProps) {
const { name, value } = props;
const [colour, setColour] = useState(() => ensureHex(value));
const debouncedChange = (value: string) => {
setColour(value);
};
return (
<div className={style.inline}>
<PopoverPicker color={colour} onChange={debouncedChange} />
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
<span>{colour}</span>
<input type='hidden' name={name} value={colour} />
</div>
);
@@ -13,6 +13,7 @@ import {
Select,
Switch,
} from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
@@ -108,30 +109,26 @@ function MultiOption(props: EditFormMultiOptionProps) {
const { paramField } = props;
const { id, defaultValue } = paramField;
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
const [paramState, setParamState] = useState<string>(defaultOptionValue);
const optionFromParams = searchParams.getAll(id);
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
return (
<>
<input name={id} hidden readOnly value={paramState} />
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
{paramField.title}
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
</MenuButton>
<MenuList>
<MenuList overflow='auto' maxHeight='200px'>
<MenuOptionGroup
type='checkbox'
value={paramState.split('_')}
onChange={(value) => {
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
}}
value={paramState}
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
>
{Object.values(paramField.values).map((option) => {
const { value, label } = option;
const { value, label, colour } = option;
return (
<MenuItemOption value={value} key={value}>
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
{label}
</MenuItemOption>
);
@@ -8,39 +8,15 @@
}
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
.sectionList {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-height: 100%;
gap: 2rem;
overflow-y: scroll;
padding-right: 0.5rem;
}
.section {
color: $ui-white;
font-size: 1rem;
&:not(:first-child) {
margin-top: 2rem;
}
}
.fieldSet {
display: flex;
padding: $section-spacing 0;
flex-direction: column;
gap: $element-spacing;
margin-left: 0.5rem;
}
.title {
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
.info {
margin-bottom: 1rem;
}
@@ -12,8 +12,11 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import ParamInput from './ParamInput';
import { isSection, ViewOption } from './types';
import useViewSettings from '../../hooks-query/useViewSettings';
import Info from '../info/Info';
import { ViewOption } from './types';
import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss';
@@ -37,15 +40,15 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
// Convert paramFields to an object that contains default values
const defaultValues: Record<string, string> = {};
paramFields.forEach((option) => {
if (!isSection(option)) {
paramFields.forEach((section) => {
section.options.forEach((option) => {
defaultValues[option.id] = String(option.defaultValue);
}
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
});
});
// compare which values are different from the default values
@@ -55,10 +58,28 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
// unfortunately this means we run all the strings through the sanitation
const valueWithoutHash = sanitiseColour(value);
if (defaultValues[id] !== valueWithoutHash) {
newSearchParams.set(id, valueWithoutHash);
handleValueString(id, valueWithoutHash);
}
}
});
/** Utility function contains logic to add a value into the searchParams object */
function handleValueString(id: string, value: string) {
const maybeMultipleValues = value.split(',');
// we need to check if the value contains comma separated list, for the case of the multi-select data
if (Array.isArray(maybeMultipleValues) && maybeMultipleValues.length > 1) {
const added = new Set();
maybeMultipleValues.forEach((v) => {
if (!added.has(v)) {
added.add(v);
newSearchParams.append(id, v);
}
});
} else {
newSearchParams.set(id, value);
}
}
return newSearchParams;
};
@@ -69,6 +90,8 @@ interface EditFormDrawerProps {
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, onClose, onOpen } = useDisclosure();
useEffect(() => {
@@ -97,8 +120,6 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams);
onClose();
};
return (
@@ -111,30 +132,18 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
</DrawerHeader>
<DrawerBody>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{viewOptions.map((option) => {
if (isSection(option)) {
return (
<div key={option.section} className={style.section}>
{option.section}
</div>
);
}
if (option.type === 'persist') {
return null;
}
return (
<div key={option.title} className={style.fieldSet}>
<label className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput key={option.title} paramField={option} />
</label>
</div>
);
})}
{viewSettings.overrideStyles && (
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
)}
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
{viewOptions.map((section) => (
<ViewParamsSection
key={section.title}
title={section.title}
collapsible={section.collapsible}
options={section.options}
/>
))}
</form>
</DrawerBody>
@@ -0,0 +1,50 @@
.section {
color: $ui-white;
font-size: 1rem;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
height: 2rem;
&.collapsible {
cursor: pointer;
&:hover {
border-bottom: 1px solid $white-10;
}
}
}
.label {
margin-top: $section-spacing;
font-size: $inner-section-text-size;
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title {
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
}
.closed {
transition: rotate $transition-time-feedback;
rotate: 0deg;
}
.open {
transition: rotate $transition-time-feedback;
rotate: 180deg;
}
@@ -0,0 +1,54 @@
import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { cx } from '../../utils/styleUtils';
import ParamInput from './ParamInput';
import { type ParamField } from './types';
import style from './ViewParamsSection.module.scss';
interface ViewParamsSectionProps {
title: string;
collapsible?: boolean;
options: ParamField[];
}
export default function ViewParamsSection(props: ViewParamsSectionProps) {
const { title, collapsible, options } = props;
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const handleCollapse = () => {
if (collapsible) {
setCollapsed((prev) => !prev);
}
};
return (
<section className={style.section}>
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
{title}
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
</div>
{!collapsed && (
<>
{options.map((option) => {
if (option.type === 'persist') {
return null;
}
return (
<label key={option.title} className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
);
})}
</>
)}
</section>
);
}
@@ -0,0 +1,45 @@
import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields } from '../constants';
describe('makeOptionsFromCustomFields', () => {
const testCustomFields: CustomFields = {
field1: { label: 'Field 1', colour: 'red', type: 'string' },
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
};
it('creates a record of keys for the given custom fields', () => {
const result = makeOptionsFromCustomFields(testCustomFields);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
it('appends additional data', () => {
const additionalData = {
test1: 'test1',
test2: 'test2',
};
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
test1: 'test1',
test2: 'test2',
});
});
it('filtersImageTypes', () => {
const customFieldsWIthImage: CustomFields = {
...testCustomFields,
field3: { label: 'Field 3', colour: 'green', type: 'image' },
};
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
});
@@ -1,17 +1,34 @@
import { CustomFields } from 'ontime-types';
import type { ParamField } from './types';
import type { MultiselectOptions, ParamField } from './types';
export const makeOptionsFromCustomFields = (
customFields: CustomFields,
additionalOptions: Record<string, string> = {},
filterImageType = true,
) => {
return Object.entries(customFields).reduce((options, [key, value]) => {
const options = structuredClone(additionalOptions);
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[`custom-${key}`] = `Custom: ${value.label}`;
return options;
}, additionalOptions);
}
return options;
};
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
const options: MultiselectOptions = {};
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[key] = { value: key, label: value.label, colour: value.colour };
}
return options;
}
export const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`;
return {
@@ -38,3 +55,14 @@ export const showLeadingZeros: ParamField = {
type: 'boolean',
defaultValue: false,
};
export enum OptionTitle {
ClockOptions = 'Clock Options',
TimerOptions = 'Timer Options',
DataSources = 'Data sources',
ElementVisibility = 'Element visibility',
BehaviourOptions = 'View behaviour',
StyleOverride = 'View style override',
Animation = 'View animation',
Schedule = 'Schedule options',
}
@@ -1,6 +1,4 @@
type ParamSection = {
section: string;
};
import { OptionTitle } from './constants';
type BaseField = {
id: string;
@@ -14,9 +12,11 @@ type OptionsField = {
defaultValue?: string;
};
export type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>;
type MultiOptionsField = {
type: 'multi-option';
values: Record<string, { value: string; label: string; colour: string }>;
values: MultiselectOptions;
defaultValue?: string;
};
@@ -28,11 +28,9 @@ type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField &
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField);
export type ViewOption = ParamSection | ParamField;
/**
* Type assertion utility checks whether an entry is a section separator
*/
export function isSection(entry: ViewOption): entry is ParamSection {
return 'section' in entry;
}
export type ViewOption = {
title: OptionTitle;
options: ParamField[];
collapsible?: boolean;
};
@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query';
import { dayInMs } from 'ontime-utils';
import { version } from '../../../../../package.json';
import { isLocalhost } from '../../externals';
import { APP_VERSION } from '../api/constants';
import { getLatestVersion, HasUpdate } from '../api/external';
const placeholder: HasUpdate & { hasUpdates: boolean } = { url: '', version: '', hasUpdates: false };
export default function useAppVersion() {
const {
data: fetchData,
status,
isFetching,
isError,
refetch,
} = useQuery({
queryKey: APP_VERSION,
queryFn: getLatestVersion,
placeholderData: (previousData, _previousQuery) => previousData,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
staleTime: dayInMs,
enabled: isLocalhost,
});
const hasUpdates = fetchData?.version && !fetchData.version.includes(version);
const data = fetchData ? { ...fetchData, hasUpdates } : placeholder;
return { data, placeholder, status, isFetching, isError, refetch };
}
@@ -0,0 +1,34 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { editAutomationSettings, getAutomationSettings } from '../api/automation';
import { AUTOMATION } from '../api/constants';
import { logAxiosError } from '../api/utils';
import { automationPlaceholderSettings } from '../models/AutomationSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: AUTOMATION,
queryFn: getAutomationSettings,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
}
export function useAutomationSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: editAutomationSettings,
onError: (error) => logAxiosError('Error saving Automation settings', error),
onSuccess: (data) => {
ontimeQueryClient.setQueryData(AUTOMATION, data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }),
});
return { isPending, mutateAsync };
}
@@ -1,34 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/constants';
import { getHTTP, postHTTP } from '../api/http';
import { logAxiosError } from '../api/utils';
import { httpPlaceholder } from '../models/Http';
import { ontimeQueryClient } from '../queryClient';
export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
}
export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
});
return { isPending, mutateAsync };
}
@@ -1,34 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/constants';
import { getOSC, postOSC } from '../api/osc';
import { logAxiosError } from '../api/utils';
import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? oscPlaceholderSettings, status, isFetching, isError, refetch };
}
export function useOscSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postOSC,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isPending, mutateAsync };
}
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types';
@@ -22,3 +23,23 @@ export function useProjectList() {
});
return { data: data ?? placeholderProjectList, status, refetch };
}
export function useOrderedProjectList() {
const response = useProjectList();
const { files, lastLoadedProject } = response.data;
const reorderedProjectFiles = useMemo(() => {
if (!files.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
if (currentlyLoadedIndex === -1) return files;
const projectFiles = [...files];
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
return [current, ...projectFiles];
}, [files, lastLoadedProject]);
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
}
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { OntimeReport } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT } from '../api/constants';
import { fetchReport } from '../api/report';
export default function useReport() {
const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT,
queryFn: fetchReport,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
networkMode: 'always',
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? {}, refetch };
}
@@ -1,14 +1,19 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown';
import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
/**
* Normalised rundown data
*/
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
@@ -22,9 +27,15 @@ export default function useRundown() {
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
/**
* Provides access to a flat rundown
* built from the order and rundown fields
*/
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<OntimeRundown>([]);
@@ -37,5 +48,26 @@ export function useFlatRundown() {
}
}, [data.order, data.revision, data.rundown, prevRevision]);
// TODO: should we have a project id field?
// invalidate current version if project changes
useEffect(() => {
if (projectData?.title !== loadedProject.current) {
setPrevRevision(-1);
loadedProject.current = projectData?.title ?? '';
}
}, [projectData]);
return { data: flatRunDown, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*/
export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) {
const { data, status } = useFlatRundown();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
return { data: filteredData, status };
}
@@ -1,46 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import useClickOutside from '../useClickOutside';
describe('useClickOutside', () => {
let target: HTMLElement;
let anotherElement: HTMLElement;
beforeAll(() => {
target = global.document.createElement('div');
global.document.body.appendChild(target);
anotherElement = global.document.createElement('div');
global.document.body.appendChild(anotherElement);
});
it('should trigger clicking outside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
global.document.dispatchEvent(new Event('click'));
});
expect(callback).toHaveBeenCalled();
act(() => {
anotherElement.click();
});
expect(callback).toHaveBeenCalledTimes(2);
});
it('should not trigger clicking inside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
target.click();
});
expect(callback).not.toHaveBeenCalled();
});
});

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