Compare commits

...

128 Commits

Author SHA1 Message Date
Carlos Valente e2c9ee31ad bump version to 3.16.1 2025-08-15 19:54:38 +02:00
Carlos Valente bfd85855d4 fix: prevent storage key conflicts between stages 2025-08-15 19:54:38 +02:00
Carlos Valente 20859abda9 chore: add demo images to github 2025-08-09 07:01:01 +02:00
Alex Christoffer Rasmussen 1025347c08 refactor: add version to session endpoint (#1659) 2025-06-23 20:42:53 +02:00
Carlos Valente 43988ea12b fix: stale ids in cuesheet (#1657) 2025-06-23 19:56:53 +02:00
Alex Christoffer Rasmussen 722e045b20 Revert "Session version" (#1649)
* Revert "refactor: add version to session endpoint"

This reverts commit cd40a6e55e.

* Revert "Remove public event feature (#1645)"

This reverts commit 08d9e24871.

* Revert "Fix: rearrange playing event (#1640)"

This reverts commit 0649678dca.

* Revert "refactor: style tweaks to edit css modal"

This reverts commit a00ec2d02a.

* Revert "refactor: remove usages of framer-motion"

This reverts commit 54a74ccc2a.

* Revert "Upgrade expressjs (#1633)"

This reverts commit 6f3ab274bd.

* Revert "Refactor: require trigger in all events objects (#1636)"

This reverts commit 90870ecfb6.

* Revert "Fix: Correct boundary condition in applyDelay"

This reverts commit b640e0e181.

* Revert "let vite be the proxy to the dev server (#1630)"

This reverts commit c41fe824cf.

* Revert "refactor: migrate custom fields to transactions"

This reverts commit 62c8319d70.

* Revert "refactor: simplify validations"

This reverts commit b1d23467a2.

* Revert "refactor: create transaction system and apply to adding entry (#1620)"

This reverts commit 9a62daf047.

* Revert "Refactor: better rounding (#1594)"

This reverts commit b9ab1c6fd7.

* Revert "refactor: improve reorder logic"

This reverts commit c1054711b0.

* Revert "chore: simplify URLs"

This reverts commit a4d4f29a37.

* Revert "refactor: order is single source of truth"

This reverts commit 2793aadea0.

* Revert "refactor: refetch targets is enum"

This reverts commit e7cfb7d9d9.

* Revert "refactor: small ux improvements"

This reverts commit 256a851c9b.

* Revert "refactor: remove trivially inferred numEvents"

This reverts commit 4ab9c81cb8.

* Revert "feat: duplicate groups"

This reverts commit 2f13d6c89e.

* Revert "feat: create group from entry selection"

This reverts commit f1f7bad25e.

* Revert "feat: create block from rundown empty"

This reverts commit a8b52a48f7.

* Revert "fix: collapsed blocks dont render children"

This reverts commit cd0999b2ab.

* Revert "refactor: type cleanup and test improvements"

This reverts commit b4c60f3f04.

* Revert "feat: allow dissolving a block"

This reverts commit 5cefad3666.

* Revert "fix: uncontrolled prop on controlled component"

This reverts commit 7a6ecd8c34.

* Revert "refactor: improve return of reorder"

This reverts commit ba96ecfd91.

* Revert "refactor: mutations on batch elements must have IDs"

This reverts commit 7bed3757f2.

* Revert "chore: upgrade dependencies"

This reverts commit e2e755b1d2.

* Revert "refactor: extract utility to merge two arrays"

This reverts commit a77d23109d.

* Revert "refactor: change network mode defaults"

This reverts commit 0eb3b8d382.

* Revert "fix: delete nested events"

This reverts commit 0021185288.

* Revert "fix: add event at end of block"

This reverts commit 94c72ff4f6.

* Revert "refactor: make finder available in exported rundown"

This reverts commit e4c08dc9b2.

* Revert "assert non null and update test (#1604)"

This reverts commit ec74af0d62.

* Revert "Fix project renumber (#1597)"

This reverts commit b6d72dd082.

* Revert "Refactor: WebSocket from flush queue to one patch (#1595)"

This reverts commit 31c311daf0.

* Revert "Refactor: ms for api calls (#1593)"

This reverts commit e9b3cc6090.

* Revert "fix test (#1601)"

This reverts commit 543b04a097.

* Revert "fix: rebase master"

This reverts commit d39b85b6e6.

* Revert "chore: correct test path"

This reverts commit bbe107bb2b.

* Revert "refactor: extract rundown parsing"

This reverts commit c616240db1.

* Revert "chore: improve convention entry <> event"

This reverts commit 166be66ce3.

* Revert "refactor: maintain flat orders"

This reverts commit 4180d0a337.

* Revert "refactor: implement operations on nested events"

This reverts commit 78108e316c.

* Revert "refactor: process events in rundown"

This reverts commit 3bb8b70915.

* Revert "chore: improve convention entry <> event"

This reverts commit 1c4f13a0ed.

* Revert "chore: rename currentBlock > parent"

This reverts commit 3ca0abad53.

* Revert "refactor: fix delay positioning in gaps"

This reverts commit 030c8f897f.

* Revert "refactor(e2e): skip flaky test"

This reverts commit 68175cfa3b.

* Revert "refactor: improve project loading"

This reverts commit fd8f757851.

* Revert "refactor: gather group metadata"

This reverts commit 876d111c61.

* Revert "refactor: swap maintains schedule"

This reverts commit 730cb95c04.

* Revert "chore: rename files"

This reverts commit 3c388d4fb5.

* Revert "refactor: restructure model to contain an object of rundowns"

This reverts commit 2e23718d73.

* Revert "refactor: clearer relationship on rundown elements"

This reverts commit 89ea8c470b.

* Revert "refactor: use strict typing"

This reverts commit 178640bfc4.

* Revert "refactor: remove stop as a possible end action"

This reverts commit 4ed38340e0.

* Revert "refactor: restructure model to contain an object of rundowns"

This reverts commit 69eb9a5eff.

* Revert "chore: remove IDE files"

This reverts commit 351425127a.

* Revert "refactor: remove unused and legacy code"

This reverts commit 95f2ba37cc.
2025-06-20 12:28:20 +02:00
Carlos Valente ad6804019b bump version to 3.16.0 2025-06-20 07:41:39 +02:00
Carlos Valente cd40a6e55e refactor: add version to session endpoint 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 08d9e24871 Remove public event feature (#1645) 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 0649678dca Fix: rearrange playing event (#1640)
* creat failing test

* find and pass index of eventNow in case it moved

* fix cache invalidate edits should also recalculate timer

* use coluor as test indicator

* fixed empty cue guard

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-06-19 18:53:28 +02:00
google-labs-jules[bot] a00ec2d02a refactor: style tweaks to edit css modal 2025-06-19 18:53:28 +02:00
google-labs-jules[bot] 54a74ccc2a refactor: remove usages of framer-motion 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 6f3ab274bd Upgrade expressjs (#1633)
* upgrade expressjs

* migration

* reenable test

* extend timeout on download test

* fixup! migration

* move empty body test from controller to validator

* enusre not empty

* extract validation function

* fixup! reenable test

* remove thin controllers

* disable e2e test of project file download
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 90870ecfb6 Refactor: require trigger in all events objects (#1636)
* reqire trigger in all events

* use structuredClone when cloning an event
2025-06-19 18:53:28 +02:00
google-labs-jules[bot] b640e0e181 Fix: Correct boundary condition in applyDelay
The `applyDelay` function had a condition that incorrectly used `rundown.order.length` instead of `rundownMetadata.flatEntryOrder.length` to check if a delay entry was the last in the sequence. `rundown.order` only contains top-level entries, while `flatEntryOrder` contains all entries, including those within blocks, which is the relevant list for this check.

This commit corrects the condition to use `rundownMetadata.flatEntryOrder.length`.

Existing tests in `rundown.dao.test.ts` (specifically the test `removes delays in last position without applying changes`) already cover this scenario and pass with the correction, ensuring the fix behaves as expected.
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen c41fe824cf let vite be the proxy to the dev server (#1630) 2025-06-19 18:53:28 +02:00
Carlos Valente 62c8319d70 refactor: migrate custom fields to transactions
refactor: extract functions to api domain

refactor: strict custom field parsing

refactor: remove rundown cache utilities

refactor: directory restructure
2025-06-19 18:53:28 +02:00
Carlos Valente b1d23467a2 refactor: simplify validations 2025-06-19 18:53:28 +02:00
Carlos Valente 9a62daf047 refactor: create transaction system and apply to adding entry (#1620)
* refactor: create transaction system and apply to adding entry

* refactor: migrate edit mutations to transaction

* refactor: migrate delete mutation to transaction

* refactor: migrate reorder mutation to transaction

* refactor: migrate apply delay to transaction

* refactor: migrate swapEvents to transaction

* refactor: migrate clone to transaction

* refactor: migrate group/ungroup transactions

* refactor: simplify mutations

* refactor: migrate getters

* chore: add tests to processRundown()
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen b9ab1c6fd7 Refactor: better rounding (#1594) 2025-06-19 18:53:28 +02:00
Carlos Valente c1054711b0 refactor: improve reorder logic 2025-06-19 18:53:28 +02:00
Carlos Valente a4d4f29a37 chore: simplify URLs 2025-06-19 18:53:28 +02:00
Carlos Valente 2793aadea0 refactor: order is single source of truth 2025-06-19 18:53:28 +02:00
Carlos Valente e7cfb7d9d9 refactor: refetch targets is enum 2025-06-19 18:53:28 +02:00
Carlos Valente 256a851c9b refactor: small ux improvements
- rename dissolve > ungroup
- prevent ondrag when clicking
- add untitled as block title fallback
- move block action to context menu
2025-06-19 18:53:28 +02:00
Carlos Valente 4ab9c81cb8 refactor: remove trivially inferred numEvents 2025-06-19 18:53:28 +02:00
Carlos Valente 2f13d6c89e feat: duplicate groups 2025-06-19 18:53:28 +02:00
Carlos Valente f1f7bad25e feat: create group from entry selection 2025-06-19 18:53:28 +02:00
Carlos Valente a8b52a48f7 feat: create block from rundown empty 2025-06-19 18:53:28 +02:00
Carlos Valente cd0999b2ab fix: collapsed blocks dont render children 2025-06-19 18:53:28 +02:00
Carlos Valente b4c60f3f04 refactor: type cleanup and test improvements 2025-06-19 18:53:28 +02:00
Carlos Valente 5cefad3666 feat: allow dissolving a block 2025-06-19 18:53:28 +02:00
Carlos Valente 7a6ecd8c34 fix: uncontrolled prop on controlled component 2025-06-19 18:53:28 +02:00
Carlos Valente ba96ecfd91 refactor: improve return of reorder 2025-06-19 18:53:28 +02:00
Carlos Valente 7bed3757f2 refactor: mutations on batch elements must have IDs 2025-06-19 18:53:28 +02:00
Carlos Valente e2e755b1d2 chore: upgrade dependencies 2025-06-19 18:53:28 +02:00
Carlos Valente a77d23109d refactor: extract utility to merge two arrays 2025-06-19 18:53:28 +02:00
Carlos Valente 0eb3b8d382 refactor: change network mode defaults 2025-06-19 18:53:28 +02:00
Carlos Valente 0021185288 fix: delete nested events 2025-06-19 18:53:28 +02:00
Carlos Valente 94c72ff4f6 fix: add event at end of block 2025-06-19 18:53:28 +02:00
Carlos Valente e4c08dc9b2 refactor: make finder available in exported rundown 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen ec74af0d62 assert non null and update test (#1604) 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen b6d72dd082 Fix project renumber (#1597)
* fix: generateUniqueFileName

* loadProject should not generate new names

* update comments

* extract and test getProjectNumber

* spell

* finish jsdoc

* use getProjectNumber

* cleanup loadProject

* create a `incrementProjectNumber` function

* spelling

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 31c311daf0 Refactor: WebSocket from flush queue to one patch (#1595)
* change flush to one patch

* remove unused types

* create batch

* merge patch into eventStore

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen e9b3cc6090 Refactor: ms for api calls (#1593)
* use ms for api calls

* change it in UI

* update comment

* rename variables

* refactor: addtime api logic
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 543b04a097 fix test (#1601)
* fix test

* add comment
2025-06-19 18:53:28 +02:00
Carlos Valente d39b85b6e6 fix: rebase master 2025-06-19 18:53:28 +02:00
Carlos Valente bbe107bb2b chore: correct test path 2025-06-19 18:53:28 +02:00
Carlos Valente c616240db1 refactor: extract rundown parsing
refactor: implement groups in editor
2025-06-19 18:53:28 +02:00
Carlos Valente 166be66ce3 chore: improve convention entry <> event 2025-06-19 18:53:28 +02:00
Carlos Valente 4180d0a337 refactor: maintain flat orders 2025-06-19 18:53:28 +02:00
Carlos Valente 78108e316c refactor: implement operations on nested events 2025-06-19 18:53:28 +02:00
Carlos Valente 3bb8b70915 refactor: process events in rundown 2025-06-19 18:53:28 +02:00
Carlos Valente 1c4f13a0ed chore: improve convention entry <> event 2025-06-19 18:53:28 +02:00
Carlos Valente 3ca0abad53 chore: rename currentBlock > parent 2025-06-19 18:53:28 +02:00
Carlos Valente 030c8f897f refactor: fix delay positioning in gaps 2025-06-19 18:53:28 +02:00
Carlos Valente 68175cfa3b refactor(e2e): skip flaky test 2025-06-19 18:53:28 +02:00
Carlos Valente fd8f757851 refactor: improve project loading 2025-06-19 18:53:28 +02:00
Carlos Valente 876d111c61 refactor: gather group metadata 2025-06-19 18:53:28 +02:00
Carlos Valente 730cb95c04 refactor: swap maintains schedule 2025-06-19 18:53:28 +02:00
Carlos Valente 3c388d4fb5 chore: rename files 2025-06-19 18:53:28 +02:00
Carlos Valente 2e23718d73 refactor: restructure model to contain an object of rundowns 2025-06-19 18:53:28 +02:00
Carlos Valente 89ea8c470b refactor: clearer relationship on rundown elements 2025-06-19 18:53:28 +02:00
Carlos Valente 178640bfc4 refactor: use strict typing 2025-06-19 18:53:28 +02:00
Carlos Valente 4ed38340e0 refactor: remove stop as a possible end action 2025-06-19 18:53:28 +02:00
Carlos Valente 69eb9a5eff refactor: restructure model to contain an object of rundowns 2025-06-19 18:53:28 +02:00
Carlos Valente 351425127a chore: remove IDE files 2025-06-19 18:53:28 +02:00
Carlos Valente 95f2ba37cc refactor: remove unused and legacy code
- remove legacy migrations
- remove unused server code
- remove unused UI code
2025-06-19 18:53:28 +02:00
jwetzell fe8bbd5003 add electron and esbuild to allow list for postinstall builds 2025-06-06 20:25:46 +02:00
jwetzell c6e38a2b4a upgrade vite to v6 (#1626) 2025-06-05 06:27:59 +02:00
jwetzell 56f18fe71c update node and pnpm versions in README 2025-06-05 06:13:04 +02:00
jwetzell 0b9306de82 update pnpm to latest v10 2025-06-05 06:13:04 +02:00
jwetzell 3b1e6cf33d update node version in workflows 2025-06-05 06:13:04 +02:00
jwetzell dda219ebbe update NodeJS to v22 2025-06-05 06:13:04 +02:00
jwetzell 8bb771e5c6 upgrade electron and electron-builder 2025-06-05 06:13:04 +02:00
Shobhit Nagpal 8723bcbd33 refactor: block event xlsx import (#1623) 2025-06-02 06:56:46 +02:00
Shobhit Nagpal 0dcea4f2d7 fix: pass empty string fallback when parsing fields for export (#1616) 2025-05-25 19:58:41 +02:00
Shobhit Nagpal 696c016c90 feat: custom data for projects (#1571) 2025-05-23 09:04:57 +02:00
Shobhit Nagpal eed6373dbf refactor: change offset classes depending on playback state (#1611)
* refactor: assign offsetClasses based on playback state
2025-05-23 09:04:57 +02:00
Shobhit-Nagpal ac61f257c0 fix: import preview enable after error 2025-05-13 21:24:14 +02:00
Carlos Valente 8d8625aa77 bump version to 3.15.2 2025-05-13 21:24:14 +02:00
Alex Christoffer Rasmussen 1e1aa4bca5 Fix: sheet export (#1606)
* add util to get keys from labels

* look for existing keys first then create new

* save ontime key to rundown metadata

* update test
2025-05-12 13:56:25 +02:00
Alex Christoffer Rasmussen 1f0cde6d0d fix: relative offset with count to end (#1602)
* refactor merge absolute and relative offset calculation

* update test
2025-05-07 22:34:25 +02:00
Carlos Valente c4b66a9ed3 bump version to 3.15.1 2025-04-30 20:28:42 +02:00
Carlos Valente 133b3aa819 refactor: create hash of table state 2025-04-30 20:23:49 +02:00
Carlos Valente 9566fadbbb chore: upgrade table dependencies 2025-04-30 20:23:49 +02:00
Carlos Valente 62570899e8 fix: issue when user hides all columns 2025-04-30 20:23:49 +02:00
Carlos Valente c1f25f8dba bump version to 3.15.0 2025-04-20 13:52:43 +02:00
Alex Christoffer Rasmussen 37a91026d8 FIX: editing event data clears delay value (#1591)
* leave data in place in case it is not regenerated

* update comments
2025-04-20 13:51:25 +02:00
Bastian Widmer c62ce213f5 fix - fix studio clock sizing when timer seconds are hidden !1585 2025-04-20 13:28:22 +02:00
Alex Christoffer Rasmussen 529751578d refactor: lower third (#1507)
* refactor

* separate in and out time

* keep element on screen

* animate on play

* spelling

* explain animation decisions

* guard numbers from url params

* spelling

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

* rename safeParseNumber

* use view data

* refactor getOptionsFromParams

* rebalance animation timing

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-04-20 12:50:16 +02:00
Alex Christoffer Rasmussen dce87c3a81 Relative run mode (#1512)
* inti relative mode

* send imediat update on mode change

* add relative to test

* don't persist offset mode

* add test relative and update calc function

* pass data from ssocket

* add dev guard

* spelling and comments

* show relative in offset overview

* remove comments

* refactor: move totalDelay to _rundown

* update  clear naming and comments

* remove old testing values
2025-04-19 21:36:54 +02:00
Alex Christoffer Rasmussen a94b10cb0f Event trigger (#1557)
* crude UI

* add server functionalety

* better event trigger list

* fix default value

* remove log

* add trigger to recalculate whitelist

* use DTO type

* fix rebase

* one callback for delete and submit

* refactor

* add invalid description

* clean up commits

* cleanup

* refactor: find event triggers in `triggerAutomations`

* refactor: switch to flat array for event triggers

* prevent deleting a automation that is in use

* refactor

* refactor: extract EventEditorCustom

* move trigger options to separate file

* move trigger edit down a file level

* cleanup

* spelling

* refactor: ui review proposal

* change default value

* fix delete filter

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-04-19 21:27:07 +02:00
Shobhit Nagpal 6817263123 feat: css editor (#1542)
* feat: allow editing CSS from settings

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

refactor: style tweaks
2025-04-19 20:40:18 +02:00
Alex Christoffer Rasmussen ddd629d7db in cloud force port 4001 (#1575)
* force 4001

* only log if there is anything to change

* skip port retry in cloud
2025-04-15 21:36:59 +02:00
Carlos Valente 9d2a3d5a12 bump version to 3.14.5 2025-04-15 21:25:46 +02:00
Carlos Valente 4bc271208e refactor: improve synchronisation of external text 2025-04-15 21:25:33 +02:00
Carlos Valente dbd535dd6c refactor: timeout for offline status 2025-04-15 21:25:33 +02:00
Carlos Valente 57b7fe214a refactor: avoid old language features 2025-04-15 21:25:33 +02:00
cv b136e2856a refactor: simplify logic 2025-04-15 21:25:33 +02:00
Carlos Valente 03e68c03a9 refactor: allow cloud in iframe 2025-04-10 21:28:43 +02:00
Carlos Valente 44048a889d bump version to 3.14.4 2025-04-10 21:28:27 +02:00
arc-alex f72db62e08 add comment 2025-04-10 21:21:33 +02:00
arc-alex 333f2623a5 Fix: timeUntil past coutToEnd events 2025-04-10 21:21:33 +02:00
Alex Christoffer Rasmussen 3ebb3451e4 better browser compatibility for view params (#1566) 2025-04-09 12:39:59 +02:00
Carlos Valente 73afbacdd7 refactor: swap does not copy time data 2025-03-28 19:42:43 +01:00
Carlos Valente d08452d613 refactor: clone events fully copies data 2025-03-28 19:42:43 +01:00
Carlos Valente 4ebe6ede59 refactor: consistent overview layout 2025-03-28 19:42:43 +01:00
Carlos Valente 09a32e7612 bump version to 3.14.3 2025-03-28 19:31:31 +01:00
asharonbaltazar d81f5fcfe1 chore(ui): update icons package (#1556)
* chore: add new icons package and remove the old one

* chore: update and consolidate imports

* chore: missed import
2025-03-25 16:36:47 -04:00
Carlos Valente 0a6542df76 fix: link generation should add prefix 2025-03-18 21:56:10 +01:00
Carlos Valente b2eb140ee2 fix: allow copying link 2025-03-18 21:56:10 +01:00
Carlos Valente e28f692d3d fix: cloud should not give IP links 2025-03-18 21:56:10 +01:00
Carlos Valente dc73e7e637 bump version to 3.14.2 2025-03-18 21:48:22 +01:00
Carlos Valente b81e3a4ba5 fix: link to variables 2025-03-17 21:48:40 +01:00
Carlos Valente 6fc7b15d23 refactor: compact event block size 2025-03-17 19:48:04 +01:00
Alex Christoffer Rasmussen 99dd4b4d5c Extract client timeuntil logic (#1521)
* extract

* add to timeline

* translate Due

* fix timeline page

* begin adding some tests

* spelling

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

* move file

* refactor

* create hook

* refactor

* update test

* spelling

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

* remove translation

* explain isLinkedToLoaded

* add delay to calculation

* add delay value to test

* spelling

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-03-10 22:47:02 +01:00
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
216 changed files with 4687 additions and 1606 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

+6 -6
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+2 -2
View File
@@ -20,13 +20,13 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1 +1 @@
v20.15.1 v22.15.1
+2 -2
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- __node__ (~20) - __node__ (~22)
- __pnpm__ (~9) - __pnpm__ (~10)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
+3 -3
View File
@@ -1,13 +1,13 @@
FROM node:20-bullseye AS builder FROM node:22-bullseye AS builder
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@9.5.0 RUN npm install -g pnpm@10.11.0
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:20-alpine FROM node:22-alpine
# Set environment variables # Set environment variables
# Environment Variable to signal that we are running production # Environment Variable to signal that we are running production
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.13.1", "version": "3.16.1",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+12 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.13.1", "version": "3.16.1",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -13,23 +13,26 @@
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.13.3", "@mantine/hooks": "^7.13.3",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.43.0", "@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7", "@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.2.0", "axios": "^1.2.0",
"color": "^4.2.3", "color": "^4.2.3",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
"prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1", "react-hook-form": "^7.53.1",
"react-icons": "5.4.0",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"react-simple-code-editor": "^0.14.1",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
@@ -64,11 +67,12 @@
"@sentry/vite-plugin": "^2.16.1", "@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4", "@tanstack/eslint-plugin-query": "^5.8.4",
"@types/color": "^3.0.3", "@types/color": "^3.0.3",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "4.5.1",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
@@ -82,10 +86,10 @@
"prettier": "catalog:", "prettier": "catalog:",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "^5.2.11", "vite": "6.3.1",
"vite-plugin-compression2": "^1.3.3", "vite-plugin-compression2": "1.4.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "^4.3.1", "vite-tsconfig-paths": "5.1.4",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }
+8 -1
View File
@@ -158,7 +158,14 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<PCuesheet />} /> <Route path='/cuesheet' element={<PCuesheet />} />
<Route path='/op' element={<POperator />} /> <Route
path='/op'
element={
<ViewLoader>
<POperator />
</ViewLoader>
}
/>
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios';
import { apiEntryUrl } from './constants';
const assetsPath = `${apiEntryUrl}/assets`;
/**
* HTTP request to get css contents
*/
export async function getCSSContents(): Promise<string> {
const res = await axios.get(`${assetsPath}/css`);
return res.data;
}
/**
* HTTP request to post css contents
*/
export async function postCSSContents(css: string): Promise<void> {
await axios.post(`${assetsPath}/css`, {
css,
});
}
/**
* HTTP request to restore default css
*/
export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`);
return res.data;
}
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5';
import { import {
IconButton, IconButton,
Input, Input,
@@ -10,7 +11,6 @@ import {
ModalOverlay, ModalOverlay,
Select, Select,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { IoArrowForward } from '@react-icons/all-files/io5/IoArrowForward';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
@@ -2,8 +2,8 @@
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx // https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { ReactElement } from 'react'; import { ReactElement } from 'react';
import { IconType } from 'react-icons';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react'; import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand'; import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption'; import { ContextMenuOption } from './ContextMenuOption';
@@ -1,7 +1,7 @@
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react'; import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { tooltipDelayFast } from '../../../ontimeConfig';
import { Size } from '../../models/Util.type'; import { Size } from '../../models/Util.type';
@@ -1,6 +1,5 @@
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig'; import { millisToDelayString } from '../../utils/dateConfig';
@@ -26,17 +26,22 @@ class ErrorBoundary extends React.Component {
error, error,
errorInfo: info, errorInfo: info,
}); });
this.reportContent = `${error} ${info.componentStack}`;
const appState = runtimeStore.getState();
if (appState.ping < 0) {
return;
}
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setExtras({ scope.setExtras({
error, error,
store: runtimeStore.getState(), store: appState,
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts }, hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
}); });
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
}); });
this.reportContent = `${error} ${info.componentStack}`;
} }
render() { render() {
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle'; import { IoAlertCircle } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,4 +1,4 @@
import { IoBan } from '@react-icons/all-files/io5/IoBan'; import { IoBan } from 'react-icons/io5';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form'; import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop'; import { IoEyedrop } from 'react-icons/io5';
import { ViewSettings } from 'ontime-types'; import { ViewSettings } from 'ontime-types';
import { debounce } from '../../../utils/debounce'; import { debounce } from '../../../utils/debounce';
@@ -17,6 +17,7 @@ export default function useReactiveTextInput(
submitOnCtrlEnter?: boolean; submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void; onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean; allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean;
}, },
): UseReactiveTextInputReturn { ): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText); const [text, setText] = useState<string>(initialText);
@@ -58,7 +59,14 @@ export default function useReactiveTextInput(
setText(cleanVal); 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, options, ref, submitCallback], [initialText, options, ref, submitCallback],
); );
@@ -70,12 +78,25 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
// No need to update if it hasn't changed // No need to update if it hasn't changed
setText(initialText); setText(initialText);
// force the text to be the initial value
if (ref.current) {
ref.current.value = initialText;
}
options?.onCancelUpdate?.(); options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
}, [initialText, options, ref]); }, [initialText, options, ref]);
const keyHandler = useMemo(() => { const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]]; const hotKeys: HotkeyItem[] = [
[
'Escape',
(event) => {
event.preventDefault();
handleEscape();
},
{ preventDefault: true },
],
];
if (options?.submitOnEnter) { if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => handleSubmit(text)]); hotKeys.push(['Enter', () => handleSubmit(text)]);
@@ -84,7 +105,22 @@ export default function useReactiveTextInput(
if (options?.submitOnCtrlEnter) { if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => handleSubmit(text)]); 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]); }, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return { return {
@@ -3,8 +3,9 @@
font-size: $text-body-size; font-size: $text-body-size;
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
color: $blue-400;
&:hover { &:hover {
color: $blue-400; color: $blue-500;
} }
} }
@@ -1,5 +1,5 @@
import { MouseEvent, ReactNode } from 'react'; import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline'; import { IoOpenOutline } from 'react-icons/io5';
import { openLink } from '../../../utils/linkUtils'; import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -1,5 +1,5 @@
import { IoApps } from '@react-icons/all-files/io5/IoApps'; import { IoApps } from 'react-icons/io5';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; import { IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,5 +1,6 @@
import { memo, PropsWithChildren } from 'react'; import { memo, PropsWithChildren } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { IoArrowUp, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { import {
Drawer, Drawer,
@@ -11,11 +12,6 @@ import {
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks'; import { useFullscreen } from '@mantine/hooks';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
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 { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
@@ -1,4 +1,4 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline'; import { IoLockClosedOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,7 +1,5 @@
import { IoPause, IoPlay, IoStop } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback } from 'ontime-types'; import { Playback } from 'ontime-types';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { tooltipDelayFast } from '../../../ontimeConfig';
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react'; import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import style from './ProtectRoute.module.scss'; import style from './ProtectRoute.module.scss';
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
Button, Button,
@@ -13,7 +14,6 @@ import {
Select, Select,
Switch, Switch,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
@@ -126,9 +126,9 @@ function MultiOption(props: EditFormMultiOptionProps) {
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])} onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
> >
{Object.values(paramField.values).map((option) => { {Object.values(paramField.values).map((option) => {
const { value, label } = option; const { value, label, colour } = option;
return ( return (
<MenuItemOption value={value} key={value}> <MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
{label} {label}
</MenuItemOption> </MenuItemOption>
); );
@@ -1,6 +1,7 @@
import { IoChevronDown } from 'react-icons/io5';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { baseURI } from '../../../externals';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
@@ -17,7 +18,7 @@ interface ViewParamsSectionProps {
export default function ViewParamsSection(props: ViewParamsSectionProps) { export default function ViewParamsSection(props: ViewParamsSectionProps) {
const { title, collapsible, options } = props; const { title, collapsible, options } = props;
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); const [collapsed, setCollapsed] = useLocalStorage({ key: `${baseURI}params-${title}`, defaultValue: false });
const handleCollapse = () => { const handleCollapse = () => {
if (collapsible) { if (collapsible) {
@@ -1,13 +1,13 @@
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import type { ParamField } from './types'; import type { MultiselectOptions, ParamField } from './types';
export const makeOptionsFromCustomFields = ( export const makeOptionsFromCustomFields = (
customFields: CustomFields, customFields: CustomFields,
additionalOptions: Record<string, string> = {}, additionalOptions: Readonly<Record<string, string>> = {},
filterImageType = true, filterImageType = true,
) => { ) => {
const options = structuredClone(additionalOptions); const options = { ...additionalOptions };
for (const [key, value] of Object.entries(customFields)) { for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') { if (filterImageType && value.type === 'image') {
continue; continue;
@@ -18,6 +18,17 @@ export const makeOptionsFromCustomFields = (
return options; 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 => { export const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`; const placeholder = `${timeFormat} (default)`;
return { return {
@@ -12,9 +12,11 @@ type OptionsField = {
defaultValue?: string; defaultValue?: string;
}; };
export type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>;
type MultiOptionsField = { type MultiOptionsField = {
type: 'multi-option'; type: 'multi-option';
values: Record<string, { value: string; label: string; colour: string }>; values: MultiselectOptions;
defaultValue?: string; defaultValue?: string;
}; };
@@ -1,5 +1,6 @@
import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react'; import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react';
import { baseURI } from '../../externals';
import useSettings from '../hooks-query/useSettings'; import useSettings from '../hooks-query/useSettings';
interface AppContextType { interface AppContextType {
@@ -26,7 +27,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
useEffect(() => { useEffect(() => {
if (status === 'pending') return; if (status === 'pending') return;
const previousEditor = sessionStorage.getItem(storageKeys.editor); const previousEditor = sessionStorage.getItem(`${baseURI}${storageKeys.editor}`);
if (previousEditor && previousEditor === data.editorKey) { if (previousEditor && previousEditor === data.editorKey) {
setEditorAuth(true); setEditorAuth(true);
@@ -34,7 +35,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
setEditorAuth(data.editorKey == null || data.editorKey === ''); setEditorAuth(data.editorKey == null || data.editorKey === '');
} }
const previousOperator = sessionStorage.getItem(storageKeys.operator); const previousOperator = sessionStorage.getItem(`${baseURI}${storageKeys.operator}`);
if (previousOperator && previousOperator === data.operatorKey) { if (previousOperator && previousOperator === data.operatorKey) {
setOperatorAuth(true); setOperatorAuth(true);
} else { } else {
@@ -55,14 +56,14 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
if (permission === 'editor') { if (permission === 'editor') {
const correct = isValid(pin, data.editorKey); const correct = isValid(pin, data.editorKey);
if (correct) { if (correct) {
sessionStorage.setItem(storageKeys.editor, pin); sessionStorage.setItem(`${baseURI}${storageKeys.editor}`, pin);
} }
setEditorAuth(correct); setEditorAuth(correct);
return correct; return correct;
} else if (permission === 'operator') { } else if (permission === 'operator') {
const correct = isValid(pin, data.operatorKey); const correct = isValid(pin, data.operatorKey);
if (correct) { if (correct) {
sessionStorage.setItem(storageKeys.operator, pin); sessionStorage.setItem(`${baseURI}${storageKeys.operator}`, pin);
} }
setOperatorAuth(correct); setOperatorAuth(correct);
return correct; return correct;
@@ -41,7 +41,7 @@ export function useFlatRundown() {
// update data whenever the revision changes // update data whenever the revision changes
useEffect(() => { useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) { if (data.revision !== -1 || data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.rundown[id]); const flatRundown = data.order.map((id) => data.rundown[id]);
setFlatRunDown(flatRundown); setFlatRunDown(flatRundown);
setPrevRevision(data.revision); setPrevRevision(data.revision);
+17 -2
View File
@@ -1,4 +1,4 @@
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
@@ -160,7 +160,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset, offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
currentBlock: state.currentBlock, currentBlock: state.currentBlock,
})); }));
@@ -170,6 +170,15 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset, offset: state.runtime.offset,
})); }));
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart,
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({ export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset, offset: state.runtime.offset,
})); }));
@@ -183,6 +192,12 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
isOnline: state.ping > 0, isOnline: state.ping > 0,
})); }));
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
offsetMode: state.runtime.offsetMode,
}));
export const setOffsetMode = (payload: OffsetMode) => socketSendJson('offsetmode', payload);
export const usePlayback = () => { export const usePlayback = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
@@ -8,4 +8,5 @@ export const projectDataPlaceholder: ProjectData = {
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
custom: [],
}; };
@@ -1,12 +1,14 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { baseURI } from '../../externals';
export enum AppMode { export enum AppMode {
Run = 'run', Run = 'run',
Edit = 'edit', Edit = 'edit',
Freeze = 'freeze', Freeze = 'freeze',
} }
const appModeKey = 'ontime-app-mode'; const appModeKey = `${baseURI}ontime-app-mode`;
function getModeFromSession() { function getModeFromSession() {
return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit; return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
+3 -1
View File
@@ -1,6 +1,8 @@
import { ClientList } from 'ontime-types'; import { ClientList } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
import { baseURI } from '../../externals';
interface ClientStore { interface ClientStore {
name?: string; name?: string;
setName: (newValue: string) => void; setName: (newValue: string) => void;
@@ -15,7 +17,7 @@ interface ClientStore {
setClients: (clients: ClientList) => void; setClients: (clients: ClientList) => void;
} }
const clientNameKey = 'ontime-client-name'; const clientNameKey = `${baseURI}ontime-client-name`;
function persistNameInStorage(newValue: string) { function persistNameInStorage(newValue: string) {
localStorage.setItem(clientNameKey, newValue); localStorage.setItem(clientNameKey, newValue);
+4 -2
View File
@@ -1,6 +1,8 @@
// eslint-disable-next-line simple-import-sort/imports
import { create } from 'zustand'; import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage'; import { booleanFromLocalStorage } from '../utils/localStorage';
import { baseURI } from '../../externals';
enum LocalEventKeys { enum LocalEventKeys {
Mirror = 'ontime-view-mirror', Mirror = 'ontime-view-mirror',
@@ -12,11 +14,11 @@ type ViewOptionsStore = {
}; };
export const useViewOptionsStore = create<ViewOptionsStore>()((set) => ({ export const useViewOptionsStore = create<ViewOptionsStore>()((set) => ({
mirror: booleanFromLocalStorage(LocalEventKeys.Mirror, false), mirror: booleanFromLocalStorage(`${baseURI}${LocalEventKeys.Mirror}`, false),
toggleMirror: (newValue?: boolean) => toggleMirror: (newValue?: boolean) =>
set((state) => { set((state) => {
const val = typeof newValue === 'undefined' ? !state.mirror : newValue; const val = typeof newValue === 'undefined' ? !state.mirror : newValue;
localStorage.setItem(LocalEventKeys.Mirror, String(val)); localStorage.setItem(`${baseURI}${LocalEventKeys.Mirror}`, String(val));
return { mirror: val }; return { mirror: val };
}), }),
})); }));
@@ -2,4 +2,4 @@
exports[`cx() > ignores falsy values 1`] = `""`; exports[`cx() > ignores falsy values 1`] = `""`;
exports[`cx() > merges styles 1`] = `"_test_98a1e0 _another_98a1e0"`; exports[`cx() > merges styles 1`] = `"_test_d5d33f _another_d5d33f"`;
@@ -1,10 +1,10 @@
import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types'; import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../eventsManager'; import { cloneEvent } from '../eventsManager';
describe('cloneEvent()', () => { describe('cloneEvent()', () => {
it('creates a stem from a given event', () => { it('creates a stem from a given event', () => {
const original = { const original: OntimeEvent = {
id: 'unique', id: 'unique',
type: SupportedEvent.Event, type: SupportedEvent.Event,
title: 'title', title: 'title',
@@ -14,6 +14,9 @@ describe('cloneEvent()', () => {
duration: 10, duration: 10,
timeEnd: 10, timeEnd: 10,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
countToEnd: false,
endAction: EndAction.None, endAction: EndAction.None,
isPublic: false, isPublic: false,
skip: false, skip: false,
@@ -21,29 +24,39 @@ describe('cloneEvent()', () => {
revision: 10, revision: 10,
timeWarning: 120000, timeWarning: 120000,
timeDanger: 60000, timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
custom: { custom: {
lighting: '3', lighting: '3',
} as EventCustomFields, } as EventCustomFields,
} as OntimeEvent; };
const cloned = cloneEvent(original); const cloned = cloneEvent(original);
expect(cloned).not.toBe(original); expect(cloned).not.toBe(original);
// @ts-expect-error -- safeguarding this expect(cloned.custom).not.toBe(original.custom);
expect(cloned?.id).toBe(undefined);
expect(cloned.title).toBe(original.title); expect(cloned).toMatchObject({
expect(cloned.note).toBe(original.note); type: SupportedEvent.Event,
expect(cloned.endAction).toBe(original.endAction); title: original.title,
expect(cloned.timerType).toBe(original.timerType); note: original.note,
expect(cloned.timeStart).toBe(original.timeStart); timeStart: original.timeStart,
expect(cloned.timeEnd).toBe(original.timeEnd); duration: original.duration,
expect(cloned.duration).toBe(original.duration); timeEnd: original.timeEnd,
expect(cloned.isPublic).toBe(original.isPublic); timerType: original.timerType,
expect(cloned.skip).toBe(original.skip); timeStrategy: original.timeStrategy,
expect(cloned.colour).toBe(original.colour); countToEnd: original.countToEnd,
expect(cloned.type).toBe(SupportedEvent.Event); linkStart: original.linkStart,
expect(cloned.revision).toBe(0); endAction: original.endAction,
expect(cloned.timeWarning).toBe(original.timeWarning); isPublic: original.isPublic,
expect(cloned.timeDanger).toBe(original.timeDanger); skip: original.skip,
expect(cloned.custom).toStrictEqual({}); colour: original.colour,
revision: 0,
delay: original.delay,
dayOffset: original.dayOffset,
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
});
}); });
}); });
@@ -1,4 +1,7 @@
import { formatTime, nowInMillis } from '../time'; import { OffsetMode } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => { describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => { it('should return the current time in milliseconds', () => {
@@ -38,3 +41,257 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('-01:00'); expect(time).toStrictEqual('-01:00');
}); });
}); });
describe('calculateTimeUntilStart()', () => {
describe('Absolute offset mode', () => {
test('ontime', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: 0,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
});
test('running ahead', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 80,
offset: 10,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind with enough gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 20,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
test('running behind with too little gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: 0,
plannedStart: 0,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
});
describe('Relative offset mode', () => {
test('basic function', () => {
const test = {
timeStart: 0,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
const timeStartEvent2 = 10;
const timeStartEvent3 = 20;
//event 1 is the currently running event
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
});
test('gaps', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
});
test('added/remove time', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
test.offset = 5; // remove 5 with addtime - we are ahead of time
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
test.offset = -5; // add 5 with addtime - we are behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
});
test('next day', () => {
const test = {
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
// this event will start the current day
expect(
calculateTimeUntilStart({
...test,
timeStart: 10,
dayOffset: 0,
totalGap: 0,
isLinkedToLoaded: false,
}),
).toBe(10);
// this event will start the next day
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs);
// advancing 100ms
test.clock = 200;
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs - 100);
});
});
test('overlap with negative total gap', () => {
const test = {
dayOffset: 0,
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
isLinkedToLoaded: false,
};
// the overlap will be pushed out to the expected available time
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
test.clock = 105;
});
});
@@ -24,11 +24,11 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
revision: 0, revision: 0,
delay: 0, delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: 0, dayOffset: event.dayOffset,
gap: 0, gap: 0,
timeWarning: event.timeWarning, timeWarning: event.timeWarning,
timeDanger: event.timeDanger, timeDanger: event.timeDanger,
custom: {}, custom: { ...event.custom },
}; };
}; };
@@ -0,0 +1,12 @@
/**
* Allows lazy evaluation and caching of a functions result
*/
export function lazyEvaluate<T>(fn: () => T): () => T {
let result: T | undefined;
return function () {
if (result === undefined) {
result = fn();
}
return result;
};
}
@@ -0,0 +1,8 @@
import { MaybeString } from 'ontime-types';
export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number {
if (!value) return defaultValue;
const number = Number(value);
if (isNaN(number)) return defaultValue;
return number;
}
+3 -1
View File
@@ -48,10 +48,12 @@ export const connectSocket = () => {
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
setOnlineStatus(false);
if (shouldReconnect) { if (shouldReconnect) {
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) {
setOnlineStatus(false);
}
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) { if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1; reconnectAttempts += 1;
+89 -2
View File
@@ -1,8 +1,9 @@
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types'; import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig'; import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants'; import { APP_SETTINGS } from '../api/constants';
import { useTimeUntilData } from '../hooks/useSocket';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
/** /**
@@ -125,3 +126,89 @@ export function formatDuration(duration: number, hideSeconds = true): string {
} }
return result; return result;
} }
/**
*
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @returns
*/
export function useTimeUntilStart(
// typed like this to make it very clear what the data is
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
totalGap: number;
isLinkedToLoaded: boolean;
},
): number {
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
}
/**
*
* @param currentDay the day offset of the urrently running event
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @param clock
* @param offset
* @returns
*/
export function calculateTimeUntilStart(
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
currentDay: number;
totalGap: number;
isLinkedToLoaded: boolean;
clock: number;
offset: number;
offsetMode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
},
): number {
const {
timeStart,
dayOffset,
currentDay,
totalGap,
isLinkedToLoaded,
clock,
offset,
delay,
offsetMode,
actualStart,
plannedStart,
} = data;
//How many days from the currently running event to this one
const relativeDayOffset = dayOffset - currentDay;
const delayedStart = Math.max(0, timeStart + delay);
//The normalised start time of this event relative to the currently running event
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
let relativeStartOffset = 0;
if (offsetMode === OffsetMode.Relative) {
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
}
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
const offsetTimeUntil = scheduledTimeUntil - offset;
if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset
return offsetTimeUntil;
}
const gapsCanCompensateForOffset = totalGap + offset >= 0;
if (gapsCanCompensateForOffset) {
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
return scheduledTimeUntil;
}
// otherwise consume as much of the offset as possible with the gap
const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap;
return offsetTimeUntilBufferedByGaps;
}
+4 -3
View File
@@ -19,9 +19,10 @@ export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
export const appVersion = version; export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production'; export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction; export const isDev = !isProduction;
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; export const currentHostName = window.location.hostname;
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER); export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no'); export const isOntimeCloud = currentHostName.includes('cloud.getontime.no');
// resolve entrypoint URLs // resolve entrypoint URLs
@@ -75,4 +76,4 @@ function resolveBaseURI(): string {
} }
return base; return base;
} }
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoClose } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import style from './PanelContent.module.scss'; import style from './PanelContent.module.scss';
@@ -207,6 +207,9 @@ $inner-padding: 1rem;
&.end { &.end {
justify-content: flex-end; justify-content: flex-end;
} }
&.apart {
justify-content: space-between;
}
} }
@keyframes animloader { @keyframes animloader {
@@ -1,6 +1,6 @@
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
@@ -113,8 +113,8 @@ export function BlockQuote({ children }: { children: ReactNode }) {
return <blockquote className={style.blockquote}>{children}</blockquote>; return <blockquote className={style.blockquote}>{children}</blockquote>;
} }
export function Error({ children }: { children: ReactNode }) { export function Error({ children, className }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
return <div className={style.fieldError}>{children}</div>; return <div className={cx([style.fieldError, className])}>{children}</div>;
} }
export function Divider() { export function Divider() {
@@ -136,7 +136,7 @@ type AllowedInlineTags = 'div' | 'td';
type InlineProps<C extends AllowedInlineTags> = { type InlineProps<C extends AllowedInlineTags> = {
as?: C; as?: C;
relation?: 'inner' | 'component' | 'section'; relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end'; align?: 'start' | 'end' | 'apart';
className?: string; className?: string;
}; };
@@ -1,8 +1,7 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react'; import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { import {
Automation, Automation,
AutomationDTO, AutomationDTO,
@@ -31,7 +30,7 @@ import OntimeActionForm from './OntimeActionForm';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/integrations/#using-variables-in-integrations'; const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
interface AutomationFormProps { interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
@@ -1,8 +1,6 @@
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
import { Button, IconButton } from '@chakra-ui/react'; import { Button, IconButton } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { AutomationDTO, NormalisedAutomation } from 'ontime-types'; import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { deleteAutomation } from '../../../../common/api/automation'; import { deleteAutomation } from '../../../../common/api/automation';
@@ -1,6 +1,6 @@
import { Fragment, useMemo, useState } from 'react'; import { Fragment, useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { NormalisedAutomation, Trigger } from 'ontime-types'; import { NormalisedAutomation, Trigger } from 'ontime-types';
import { deleteTrigger } from '../../../../common/api/automation'; import { deleteTrigger } from '../../../../common/api/automation';
@@ -1,8 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react'; import { IconButton } from '@chakra-ui/react';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types'; import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { IoTrashBin } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoTrashBin } from '@react-icons/all-files/io5/IoTrashBin';
import { deleteAllReport } from '../../../../common/api/report'; import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils'; import { createBlob, downloadBlob } from '../../../../common/api/utils';
@@ -1,9 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoOpenOutline, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Switch } from '@chakra-ui/react'; import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { URLPreset } from 'ontime-types'; import { URLPreset } from 'ontime-types';
import { postUrlPresets } from '../../../../common/api/urlPresets'; import { postUrlPresets } from '../../../../common/api/urlPresets';
@@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react'; import { IconButton } from '@chakra-ui/react';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { CustomField, CustomFieldLabel } from 'ontime-types'; import { CustomField, CustomFieldLabel } from 'ontime-types';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { CustomField, CustomFieldLabel } from 'ontime-types'; import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields'; import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
@@ -1,7 +1,7 @@
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren, useState } from 'react';
import { UseFormRegister } from 'react-hook-form'; import { UseFormRegister } from 'react-hook-form';
import { IoEyeOutline } from 'react-icons/io5';
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react'; import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
import { Settings } from 'ontime-types'; import { Settings } from 'ontime-types';
import { isAlphanumeric } from '../../../../common/utils/regex'; import { isAlphanumeric } from '../../../../common/utils/regex';
@@ -0,0 +1,4 @@
.wrapper {
max-height: 500px;
overflow-y: auto;
}
@@ -0,0 +1,72 @@
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
import Editor from 'react-simple-code-editor';
import Prism from 'prismjs/components/prism-core';
import 'prismjs/components/prism-css';
import 'prismjs/themes/prism-tomorrow.min.css';
import style from './StyleEditor.module.scss';
interface CodeEditorProps {
language: string;
initialValue: string;
isDirty: boolean;
setIsDirty: (value: boolean) => void;
}
const CodeEditor = forwardRef((props: CodeEditorProps, cssRef) => {
const { language, initialValue, isDirty, setIsDirty } = props;
const [code, setCode] = useState(initialValue);
const highlight = (code: string) => {
const grammar = Prism.languages[language];
return grammar ? Prism.highlight(code, grammar, language) : code;
};
const handleChange = (newCode: string) => {
setCode(newCode);
};
useImperativeHandle(cssRef, () => {
return {
getCss: () => code,
};
});
// add contents to editor on mount and any change in initialValue
useEffect(() => {
setCode(initialValue);
}, [initialValue]);
// handle dirty state on change
useEffect(() => {
if (initialValue.trim() !== code.trim() && !isDirty && code.length !== 0) {
setIsDirty(true);
}
if (initialValue.trim() === code.trim() && isDirty) {
setIsDirty(false);
}
}, [initialValue, code, isDirty, setIsDirty]);
return (
<div className={style.wrapper}>
<Editor
value={code}
padding={15}
onValueChange={handleChange}
highlight={highlight}
style={{
fontFamily: 'monospace',
fontSize: 12,
minHeight: 500,
background: '#2d2d2d', // Background of tomorrow theme
}}
/>
</div>
);
});
CodeEditor.displayName = 'StyleEditor';
export default memo(CodeEditor);
@@ -0,0 +1,14 @@
.editorActions {
width: 100%;
}
.right {
align-self: end;
text-align: right
}
.column {
align-items: start;
display: flex;
flex-direction: column;
}
@@ -0,0 +1,129 @@
import { lazy, useEffect, useRef, useState } from 'react';
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../common/api/assets';
import Info from '../../../../common/components/info/Info';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './StyleEditorModal.module.scss';
const CodeEditor = lazy(() => import('./StyleEditor'));
interface CodeEditorModalProps {
isOpen: boolean;
onClose: () => void;
}
interface CSSRef {
getCss: () => string;
}
export default function CodeEditorModal(props: CodeEditorModalProps) {
const { isOpen, onClose } = props;
const [css, setCSS] = useState('');
const [isDirty, setIsDirty] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [resetLoading, setResetLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const cssRef = useRef<CSSRef>(null);
const handleRestore = async () => {
try {
setResetLoading(true);
const defaultCss = await restoreCSSContents();
setCSS(defaultCss);
} catch (_error) {
/** no error handling for now */
} finally {
setResetLoading(false);
}
};
const handleSave = async () => {
try {
setSaveLoading(true);
if (cssRef.current) {
await postCSSContents(cssRef.current.getCss());
setCSS(cssRef.current.getCss());
setIsDirty(false);
}
} catch (_error) {
/** no error handling for now */
} finally {
setSaveLoading(false);
}
};
const clear = () => setCSS('');
useEffect(() => {
async function fetchServerCSS() {
// check for isOpen to fetch recent css
if (isOpen) {
try {
const css = await getCSSContents();
setCSS(css);
} catch (_error) {
setError('Failed to load CSS from server');
/** no error handling for now */
}
}
}
fetchServerCSS();
}, [isOpen]);
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime' isCentered>
<ModalOverlay />
<ModalContent maxWidth='max(800px, 40vw)'>
<ModalHeader>Edit CSS override</ModalHeader>
<ModalCloseButton />
<ModalBody>
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
</ModalBody>
<ModalFooter className={style.column}>
<Info>Invalid CSS will be refused by the browser</Info>
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
<Panel.InlineElements align='apart' className={style.editorActions}>
<Button
variant='ontime-ghosted'
onClick={handleRestore}
isDisabled={saveLoading || resetLoading}
isLoading={resetLoading}
>
Reset to example
</Button>
<Panel.InlineElements>
<Button variant='ontime-ghosted' onClick={clear}>
Clear
</Button>
<Button variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button
variant='ontime-filled'
onClick={handleSave}
isDisabled={saveLoading || resetLoading || !isDirty}
isLoading={saveLoading}
>
Save changes
</Button>
</Panel.InlineElements>
</Panel.InlineElements>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { Button, Input, Switch } from '@chakra-ui/react'; import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types'; import { ViewSettings } from 'ontime-types';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
@@ -14,11 +14,14 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOntimeCloud } from '../../../../externals'; import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import CodeEditorModal from './StyleEditorModal';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/'; const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettingsForm() { export default function ViewSettingsForm() {
const { data, status, refetch } = useViewSettings(); const { data, status, refetch } = useViewSettings();
const { data: info, status: infoStatus } = useInfo(); const { data: info, status: infoStatus } = useInfo();
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
const { const {
control, control,
@@ -103,6 +106,7 @@ export default function ViewSettingsForm() {
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={isLoading} /> <Panel.Loader isLoading={isLoading} />
<Panel.ListGroup> <Panel.ListGroup>
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Override CSS styles' title='Override CSS styles'
@@ -115,6 +119,15 @@ export default function ViewSettingsForm() {
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} /> <Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)} )}
/> />
<Button
onClick={onCodeEditorOpen}
variant='ontime-subtle'
size='sm'
isDisabled={!data.overrideStyles}
width='fit-content'
>
Edit CSS override
</Button>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
<Panel.ListGroup> <Panel.ListGroup>
@@ -0,0 +1,5 @@
declare module 'prismjs/components/prism-core' {
export * from 'prismjs';
}
declare module 'prismjs/components/prism-css';
@@ -3,3 +3,12 @@
background: $ui-white; background: $ui-white;
border-radius: 3px; border-radius: 3px;
} }
.column {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
color: $label-gray;
user-select: text;
}
@@ -6,13 +6,12 @@ import { Button, Select, Switch } from '@chakra-ui/react';
import { generateUrl } from '../../../../common/api/session'; import { generateUrl } from '../../../../common/api/session';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets'; import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import copyToClipboard from '../../../../common/utils/copyToClipboard'; import copyToClipboard from '../../../../common/utils/copyToClipboard';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { linkToOtherHost } from '../../../../common/utils/linkUtils'; import { linkToOtherHost } from '../../../../common/utils/linkUtils';
import { serverURL } from '../../../../externals'; import { currentHostName, isOntimeCloud, serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './GenerateLinkForm.module.scss'; import style from './GenerateLinkForm.module.scss';
@@ -39,6 +38,12 @@ export default function GenerateLinkForm() {
formState: { errors }, formState: { errors },
} = useForm<GenerateLinkFormOptions>({ } = useForm<GenerateLinkFormOptions>({
mode: 'onChange', mode: 'onChange',
defaultValues: {
baseUrl: currentHostName,
path: '',
lock: false,
authenticate: false,
},
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
@@ -72,8 +77,11 @@ export default function GenerateLinkForm() {
</Info> </Info>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Host IP' description='Which IP address will be used' /> <Panel.Field
<Select variant='ontime' size='sm' {...register('baseUrl')}> title='Host IP'
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
/>
<Select variant='ontime' isDisabled={isOntimeCloud} size='sm' {...register('baseUrl')}>
{infoData.networkInterfaces.map((nif) => { {infoData.networkInterfaces.map((nif) => {
return ( return (
<option key={nif.name} value={nif.address}> <option key={nif.name} value={nif.address}>
@@ -128,9 +136,9 @@ export default function GenerateLinkForm() {
> >
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'} {formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
</Button> </Button>
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.25rem' }}> <div className={style.column}>
<QRCode size={172} value={url} className={style.qrCode} /> <QRCode size={172} value={url} className={style.qrCode} />
<ExternalLink href={url}>{url}</ExternalLink> <div>{url}</div>
</div> </div>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
@@ -1,4 +1,4 @@
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; import { IoArrowUp } from 'react-icons/io5';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
@@ -1,6 +1,6 @@
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
import { IoArrowUp } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { handleLinks } from '../../../../common/utils/linkUtils'; import { handleLinks } from '../../../../common/utils/linkUtils';
import Log from '../../../log/Log'; import Log from '../../../log/Log';
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket'; import { usePing } from '../../../../common/hooks/useSocket';
import { socketSendJson } from '../../../../common/utils/socket'; import { socketSendJson } from '../../../../common/utils/socket';
import { isDockerImage } from '../../../../externals'; import { isDockerImage, isOntimeCloud } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel'; import ClientControlPanel from '../client-control-panel/ClientControlPanel';
@@ -30,8 +30,12 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
<Panel.Card> <Panel.Card>
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader> <Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph> {!isOntimeCloud && (
<InfoNif /> <>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
<InfoNif />
</>
)}
<GenerateLinkForm /> <GenerateLinkForm />
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
@@ -1,7 +1,7 @@
import { ChangeEvent, useRef, useState } from 'react'; import { ChangeEvent, useRef, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { uploadProjectFile } from '../../../../common/api/db'; import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@@ -23,6 +24,7 @@ type ProjectCreateFormValues = {
publicUrl?: string; publicUrl?: string;
backstageInfo?: string; backstageInfo?: string;
backstageUrl?: string; backstageUrl?: string;
custom?: { title: string; value: string }[];
}; };
export default function ProjectCreateForm(props: ProjectCreateFromProps) { export default function ProjectCreateForm(props: ProjectCreateFromProps) {
@@ -34,6 +36,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
control,
formState: { isSubmitting, isValid }, formState: { isSubmitting, isValid },
setFocus, setFocus,
} = useForm<ProjectCreateFormValues>({ } = useForm<ProjectCreateFormValues>({
@@ -44,6 +47,11 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
}, },
}); });
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// set focus to first field // set focus to first field
useEffect(() => { useEffect(() => {
setFocus('title'); setFocus('title');
@@ -59,6 +67,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
...values, ...values,
filename, filename,
}); });
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST }); await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -66,6 +75,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
} }
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
return ( return (
<Panel.Section <Panel.Section
as='form' as='form'
@@ -151,6 +164,42 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
<Panel.Section>
<Panel.ListItem>
<Panel.Field title='Custom data' description='Add custom data for your project' />
<Button variant='ontime-subtle' onClick={handleAddCustom}>
+
</Button>
</Panel.ListItem>
{fields.map((field, idx) => (
<div key={field.id} className={style.customDataItem}>
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
placeholder={field.title}
autoComplete='off'
{...register(`custom.${idx}.title` as const)}
/>
</label>
<label>
Value
<Input
variant='ontime-filled'
size='sm'
placeholder={field.value}
autoComplete='off'
{...register(`custom.${idx}.value` as const)}
/>
</label>
<Button variant='ontime-ghosted' onClick={() => remove(idx)}>
<IoTrash />
</Button>
</div>
))}
</Panel.Section>
</Panel.Section> </Panel.Section>
</Panel.Section> </Panel.Section>
); );
@@ -1,8 +1,7 @@
import { ChangeEvent, useEffect, useRef } from 'react'; import { ChangeEvent, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { type ProjectData } from 'ontime-types'; import { type ProjectData } from 'ontime-types';
import { projectLogoPath } from '../../../../common/api/constants'; import { projectLogoPath } from '../../../../common/api/constants';
@@ -26,6 +25,7 @@ export default function ProjectData() {
formState: { isSubmitting, isValid, isDirty, errors }, formState: { isSubmitting, isValid, isDirty, errors },
setError, setError,
watch, watch,
control,
setValue, setValue,
} = useForm({ } = useForm({
defaultValues: data, defaultValues: data,
@@ -33,6 +33,12 @@ export default function ProjectData() {
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
mode: 'onChange',
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
}); });
// reset form values if data changes // reset form values if data changes
@@ -78,6 +84,10 @@ export default function ProjectData() {
}); });
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
const onSubmit = async (formData: ProjectData) => { const onSubmit = async (formData: ProjectData) => {
try { try {
await postProjectData(formData); await postProjectData(formData);
@@ -97,7 +107,7 @@ export default function ProjectData() {
const isLoading = status === 'pending'; const isLoading = status === 'pending';
return ( return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}> <Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}>
<Panel.Card> <Panel.Card>
<Panel.SubHeader> <Panel.SubHeader>
Project data Project data
@@ -232,6 +242,69 @@ export default function ProjectData() {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
<Panel.Section style={{ marginTop: 0 }}>
<Panel.ListItem>
<Panel.Field title='Custom data' description='' />
<Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
Add
</Button>
</Panel.ListItem>
{fields.length > 0 &&
fields.map((field, idx) => {
const rowErrors = errors.custom?.[idx] as
| {
title?: { message?: string };
value?: { message?: string };
}
| undefined;
return (
<div key={field.id} className={style.customDataItem}>
<div>
<div className={style.titleRow}>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
defaultValue={field.title}
placeholder='Title of your custom data'
autoComplete='off'
{...register(`custom.${idx}.title`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
</label>
<Button
size='sm'
variant='ontime-subtle'
color='#FA5656' // $red-500
onClick={() => remove(idx)}
leftIcon={<IoTrash />}
>
Delete Entry
</Button>
</div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
</div>
<label>
Value
<Textarea
variant='ontime-filled'
resize='none'
size='sm'
defaultValue={field.value}
autoComplete='off'
placeholder='Text of your custom data'
{...register(`custom.${idx}.value`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
</label>
</div>
);
})}
</Panel.Section>
</Panel.Section> </Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react'; import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
import { import {
deleteProject, deleteProject,
@@ -57,3 +57,18 @@
height: auto; height: auto;
} }
} }
.customDataItem {
display: contents;
width: 100%;
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
@@ -1,7 +1,6 @@
import { ChangeEvent, useEffect, useState } from 'react'; import { ChangeEvent, useEffect, useState } from 'react';
import { IoCheckmark, IoShieldCheckmarkOutline } from 'react-icons/io5';
import { Button, Input, Spinner } from '@chakra-ui/react'; import { Button, Input, Spinner } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
import { getWorksheetNames } from '../../../../common/api/sheets'; import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
@@ -1,7 +1,6 @@
import { ChangeEvent, useRef, useState } from 'react'; import { ChangeEvent, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { getErrorMessage, ImportMap } from 'ontime-utils'; import { getErrorMessage, ImportMap } from 'ontime-utils';
import { import {
@@ -104,6 +103,7 @@ export default function SourcesPanel() {
}; };
const handleSubmitImportPreview = async (importMap: ImportMap) => { const handleSubmitImportPreview = async (importMap: ImportMap) => {
setError(''); // to clear previous error
if (importFlow === 'excel') { if (importFlow === 'excel') {
try { try {
const previewData = await importRundownPreviewExcel(importMap); const previewData = await importRundownPreviewExcel(importMap);
@@ -1,8 +1,7 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react'; import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils'; import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
@@ -29,6 +28,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
control, control,
handleSubmit, handleSubmit,
register, register,
setValue,
formState: { errors, isValid }, formState: { errors, isValid },
} = useForm<NamedImportMap>({ } = useForm<NamedImportMap>({
mode: 'onChange', mode: 'onChange',
@@ -46,6 +46,14 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const [loading, setLoading] = useState<'' | 'export' | 'import'>(''); const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
// Set first sheet as default worksheet when 'event schedule' sheet is not there
useEffect(() => {
if (!worksheetNames || worksheetNames.length === 0) return;
if (!worksheetNames.includes(namedImportMap.Worksheet)) {
setValue('Worksheet', worksheetNames[0], { shouldValidate: true, shouldDirty: true });
}
}, [worksheetNames, setValue, namedImportMap.Worksheet]);
const handleExport = async (values: NamedImportMap) => { const handleExport = async (values: NamedImportMap) => {
setLoading('export'); setLoading('export');
const importMap = convertToImportMap(values); const importMap = convertToImportMap(values);
@@ -1,5 +1,7 @@
import { ImportCustom, ImportMap } from 'ontime-utils'; import { ImportCustom, ImportMap } from 'ontime-utils';
import { baseURI } from '../../../../../externals';
export type NamedImportMap = typeof namedImportMap; export type NamedImportMap = typeof namedImportMap;
// Record of label and import name // Record of label and import name
@@ -64,11 +66,11 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
} }
export function persistImportMap(options: NamedImportMap) { export function persistImportMap(options: NamedImportMap) {
localStorage.setItem('ontime-import-options', JSON.stringify(options)); localStorage.setItem(`${baseURI}ontime-import-options`, JSON.stringify(options));
} }
function getPersistImportMap(): unknown { function getPersistImportMap(): unknown {
const options = localStorage.getItem('ontime-import-options'); const options = localStorage.getItem(`${baseURI}ontime-import-options`);
if (!options) { if (!options) {
throw new Error('no import options found'); throw new Error('no import options found');
} }
@@ -1,5 +1,5 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { IoLink } from '@react-icons/all-files/io5/IoLink'; import { IoLink } from 'react-icons/io5';
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types'; import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
@@ -1,10 +1,7 @@
import { useEffect, useRef } from 'react'; import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react'; import { Input } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn'; import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './InputRow.module.scss'; import style from './InputRow.module.scss';
@@ -13,50 +10,53 @@ interface InputRowProps {
placeholder: string; placeholder: string;
text: string; text: string;
visible: boolean; visible: boolean;
actionHandler: () => void;
changeHandler: (newValue: string) => void; changeHandler: (newValue: string) => void;
} }
export default function InputRow(props: InputRowProps) { export default function InputRow(props: PropsWithChildren<InputRowProps>) {
const { label, placeholder, text, visible, actionHandler, changeHandler } = props; const { label, placeholder, text, visible, changeHandler, children } = props;
const [value, setValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef(0); const cursorPositionRef = useRef(0);
// sync cursor position with text // sync cursor position with text
useEffect(() => { useEffect(() => {
if (inputRef.current) { if (inputRef.current && inputRef.current !== document.activeElement) {
inputRef.current.selectionStart = cursorPositionRef.current; inputRef.current.selectionStart = cursorPositionRef.current;
inputRef.current.selectionEnd = cursorPositionRef.current; inputRef.current.selectionEnd = cursorPositionRef.current;
} }
}, [text]); }, [text]);
// synchronise external text
useEffect(() => {
if (inputRef.current !== document.activeElement) {
setValue(text);
}
}, [text]);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
cursorPositionRef.current = event.target.selectionStart ?? 0; cursorPositionRef.current = event.target.selectionStart ?? 0;
setValue(event.target.value);
changeHandler(event.target.value); changeHandler(event.target.value);
}; };
return ( return (
<div className={style.inputRow}> <div className={style.inputRow}>
<label className={`${style.label} ${visible ? style.active : ''}`}>{label}</label> <label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
{label}
</label>
<div className={style.inputItems}> <div className={style.inputItems}>
<Input <Input
id={label}
ref={inputRef} ref={inputRef}
size='sm' size='sm'
variant='ontime-filled' variant='ontime-filled'
value={text} value={value}
onChange={handleInputChange} onChange={handleInputChange}
placeholder={placeholder} placeholder={placeholder}
/> />
<TooltipActionBtn {children}
clickHandler={actionHandler}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</div> </div>
</div> </div>
); );
@@ -1,4 +1,8 @@
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket'; import { setMessage, useExternalMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../ontimeConfig';
import InputRow from './InputRow'; import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl'; import TimerControlsPreview from './TimerViewControl';
@@ -23,8 +27,17 @@ function TimerMessageInput() {
text={text} text={text}
visible={visible} visible={visible}
changeHandler={(newValue) => setMessage.timerText(newValue)} changeHandler={(newValue) => setMessage.timerText(newValue)}
actionHandler={() => setMessage.timerVisible(!visible)} >
/> <TooltipActionBtn
clickHandler={() => setMessage.timerVisible(!visible)}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label='Toggle timer message visibility'
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</InputRow>
); );
} }
@@ -46,7 +59,16 @@ function ExternalInput() {
text={text} text={text}
visible={visible} visible={visible}
changeHandler={(newValue) => setMessage.externalText(newValue)} changeHandler={(newValue) => setMessage.externalText(newValue)}
actionHandler={toggleExternal} >
/> <TooltipActionBtn
clickHandler={toggleExternal}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label='Toggle external message visibility'
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
</InputRow>
); );
} }
@@ -1,9 +1,5 @@
import { IoArrowDown, IoArrowUp, IoBan, IoFlag, IoTime } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoBan } from '@react-icons/all-files/io5/IoBan';
import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { TimerPhase, TimerType } from 'ontime-types'; import { TimerPhase, TimerType } from 'ontime-types';
import { useMessagePreview } from '../../../common/hooks/useSocket'; import { useMessagePreview } from '../../../common/hooks/useSocket';
@@ -1,12 +1,12 @@
import { IoAdd, IoRemove } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { Playback } from 'ontime-types'; import { Playback } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils'; import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { setPlayback } from '../../../../common/hooks/useSocket'; import { setPlayback } from '../../../../common/hooks/useSocket';
import { baseURI } from '../../../../externals';
import { tooltipDelayMid } from '../../../../ontimeConfig'; import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton'; import TapButton from '../tap-button/TapButton';
@@ -18,7 +18,7 @@ interface AddTimeProps {
export default function AddTime(props: AddTimeProps) { export default function AddTime(props: AddTimeProps) {
const { playback } = props; const { playback } = props;
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes const [time, setTime] = useLocalStorage({ key: `${baseURI}add-time`, defaultValue: 300_000 }); // 5 minutes
const handleTimeChange = (_field: string, value: string) => { const handleTimeChange = (_field: string, value: string) => {
const newTime = parseUserTime(value); const newTime = parseUserTime(value);
@@ -1,8 +1,4 @@
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown'; import { IoArrowDown, IoArrowUp, IoPause, IoPlay, IoStop } from 'react-icons/io5';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback, SimpleDirection, SimplePlayback } from 'ontime-types'; import { Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
import { parseUserTime } from 'ontime-utils'; import { parseUserTime } from 'ontime-utils';
@@ -1,12 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { IoPause, IoPlay, IoPlaySkipBack, IoPlaySkipForward, IoReload, IoStop, IoTime } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { Playback, TimerPhase } from 'ontime-types'; import { Playback, TimerPhase } from 'ontime-types';
import { validatePlayback } from 'ontime-utils'; import { validatePlayback } from 'ontime-utils';
+1 -3
View File
@@ -1,9 +1,7 @@
import { lazy, useCallback, useEffect } from 'react'; import { lazy, useCallback, useEffect } from 'react';
import { IoApps, IoClose, IoSettingsOutline } from 'react-icons/io5';
import { IconButton, useDisclosure } from '@chakra-ui/react'; import { IconButton, useDisclosure } from '@chakra-ui/react';
import { useHotkeys } from '@mantine/hooks'; import { useHotkeys } from '@mantine/hooks';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { useElectronListener } from '../../common/hooks/useElectronEvent'; import { useElectronListener } from '../../common/hooks/useElectronEvent';
@@ -1,6 +1,6 @@
import type { HTMLAttributes, LabelHTMLAttributes } from 'react'; import type { HTMLAttributes, LabelHTMLAttributes } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; import { IconBaseProps } from 'react-icons';
import { type IconBaseProps } from '@react-icons/all-files/lib'; import { IoArrowUp } from 'react-icons/io5';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
@@ -116,7 +116,7 @@ export default function Operator() {
// subscriptions is a MultiSelect and may have multiple values // subscriptions is a MultiSelect and may have multiple values
const subscriptions = searchParams.getAll('subscribe').filter((value) => Object.hasOwn(customFields, value)); const subscriptions = searchParams.getAll('subscribe').filter((value) => Object.hasOwn(customFields, value));
const canEdit = shouldEdit && subscriptions; const canEdit = shouldEdit && subscriptions.length;
const main = searchParams.get('main') as keyof TitleFields | null; const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary'); const secondary = searchParams.get('secondary');
@@ -1,4 +1,4 @@
import { IoLocate } from '@react-icons/all-files/io5/IoLocate'; import { IoLocate } from 'react-icons/io5';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
@@ -2,6 +2,7 @@ import { CustomFields } from 'ontime-types';
import { import {
getTimeOption, getTimeOption,
makeCustomFieldSelectOptions,
makeOptionsFromCustomFields, makeOptionsFromCustomFields,
OptionTitle, OptionTitle,
} from '../../common/components/view-params-editor/constants'; } from '../../common/components/view-params-editor/constants';
@@ -9,13 +10,7 @@ import { ViewOption } from '../../common/components/view-params-editor/types';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => { export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' }); const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = makeCustomFieldSelectOptions(customFields);
const customFieldSelect = Object.entries(customFields).reduce<
Record<string, { value: string; label: string; colour: string }>
>((acc, [key, field]) => {
acc[key] = { value: key, label: field.label, colour: field.colour };
return acc;
}, {});
return [ return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] }, { title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -46,6 +41,13 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
type: 'multi-option', type: 'multi-option',
values: customFieldSelect, values: customFieldSelect,
}, },
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an highlighted custom field by long pressing',
type: 'boolean',
defaultValue: false,
},
], ],
}, },
{ {
@@ -59,13 +61,6 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
], ],
}, },
]; ];
@@ -45,6 +45,10 @@
@include ellipsis-overflow; @include ellipsis-overflow;
} }
.offset {
color: $muted-gray;
}
.ahead { .ahead {
color: $playback-ahead; color: $playback-ahead;
} }
@@ -1,4 +1,5 @@
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react'; import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
@@ -163,14 +164,14 @@ function ProgressOverview() {
} }
function RuntimeOverview() { function RuntimeOverview() {
const { clock, offset } = useRuntimePlaybackOverview(); const { clock, offset, playback } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset); const offsetText = getOffsetText(offset);
const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead; const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
return ( return (
<> <>
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} /> <TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />
<TimeColumn label='Time now' value={formatedTime(clock)} /> <TimeColumn label='Time now' value={formatedTime(clock)} />
</> </>
); );
@@ -10,13 +10,16 @@ interface TimeLayoutProps {
muted?: boolean; muted?: boolean;
daySpan?: number; daySpan?: number;
className?: string; className?: string;
testId?: string;
} }
export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) { export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) {
return ( return (
<div className={style.column}> <div className={style.column}>
<span className={style.label}>{label}</span> <span className={style.label}>{label}</span>
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span> <span className={cx([style.clock, muted && style.muted, className])} data-testid={testId}>
{value}
</span>
</div> </div>
); );
} }
+7 -5
View File
@@ -7,7 +7,6 @@ import {
isOntimeEvent, isOntimeEvent,
isPlayableEvent, isPlayableEvent,
MaybeString, MaybeString,
OntimeEvent,
PlayableEvent, PlayableEvent,
Playback, Playback,
RundownCached, RundownCached,
@@ -272,7 +271,7 @@ export default function Rundown({ data }: RundownProps) {
let isNextDay = false; let isNextDay = false;
let totalGap = 0; let totalGap = 0;
const isEditMode = appMode === AppMode.Edit; const isEditMode = appMode === AppMode.Edit;
let currentDay = 0; let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
return ( return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'> <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}> <DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
@@ -299,7 +298,11 @@ export default function Rundown({ data }: RundownProps) {
if (isPlayableEvent(entry)) { if (isPlayableEvent(entry)) {
isNextDay = checkIsNextDay(entry, lastEvent); isNextDay = checkIsNextDay(entry, lastEvent);
totalGap += !isPast ? entry.gap : 0; if (!isPast) {
totalGap += entry.gap;
// We also include countToEnd in this test as the behavior of a linked event coming after a countToEnd is simelar to an unlinked event
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null && !lastEvent?.countToEnd;
}
if (isNewLatest(entry, lastEvent)) { if (isNewLatest(entry, lastEvent)) {
// populate previous entry // populate previous entry
thisEvent = entry; thisEvent = entry;
@@ -313,7 +316,6 @@ export default function Rundown({ data }: RundownProps) {
const hasCursor = entry.id === cursor; const hasCursor = entry.id === cursor;
if (isLoaded) { if (isLoaded) {
isPast = false; isPast = false;
currentDay = (entry as OntimeEvent).dayOffset;
} }
return ( return (
@@ -336,7 +338,7 @@ export default function Rundown({ data }: RundownProps) {
isRolling={featureData.playback === Playback.Roll} isRolling={featureData.playback === Playback.Roll}
isNextDay={isNextDay} isNextDay={isNextDay}
totalGap={totalGap} totalGap={totalGap}
currentDay={currentDay} isLinkedToLoaded={isLinkedToLoaded}
/> />
</div> </div>
</div> </div>
@@ -1,5 +1,5 @@
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
@@ -39,7 +39,7 @@ interface RundownEntryProps {
playback?: Playback; // we only care about this if this event is playing playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event isRolling: boolean; // we need to know even if not related to this event
totalGap: number; totalGap: number;
currentDay: number; isLinkedToLoaded: boolean;
} }
export default function RundownEntry(props: RundownEntryProps) { export default function RundownEntry(props: RundownEntryProps) {
@@ -56,7 +56,7 @@ export default function RundownEntry(props: RundownEntryProps) {
eventIndex, eventIndex,
isNextDay, isNextDay,
totalGap, totalGap,
currentDay, isLinkedToLoaded,
} = props; } = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
@@ -178,8 +178,9 @@ export default function RundownEntry(props: RundownEntryProps) {
isRolling={isRolling} isRolling={isRolling}
gap={data.gap} gap={data.gap}
isNextDay={isNextDay} isNextDay={isNextDay}
dayOffset={data.dayOffset - currentDay} dayOffset={data.dayOffset}
totalGap={totalGap} totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
actionHandler={actionHandler} actionHandler={actionHandler}
/> />
); );
@@ -1,7 +1,7 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { IoReorderTwo } from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { OntimeBlock } from 'ontime-types'; import { OntimeBlock } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';

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