Compare commits

...

305 Commits

Author SHA1 Message Date
Carlos Valente 197042e630 bump version to 4.4.0-beta.0 2026-01-26 12:35:32 +01:00
Carlos Valente d1780aa98c refactor: align inputs with group, refs #1857 2026-01-26 11:35:09 +01:00
Carlos Valente 87b1721b9a feat: editor layout 2026-01-26 11:35:09 +01:00
Carlos Valente 9c08f0dcb1 feat: extend rundown shortcuts 2026-01-26 11:09:38 +01:00
Carlos Valente 0ac98f0f02 feat: unify rundown features 2026-01-25 13:54:49 +01:00
Carlos Valente 261cd25bec refactor: table mode and context 2026-01-25 13:54:49 +01:00
Carlos Valente 8cfbcd35cd fix: tsconfig references for utility tests 2026-01-25 13:54:49 +01:00
Carlos Valente c4435259c5 refactor: create utility for enums 2026-01-25 13:54:49 +01:00
Carlos Valente 84a7b1c1f7 feat: create reusable scroll area 2026-01-25 13:54:49 +01:00
Carlos Valente db9444aaa4 refactor: expose actions through context 2026-01-25 13:54:49 +01:00
Carlos Valente fc007cf124 refactor: improve context menu performance
- extract menu from rundown tree
- lazy create actions
2026-01-25 13:54:49 +01:00
Carlos Valente e95685d366 refactor: improve performance in rundown
- leverage compiler
- correct subscriptions from zustand
2026-01-25 13:54:49 +01:00
Carlos Valente 5f634bb48a feat: table mode in editor 2026-01-25 13:54:49 +01:00
Carlos Valente 2af13b340b fix: automation form validation 2026-01-15 15:38:30 +01:00
Carlos Valente 525999440a fix: inline elements should not wrap by default 2026-01-14 17:12:03 +01:00
Carlos Valente 6e5805977a refactor: hide logo on error 2026-01-11 17:43:53 +01:00
Carlos Valente eb897a2a23 refactor: set background colour from content 2026-01-11 17:43:53 +01:00
Carlos Valente 05d997a52d bump version to 4.3.1 2026-01-11 17:43:23 +01:00
Carlos Valente f8cde5d627 fix: restore process sets currentDay 2026-01-10 19:23:49 +01:00
Alex Christoffer Rasmussen 1b55254834 fix: Network Interface overflow (#1935) 2026-01-05 21:05:45 +01:00
Alex Christoffer Rasmussen 6436e3fda8 refactor: extract rundown switching to a single function in rundown.service (#1930) 2026-01-05 20:47:04 +01:00
Alex Christoffer Rasmussen 680273770c fix: crash-log not written on windows and add log entries to crash report
* fix: crashlog not written on windows

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

* chore: configure opt-in compiler

* refactor(rundown): stabilise frequently accessed data

* feat(clone): allow cloning any element

* remove paste above and cue increment from test

---------

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

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

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

* fix: custom fields can be uppercase

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

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

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

* use base url directly

* lint
2025-10-27 11:19:21 +01:00
Carlos Valente 37abd3fe47 chore(ci): prevent publishing resolver on release 2025-10-22 06:26:35 +02:00
Carlos Valente b509ee5b1f bump to version 4.0.2 2025-10-22 06:26:35 +02:00
Shobhit-Nagpal dd2b59efe5 fix: move blue line to pseudo element 2025-10-21 16:29:59 +02:00
Shobhit-Nagpal 5788785b3e fix: minor ui shift on quick add inline 2025-10-21 16:29:59 +02:00
Carlos Valente e488ad19b3 fix: ensure well formed URLs for IP address destinations 2025-10-18 15:29:16 +02:00
Carlos Valente cbc03c898c fix: correct alignment to end message 2025-10-18 15:28:47 +02:00
jwetzell 155bfbdc42 Merge pull request #1833 from cpvalente/fix/npm-action-access-error 2025-10-18 07:13:05 -05:00
jwetzell 5d3714a4fc Merge pull request #1836 from cpvalente/fix/use-flat-rundown 2025-10-18 07:12:39 -05:00
jwetzell a8a1d210d8 use flat order when building flat rundown 2025-10-17 21:46:13 -05:00
jwetzell 49de8b63a7 add setup-node step back to resolve npm access issues 2025-10-17 08:13:11 -05:00
Carlos Valente 03c9f51b47 chore: update demo project 2025-10-17 12:20:18 +02:00
Carlos Valente 8d00fd3966 bump version to 4.0.1 2025-10-17 12:20:18 +02:00
Carlos Valente 80fd21354e fix: apply styling to overtime timer 2025-10-16 21:13:26 +02:00
Carlos Valente a87b15825a fix: end message scaling 2025-10-16 21:13:26 +02:00
Carlos Valente 6a8f98c469 fix: end message only shows if timer is in overtime 2025-10-16 21:13:26 +02:00
jwetzell 8685298430 Merge pull request #1830 from cpvalente/fix/linux-project-download 2025-10-15 07:25:57 -05:00
jwetzell d43af2db57 pass options to project download to allow folders beginning with a dot 2025-10-14 19:51:15 -05:00
jwetzell ef718c48e4 Merge pull request #1828 from cpvalente/npm-trusted-publisher
update cli and resolver workflows for new NPM auth
2025-10-14 16:18:53 -05:00
Alex Christoffer Rasmussen 45e7373279 Small clean-up (#1827)
* chore: avoid shadow variable name

* chore: map could just be foreach

* clean uneeded template literal

* lint
2025-10-13 20:24:25 +02:00
jwetzell 6af2d6e021 update cli and resolver workflows for new NPM auth 2025-10-12 14:11:52 -05:00
Alex Christoffer Rasmussen 9b36f36f73 fix: next cue (#1821)
* fix next cue

* Update apps/server/src/services/runtime-service/runtime.service.ts

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-10-12 14:42:55 +02:00
Carlos Valente 3a0dbf057e chore: update docs 2025-10-12 07:48:25 +02:00
Carlos Valente dae84ab48a chore: update screenshots 2025-10-12 07:48:25 +02:00
Carlos Valente aa927597f3 fix: product name ontime 2025-10-10 19:03:53 +02:00
Carlos Valente ef7f0fb54a refactor: bump modal sizes 2025-10-10 18:32:29 +02:00
Carlos Valente 324320fdc8 fix: custom field is multiline 2025-10-10 18:32:29 +02:00
Carlos Valente 4b27d080b6 fix: background colour in not found page 2025-10-10 18:13:58 +02:00
Carlos Valente 7918063753 bump version to 4.0.0 2025-10-10 18:13:58 +02:00
arc-alex e2202c2417 fix: dynamic full hight for mobile view param editor 2025-10-10 18:07:39 +02:00
Carlos Valente 9817c8f8c0 fix: selected styling overrides 2025-10-10 17:41:26 +02:00
Carlos Valente 1d8c04d0b4 feat: export excel file
refactor: align id columns between export and import

refactor: match column names with template
2025-10-10 17:41:26 +02:00
Carlos Valente 10762f10bf docs: caveat on proxy not working before project build 2025-10-10 17:41:26 +02:00
Alex Christoffer Rasmussen da7c0d4ff2 Small clean-up (#1815)
* chore: remove old comment

* chore: smal lclean up of a e2e test

* chore: bump to beta.6

* chore: cleanup imports

* fix integration values com in ms

* allow linkStart and timeStrategy from API

* remove local mac dist
2025-10-10 17:37:55 +02:00
Carlos Valente 4a27973de1 refactor: allow locking op settings and navigation 2025-10-10 16:01:41 +02:00
Carlos Valente bdeb087e28 fix: op gets params from preset context 2025-10-10 16:01:41 +02:00
Carlos Valente 3662cd58f5 refactor: extract operator data loading 2025-10-10 16:01:41 +02:00
Carlos Valente 62c52bca07 refactor: larger secondary field in timer 2025-10-09 21:03:22 +02:00
Carlos Valente 1fc4a06133 fix: allow user to reset schedule filter 2025-10-09 21:03:22 +02:00
Alex Christoffer Rasmussen 147862e647 Fix opview with groups (#1814)
* create failing test

* fix: op evet used past state of the group
2025-10-08 21:19:57 +02:00
Alex Christoffer Rasmussen 12bc323e57 Fix build (#1813)
* fix resolver build

* fix docker build

* node version in docker via arg

* add step to setup auth for pnpm

* lint

---------

Co-authored-by: Joel Wetzell <jwetzell@yahoo.com>
2025-10-08 10:44:53 +02:00
Alex Christoffer Rasmussen de8f9ef587 Merge pull request #1535 from cpvalente/v4
v4
2025-10-07 12:51:09 +02:00
arc-alex 217402050d fix merge issue 2025-10-07 12:44:06 +02:00
Alex Christoffer Rasmussen de75d55849 Merge branch 'master' into v4 2025-10-07 12:39:06 +02:00
Alex Christoffer Rasmussen c4e6215b29 Update test time until (#1809)
* test views

test timeline

* timeline show seconds when less than 2m

* fix: countown typings

* refactor: backstage schedule item

* chore: lint
2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen 57315595a5 All views follow offset mode (#1808)
* chore: remove unused

* fix: studio view follow offset mode
2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen fc7ed6bd20 clean turbo config and cache github actions (#1805)
* refactor: turbo scripts

refactor dev and build scripts

remove cleanup

* fix e2e script

* cache playwright

* cache pnpm

* fix docker env in vite

* fixup! refactor: turbo scripts

* chore: add comment
2025-10-07 12:32:12 +02:00
jwetzell 750927dc8d chore: switch workspaces to use pnpm to setup node with useNodeVersion value 2025-10-07 12:32:12 +02:00
Carlos Valente 00d2ae0a7c refactor: clarify settings section name 2025-10-07 12:32:12 +02:00
Carlos Valente 56c4d68bae refactor: use coffee icon for keep awake feature 2025-10-07 12:32:12 +02:00
Carlos Valente a02cdf71c0 fix: maintain offset on roll takeover 2025-10-07 12:32:12 +02:00
Carlos Valente 25030debe4 refactor: error page sends email 2025-10-07 12:32:12 +02:00
Carlos Valente 27243c4432 chore: upgrade electron packages 2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen 22559c59d4 Expected time for views (#1798)
* refactor: use correct metadata in op view

* refactor: use correct metadata in backstage view

* remove uneeded test

* fix: correct offset colour

* refactor: use correct metadata in timline view

* refactor: schedule item dont pass the whole event

* chore: lint
2025-10-07 12:32:12 +02:00
Carlos Valente 700948bfbb fix: use correct order when creating groups 2025-10-07 12:32:12 +02:00
Carlos Valente 960a39fbf4 refactor: cleanup dragging cursor indicator 2025-10-07 12:32:12 +02:00
Carlos Valente d986f438d9 refactor: improve delay component 2025-10-07 12:32:12 +02:00
Carlos Valente c2012bd9e3 refactor: improve studio clock responsiveness 2025-10-07 12:32:12 +02:00
Carlos Valente 1f0f92371f fix: show secondary timer only if active 2025-10-07 12:32:12 +02:00
Carlos Valente 4b81725e80 chore: upgrade base-ui 2025-10-07 12:32:12 +02:00
Carlos Valente 846eaf569d refactor: remove project CSV export option 2025-10-07 12:32:12 +02:00
Carlos Valente ccf1ddf2fd fix: conflict between swatch picker and swatch elements 2025-10-07 12:32:12 +02:00
Carlos Valente 81e00993f2 fix: prevent rolling into events of 0 duration 2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen e22c7698c3 fix: alpha bg (#1799) 2025-10-07 12:32:12 +02:00
arc-alex ef053fc2f7 bump to v4.0.0-beta.5 2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen 3b6d3a4631 feat: Keep screen awake (#1788)
* feat keepAwake hook

* reaqure lock after view change

* refactor: move it to a navigation view setting

* save to search params
2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen 31c3e603ed refactor: better type for api aux action (#1796) 2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen 1dfe5c980d refactor: collect common tsconfig (#1794)
* refactor: collect common tsconfig settings

* chore: module and target should be lower case
2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen 80e4dc0941 Fix: rundown import (#1795)
* fix: patch in the active rundown id and title

* fix: send a ALL refetch on project patch

* fix: load the last used rundown on project patch and not just the first one

* chore: lint

* chore: clean up and let rundown service send the refetch request

* chore: dont destructure rundown
2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen bc0b83aa14 remove private field from package (#1793) 2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen 971c6c66f0 Chore: Small todo about coerce boolean (#1789)
* fix: coerce boolean types negative number is false

* strict coerce
2025-09-26 21:34:01 +02:00
Carlos Valente 343697dc93 refactor: remove unused dependencies 2025-09-26 21:34:01 +02:00
Carlos Valente 4058231d6b refactor: remove unused code 2025-09-26 21:34:01 +02:00
Carlos Valente c5ff254267 refactor: prevent upload logo errors from being stuck 2025-09-26 21:34:01 +02:00
Carlos Valente ac14cb9624 refactor: increase file size limits 2025-09-26 21:34:01 +02:00
Carlos Valente bcabc50ad1 fix: check automation usage in all rundowns before allowing delete 2025-09-26 21:34:01 +02:00
Carlos Valente cbd7bce106 bump version to 4.0.0-beta.4 2025-09-26 21:34:01 +02:00
Carlos Valente 00aa8a09fa refactor(roll breaking): make starting with roll backtrace planned times 2025-09-21 07:42:47 +02:00
Carlos Valente d04f958c11 fix: initialise group time 2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen 5716fc31cf feat: export package to help api consumers (#1624) 2025-09-21 07:42:47 +02:00
Carlos Valente a5d628af0c fix: prevent params drawer from being larger than screen 2025-09-21 07:42:47 +02:00
Carlos Valente 111a1f95ba fix: correct offset direction in expected time calculations 2025-09-21 07:42:47 +02:00
Carlos Valente 4bf74d86b3 refactor: set start metadata when starting from roll 2025-09-21 07:42:47 +02:00
arc-alex d2638d64e4 fix: group end elements should only send timertype data nothing else 2025-09-21 07:42:47 +02:00
arc-alex 02cddeec4d refactor: add a try grourd to the cell data generator to better pinpoint errors 2025-09-21 07:42:47 +02:00
Carlos Valente a8fe8b5f1e refactor: allow filtering schedule 2025-09-21 07:42:47 +02:00
Carlos Valente 8d2db7ffcd Relative mode (#1764)
* feat: add group start time to state

* refactor: calculate expected times when offset mode changes

* feat: show relative data in ui

* fixup! feat: add group start time to state

* fixup! refactor: calculate expected times when offset mode changes

* fixup! feat: show relative data in ui

* try to handle multiday

* show planed/expected rundown end day offset values

* refactor: style tweaks to timers

* refactor: cleanup data use

* day offset

* update tests

* test getExpectedStart multiday

* write start epoch to restore file

* current day in relative mode

* remove todo

* move find day offset to utils

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen 313590905f fix docker ignore (#1780) 2025-09-21 07:42:47 +02:00
Carlos Valente 7bbdab7dd9 fix(cuesheet): infinite render loop on resizing columns 2025-09-21 07:42:47 +02:00
Carlos Valente 304832ecde fix: maintain multiline in cuesheet cells 2025-09-21 07:42:47 +02:00
Carlos Valente 0ab4abfcfe docs: add contribution guidelines 2025-09-21 07:42:47 +02:00
Carlos Valente 1847822f31 fix: skip nested events 2025-09-21 07:42:47 +02:00
Carlos Valente 4499495212 feat: allow finding milestones 2025-09-21 07:42:47 +02:00
Carlos Valente 0035070db8 refactor: improve automated following 2025-09-21 07:42:47 +02:00
Carlos Valente 890a97d071 fix: allow moving a group after another 2025-09-21 07:42:47 +02:00
Carlos Valente 5441e5b07f refactor: improve pin styling 2025-09-21 07:42:47 +02:00
Carlos Valente ab4b94f2d8 fix: prevent layout reflow on different entry types 2025-09-21 07:42:47 +02:00
Carlos Valente fd7ccb4a5b refactor: add default value to secondary source 2025-09-21 07:42:47 +02:00
Carlos Valente 2e6bf234d5 fix: phase style overrides in timer 2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen c934b0ecdd cleanup (#1776)
* chore: cleanup leftover console log

* chore: prevent error in client tsconfig
2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen cd6d6f24eb fix: sheet default import map (#1775)
* fix: default import map

* fix: update test
2025-09-21 07:42:47 +02:00
arc-alex e632a08557 chore: bump version 2025-09-21 07:42:47 +02:00
Carlos Valente 0057b4a3f1 refactor: tweaks to countdown styles 2025-09-08 06:23:39 +02:00
Carlos Valente ac8c1e333e bump version to 4.0.0-beta.2 2025-09-08 06:09:31 +02:00
Carlos Valente 321297b685 fix: redirect to preset 2025-09-08 06:09:00 +02:00
Alex Christoffer Rasmussen 67e67d6a5d feat: propper timeuntil calc in countdown view (#1765)
* fix error with is linked to loaded metadata

* pass down metadata

* create expected schedule

* expected start in sub info

* for singel subscribtions

* refactor

* add color to single events

* refactor: clean up subscribtion data

* show schedule and secondray data for single events

* improve subscribtion data display
2025-09-07 21:49:45 +02:00
Carlos Valente 4385e0b9c9 refactor: ensure clocks timer and clock are in sync (#1772)
* refactor: ensure clocks timer and clock are in sync

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-09-07 21:49:45 +02:00
Carlos Valente 02243242f1 fix: redirect to preset 2025-09-07 21:49:45 +02:00
arc-alex 1af42b766d fix: insert group-end where needed 2025-09-07 21:49:45 +02:00
arc-alex 9d6ec3758b chore: clarifying comments 2025-09-07 21:49:45 +02:00
Carlos Valente 9e78ff4d1c refactor: export data for all entry types 2025-09-07 21:49:45 +02:00
Carlos Valente 2037aef080 fix: prevent clipping on bold font sizes 2025-09-07 21:49:45 +02:00
Carlos Valente 48b9cf042f refactor: lax validation for settings
with this changes we allow the client and server to share the same data
shape. even if some of it is useless.
we also get an extra version property in the payload, if we want to save
the transfer cost, we would rather remove it from the sent payload
2025-09-07 21:49:45 +02:00
Carlos Valente 1866e5e78d refactor: simplify restore point 2025-09-07 21:49:45 +02:00
Carlos Valente bc1b3800c8 refactor: op clock uses time format string 2025-09-07 21:49:45 +02:00
Carlos Valente a2505c838b refactor: add none option to optional select 2025-09-07 21:49:45 +02:00
Carlos Valente bffa863fb4 fix: multi select infinite loop 2025-09-07 21:49:45 +02:00
Carlos Valente d00d7677bc fix: merge virtuoso and rundown styles 2025-09-07 21:49:45 +02:00
Carlos Valente 3ae73be562 refactor: prevent emitting errors from user pages 2025-09-07 21:49:45 +02:00
Carlos Valente 763080cd4d fix: generate link for companion 2025-09-07 21:49:45 +02:00
Carlos Valente 6ff06b21a8 fix: hide fade out elements by default 2025-09-07 21:49:45 +02:00
Carlos Valente adebde1b43 chore: upgrade client packages 2025-09-07 21:49:45 +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
493 changed files with 12867 additions and 5396 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
# Ignore build folders
node_modules
dist
**/node_modules
# Ignore default volumes created by running docker compose up
ontime-db
+2 -1
View File
@@ -1 +1,2 @@
"ONTIME_VERSION.js"
"ONTIME_VERSION.js"
dist/
+1 -1
View File
@@ -8,7 +8,7 @@
"jest": true
},
"parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"plugins": ["@typescript-eslint", "prettier"],
"overrides": [
{
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 149 KiB

+20 -12
View File
@@ -14,19 +14,24 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Use Node.js
uses: actions/setup-node@v4
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -38,16 +43,19 @@ jobs:
- name: Copy server
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
- name: Copy client
run: cp -R apps/client/build apps/cli/client
- name: Copy external
run: cp -R apps/server/src/external apps/cli/external
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
- name: Install newer version of npm
run: npm install -g npm@11
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/cli
+49 -29
View File
@@ -6,43 +6,63 @@ on:
workflow_dispatch:
jobs:
publish_docker:
runs-on: ubuntu-latest
env:
CI: ''
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
@@ -2,7 +2,7 @@ name: Ontime build
on:
push:
tags: [ "*" ]
tags: ['*']
workflow_dispatch:
jobs:
@@ -11,15 +11,20 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -30,7 +35,7 @@ jobs:
run: pnpm build
- name: Electron - Build app
env:
env:
APPLE_ID: ${{ secrets.APPLEID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
@@ -53,15 +58,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -86,15 +85,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -110,6 +103,9 @@ jobs:
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-linux.AppImage'
files: |
./apps/electron/dist/ontime-linux-x86_64.AppImage
./apps/electron/dist/ontime-linux-arm64.AppImage
./apps/electron/dist/ontime-linux-armv7l.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+48
View File
@@ -0,0 +1,48 @@
name: Ontime Resolver build
on:
workflow_dispatch:
jobs:
build_resolver:
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
run: pnpm build:resolver
# This will be included in v24 of NodeJS so when the project upgrades to that this can be removed
- name: Install newer version of npm
run: npm install -g npm@11
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/resolver
+41 -46
View File
@@ -14,55 +14,35 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Run code quality per package
- name: React - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Server - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
if: always()
# Run code quality
- name: Run linter
run: pnpm lint
working-directory: ./packages/types
- name: Run TypeScript checks
run: pnpm typecheck
# We choose to run tests separately
- name: React - Run unit tests
- name: Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/client
- name: Server - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/server
- name: Utils - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./packages/utils
run: pnpm test
e2e-test:
runs-on: ubuntu-latest
@@ -70,21 +50,36 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build client
run: pnpm build:local
- name: Get installed Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
- name: Cache playwright binaries
uses: actions/cache@v4
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: |
${{ runner.os }}-playwright-
- run: npx playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright Browsers
run: npx playwright install --with-deps
+1
View File
@@ -40,6 +40,7 @@ override.css
# working stuff
**/TODO.md
**.local.**
# docker utils
ontime-db
+6 -2
View File
@@ -5,6 +5,10 @@ node_modules
playwright-report
pnpm-lock.yaml
**/*.toml
**/*.yml
**/*.json
**/*.json
!tsconfig.common.json
!turbo.json
!package.json
+22 -11
View File
@@ -21,7 +21,8 @@ Locally, we would need to run both the React client and the node.js server in de
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `pnpm turbo dev`
- __Create a local build__ by running `pnpm build`, this will populate local dependencies
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
### Debugging backend
@@ -30,10 +31,10 @@ The previous command will start the development servers for both the client, ser
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
We do that by creating two terminals an running
- __Run the React UI__ by running `pnpm turbo dev --filter=ontime-ui`
- __Run the nodejs server__ by running `pnpm turbo dev --filter=ontime-server`
- __Run the React UI__ by running `pnpm dev --filter=ontime-ui`
- __Run the nodejs server__ by running `pnpm dev --filter=ontime-server`
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm turbo dev:inspect --filter=ontime-server`.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect --filter=ontime-server`.
## TESTING
@@ -46,7 +47,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running `pnpm turbo test:pipeline` from the project root.
You can run unit tests by running `pnpm test:pipeline` from the project root.
This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
@@ -66,7 +67,7 @@ start the webserver with `pnpm dev:server`
Some other useful commands
- `pnpm e2e --ui` open playwright UI
- `pnpm e2e:ui` open playwright UI
- `pnpm e2e --headed` run tests with a visible browser window
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
@@ -77,14 +78,11 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `pnpm turbo run build:electron`
- __Create the package__ by running `pnpm turbo run dist-win`, `pnpm turbo run dist-mac` or `pnpm turbo run dist-linux`
- __Build the UI and server__ by running `pnpm build`
- __Create the package__ by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm dist-linux`
The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `pnpm turbo run dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
## DOCKER
Ontime provides a docker-compose file to aid with building and running docker images.
@@ -99,3 +97,16 @@ Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
## CONTRIBUTION GUIDELINES
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
For new PRs, please follow the following checklist:
* [ ] You have updated and ran unit locally and they are passing. Unit tests are generally created for all utility functions and business logic
* [ ] You have ran code formatting and linting in all your changes
* [ ] The branch is clean and the commits are meaningfully separated and contain descriptive messages
* [ ] The PR body contains description and motivation for the changes
After this checklist is complete, you can request a review from one of the maintainers to get feedback and approval on the changes. \
We will review as soon as possible
+30 -37
View File
@@ -1,37 +1,30 @@
FROM node:22-bullseye AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0
COPY . /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 run build:docker
FROM node:22-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/data/
WORKDIR /app/
# Prepare UI
COPY --from=builder /app/apps/client/build ./client/
# Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"]
# Build and run commands
# !!! Note that this command needs pre-build versions of the UI and server apps
# docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
ARG NODE_VERSION=22.15.1
FROM node:${NODE_VERSION}-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/data/
RUN mkdir /app
WORKDIR /app/
# Prepare UI
COPY apps/client/build/ ./client/
# Prepare Backend
COPY apps/server/dist/ ./server/
COPY apps/server/src/external/ ./external/
COPY apps/server/src/user/ ./user/
COPY apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"]
# Build and run commands
# pnpm build:docker
# docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
-2
View File
@@ -26,8 +26,6 @@ We do our best to have most topics covered by the documentation. However, if you
Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
# Ontime
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.0.0-alpha.6",
"version": "4.4.0-beta.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
-6
View File
@@ -13,12 +13,6 @@
<link rel="manifest" href="/manifest.json" />
<meta name="robots" content="noindex" />
<title>ontime</title>
<style>
body,
html {
background-color: #101010 !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
+18 -21
View File
@@ -1,51 +1,47 @@
{
"name": "ontime-ui",
"version": "4.0.0-alpha.6",
"version": "4.4.0-beta.0",
"private": true,
"type": "module",
"dependencies": {
"@base-ui-components/react": "1.0.0-beta.2",
"@base-ui/react": "1.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^8.2.4",
"@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.84.1",
"@tanstack/react-query-devtools": "^5.84.1",
"@tanstack/react-query": "^5.85.9",
"@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.11.0",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"axios": "^1.12.2",
"csv-stringify": "^6.6.0",
"prismjs": "^1.30.0",
"react": "^19.1.1",
"react": "^19.2.3",
"react-colorful": "^5.6.1",
"react-dom": "^19.1.1",
"react-dom": "^19.2.3",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.62.0",
"react-icons": "5.5.0",
"react-qr-code": "^2.0.18",
"react-router": "^7.8.0",
"react-router": "^7.11.0",
"react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.14.0",
"react-virtuoso": "^4.17.0",
"web-vitals": "^5.1.0",
"zustand": "^5.0.7"
"zustand": "^5.0.9"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
"postinstall": "pnpm addversion",
"dev": "cross-env BROWSER=none vite",
"build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"dev:electron": "pnpm dev",
"lint": "eslint . --quiet",
"typecheck": "tsc --noEmit",
"build": "vite build",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
"analyse": "npx vite-bundle-visualizer"
},
"browserslist": {
@@ -64,11 +60,12 @@
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0",
+3 -1
View File
@@ -1,5 +1,5 @@
import { BrowserRouter } from 'react-router';
import { Tooltip } from '@base-ui-components/react/tooltip';
import { Tooltip } from '@base-ui/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -8,6 +8,7 @@ import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverla
import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient';
import { connectSocket } from './common/utils/socket';
import KeepAwake from './features/keep-awake/KeepAwake';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
@@ -24,6 +25,7 @@ function App() {
<ErrorBoundary>
<TranslationProvider>
<IdentifyOverlay />
<KeepAwake />
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
+4 -3
View File
@@ -102,6 +102,7 @@ export default function AppRouter() {
path='op'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator />
</ViewLoader>
}
@@ -207,13 +208,13 @@ function PresetView() {
/**
* Locked presets do not allow configuration changes
* Whether the user can navigate is determined by the locked param
*
* We inject the preset to the context value for the view to consume
*/
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return (
<PresetContext value={preset}>
{preset.target !== OntimeView.Cuesheet && (
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
)}
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
{Component ? <Component /> : <NotFound />}
</PresetContext>
);
-24
View File
@@ -1,9 +1,6 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -33,27 +30,6 @@ export async function downloadProject(fileName: string) {
}
}
/**
* Request download of the current rundown as a CSV file
* @param fileName
*/
export async function downloadCSV(fileName: string = 'rundown') {
try {
const { data, name } = await fileDownload(fileName);
const { project, rundowns, customFields } = data;
const flatRundowns = aggregateRundowns(rundowns);
const sheetData = makeTable(project, flatRundowns, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
} catch (error) {
console.error(error);
}
}
/**
* HTTP request to upload project file
*/
+20 -11
View File
@@ -1,8 +1,9 @@
import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown } from 'ontime-types';
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`;
@@ -10,28 +11,21 @@ const excelPath = `${apiEntryUrl}/excel`;
* upload Excel file to server
* @return string - file ID op the uploaded file
*/
export async function upload(file: File) {
export async function upload(file: File): Promise<string[]> {
const formData = new FormData();
formData.append('excel', file);
await axios.post(`${excelPath}/upload`, formData, {
const response = await axios.post(`${excelPath}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/**
* Get Worksheet names
* @return string[] - array of available worksheets
*/
export async function getWorksheetNames(): Promise<string[]> {
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
return response.data;
}
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
@@ -39,3 +33,18 @@ export async function importRundownPreview(options: ImportMap): Promise<PreviewS
});
return response.data;
}
/**
* Downloads a xlsx representation of the rundown from the server
*/
export async function downloadAsExcel(rundownId: string, fileName?: string) {
try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
responseType: 'blob',
});
downloadBlob(response.data, `${fileName ?? 'Ontime_rundown'}.xlsx`);
} catch (error) {
console.error('Error downloading file:', error);
}
}
+58 -16
View File
@@ -3,6 +3,7 @@ import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, Transi
import { apiEntryUrl } from './constants';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns =========================
@@ -20,14 +21,30 @@ export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
*/
export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
return res.data;
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
}
/**
* HTTP request to switch the currently loaded rundown
*/
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${id}/load`);
export async function loadRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/load`);
}
/**
@@ -37,11 +54,25 @@ export async function createRundown(title: string): Promise<AxiosResponse<Projec
return axios.post(rundownPath, { title });
}
/**
* HTTP request to duplicate an existing rundown
*/
export async function duplicateRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${rundownId}/duplicate`);
}
/**
* HTTP request to rename an existing rundown
*/
export async function renameRundown(rundownId: RundownId, title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.patch(`${rundownPath}/${rundownId}`, { title });
}
/**
* HTTP request to delete a rundown
*/
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${id}`);
export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${rundownId}`);
}
// #endregion operations on project rundowns ======================
@@ -51,7 +82,7 @@ export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRu
* HTTP request to post new entry
*/
export async function postAddEntry(
rundownId: string,
rundownId: RundownId,
data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
@@ -60,7 +91,10 @@ export async function postAddEntry(
/**
* HTTP request to edit an entry
*/
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
export async function putEditEntry(
rundownId: RundownId,
data: Partial<OntimeEntry>,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
}
@@ -72,7 +106,7 @@ export type BatchEditEntry = {
/**
* HTTP request to edit multiple events
*/
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
}
@@ -85,56 +119,64 @@ export type ReorderEntry = {
/**
* HTTP request to reorder an entry
*/
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
}
/**
* HTTP request to swap two events
*/
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
export async function requestEventSwap(
rundownId: RundownId,
from: EntryId,
to: EntryId,
): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
}
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
}
/**
* HTTP request for cloning an entry
*/
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
export async function postCloneEntry(
rundownId: RundownId,
entryId: EntryId,
options?: { before?: EntryId; after?: EntryId },
): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
}
/**
* HTTP request for grouping a list of entries into a group
*/
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
export async function requestGroupEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
}
/**
* HTTP request for dissolving of a group
*/
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
export async function requestUngroup(rundownId: RundownId, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
}
/**
* HTTP request to delete entries of a given rundown
*/
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
export async function deleteEntries(rundownId: RundownId, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
}
/**
* HTTP request to delete all entries of a given rundown
*/
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> {
export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/all`);
}
+2 -1
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
@@ -56,6 +56,7 @@ export const previewRundown = async (
): Promise<{
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
}> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
return response.data;
@@ -1,140 +0,0 @@
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
@@ -1,5 +1,6 @@
@use '@/theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
@use './buttonVariants' as base;
@include base.button-variants;
.baseButton {
position: relative;
@@ -81,4 +82,3 @@
.fluid {
width: 100%;
}
@@ -1,5 +1,6 @@
@use '@/theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
@use './buttonVariants' as base;
@include base.button-variants;
.baseIconButton {
aspect-ratio: 1;
@@ -0,0 +1,144 @@
@use '@/theme/themeTokens' as *;
@use '@/theme/viewerDefs' as *;
@mixin button-variants {
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
}
@@ -1,5 +1,5 @@
import { IoCheckmark } from 'react-icons/io5';
import { Checkbox as BaseCheckbox } from '@base-ui-components/react/checkbox';
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
import style from './Checkbox.module.scss';
@@ -5,10 +5,10 @@ import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog';
import Info from '../info/Info';
import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink';
import Modal from '../modal/Modal';
import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
@@ -33,7 +33,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
if (newPath === '/' || newPath === currentPath) {
return;
}
setRedirect({ target: id, redirect: newPath });
if (newPath.startsWith('preset-')) {
setRedirect({ target: id, redirect: newPath.slice(7) });
} else {
setRedirect({ target: id, redirect: newPath });
}
onClose();
};
@@ -45,13 +51,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
label: view.label,
})),
...enabledPresets.map((preset) => ({
value: preset.search,
value: `preset-${preset.alias}`,
label: `URL Preset: ${preset.alias}`,
})),
];
return (
<Modal
<Dialog
isOpen={isOpen}
onClose={onClose}
showCloseButton
@@ -61,32 +67,11 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<>
<Info>
Remotely redirect the client to a different URL. <br />
Either by selecting a URL Preset or entering a custom path.
Either by entering a custom path or selecting a URL Preset.
<br />
<br />
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.textEntry}>
<Select
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)}
disabled={enabledPresets.length === 0}
/>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
</Button>
</div>
</div>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
@@ -100,9 +85,37 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
className={style.redirect}
onClick={() => handleRedirect(path)}
>
Redirect <IoArrowForward />
Redirect
<IoArrowForward />
</Button>
</div>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.inlineEntry}>
<label className={style.textEntry}>
{origin}
<Select
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
disabled={enabledPresets.length === 0}
/>
</label>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
</Button>
</div>
</div>
</>
}
/>
@@ -1,8 +1,7 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { IoCheckmark, IoCopy } from 'react-icons/io5';
import copyToClipboard from '../../utils/copyToClipboard';
import { copyToClipboard } from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
@@ -26,15 +25,19 @@ export default function CopyTag({
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = () => {
copyToClipboard(copyValue);
setCopied(true);
const handleClick = async () => {
try {
await copyToClipboard(copyValue);
setCopied(true);
// reset copied state
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
// reset copied state
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// ignore errors
}
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
};
return (
@@ -6,7 +6,7 @@
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(420px, 90vw);
min-width: min(600px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import IconButton from '../buttons/IconButton';
@@ -12,7 +12,7 @@ interface DialogProps {
showCloseButton?: boolean;
showBackdrop?: boolean;
bodyElements: ReactNode;
footerElements: ReactNode;
footerElements?: ReactNode;
onClose: () => void;
}
@@ -27,16 +27,16 @@
}
}
.item {
outline: 0;
cursor: default;
padding-block: 0.5rem;
padding-inline: 1rem 2rem;
padding-inline: 1rem;
display: flex;
gap: 0.5rem;
line-height: 1em;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
svg {
color: $gray-500;
@@ -61,6 +61,42 @@
border-radius: 3px;
background-color: $gray-1000;
}
&[data-type='destructive'] {
color: $red-500;
svg {
color: $red-500;
}
}
}
.item .iconStrong {
color: $ui-white;
}
.content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
line-height: 1.2;
}
.description {
color: $gray-400;
font-size: calc(1rem - 2px);
line-height: 1.3;
}
.shortcut {
color: $gray-500;
font-size: calc(1rem - 4px);
letter-spacing: 0.02em;
}
.separator {
@@ -1,16 +1,18 @@
import { PropsWithChildren } from 'react';
import { IconType } from 'react-icons';
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
import { Menu as BaseMenu } from '@base-ui/react/menu';
import style from './DropdownMenu.module.scss';
type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = {
type: 'item';
type: 'item' | 'destructive';
label: string;
description?: string;
icon?: IconType;
disabled?: boolean;
onClick: () => void;
shortcut?: string;
};
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
@@ -31,9 +33,21 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
return <BaseMenu.Separator key={index} className={style.separator} />;
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />}
{item.label}
<BaseMenu.Item
key={index}
className={style.item}
onClick={item.onClick}
disabled={item.disabled}
data-type={item.type}
>
<span className={style.content}>
<span className={style.label}>
{item.icon && <item.icon />}
{item.label}
</span>
{item.description && <span className={style.description}>{item.description}</span>}
</span>
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
</BaseMenu.Item>
);
})}
@@ -69,8 +83,14 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />}
{item.label}
<span className={style.content}>
<span className={style.label}>
{item.icon && <item.icon />}
{item.label}
</span>
{item.description && <span className={style.description}>{item.description}</span>}
</span>
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
</BaseMenu.Item>
);
})}
@@ -1,6 +1,7 @@
.corner {
.arrow {
transform: rotate(45deg);
}
.corner {
position: absolute;
top: 0.5rem;
right: 0.5rem;
@@ -21,6 +22,10 @@
}
}
.offsetCorner {
right: 2rem;
}
.header {
font-size: 1.5rem;
}
@@ -51,6 +56,13 @@
&.vertical {
width: 1px;
height: 0.75em;
height: 0.75em;
}
}
.panel {
position: relative;
border-radius: $panel-border-radius;
background-color: $bg-container-l2;
padding: 1rem;
}
@@ -1,13 +1,33 @@
import type { HTMLAttributes, LabelHTMLAttributes } from 'react';
import type { HTMLAttributes, JSX, LabelHTMLAttributes, MouseEventHandler } from 'react';
import { IconBaseProps } from 'react-icons';
import { IoArrowUp } from 'react-icons/io5';
import { TbPictureInPictureOff } from 'react-icons/tb';
import { cx } from '../../utils/styleUtils';
import style from './EditorUtils.module.scss';
export function Corner({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
export function CornerExtract({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, style.arrow, className])} {...elementProps} />;
}
export function CornerPipButton({ className, ...elementProps }: IconBaseProps) {
return <TbPictureInPictureOff className={cx([style.corner, style.offsetCorner, className])} {...elementProps} />;
}
interface ExtractAndPip extends IconBaseProps {
onExtractClick: MouseEventHandler<SVGElement>;
pipElement: JSX.Element;
}
export function CornerWithPip({ className, pipElement, onExtractClick }: ExtractAndPip) {
return (
<>
<IoArrowUp className={cx([style.corner, style.arrow, className])} onClick={onExtractClick} />
{/* the pip element returns the icon button */}
{pipElement}
</>
);
}
export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) {
@@ -35,3 +55,7 @@ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
}
export function Panel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cx([style.panel, className])} {...props} />;
}
@@ -49,15 +49,14 @@ class ErrorBoundary extends React.Component {
return (
<div className={style.errorContainer} data-testid='error-container'>
<div>
<p className={style.error}>:/</p>
<p>Something went wrong</p>
<div
role='button'
<p className={style.error}>: /</p>
<p>Something went wrong.</p>
<a
className={style.report}
onClick={() => Sentry.showReportDialog({ eventId: this.state.eventId })}
href={`mailto:mail@getontime.no?subject=Error%20Report&body=${encodeURIComponent(this.reportContent)}`}
>
Report error
</div>
</a>
<div
role='button'
className={style.report}
@@ -3,24 +3,21 @@
height: 100%;
display: grid;
place-content: center;
background-color: #121212;
color: white;
background-color: $ui-black;
color: $ui-white;
}
.error {
color: $error-red;
font-weight: 600;
}
.error {
color: $error-red;
}
.report {
text-decoration: underline $error-red;
cursor: pointer;
}
.report {
color: $blue-500;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
.report:hover {
color: $error-red;
}
.report:active {
color: white;
&:hover {
color: $ontime-color;
}
}
@@ -1,3 +1,4 @@
import { MouseEvent } from 'react';
import { IoBan } from 'react-icons/io5';
import { cx } from '../../../utils/styleUtils';
@@ -10,12 +11,13 @@ interface SwatchProps {
isSelected?: boolean;
}
export default function Swatch(props: SwatchProps) {
const { color, isSelected, onClick } = props;
const handleClick = () => {
export default function Swatch({ color, isSelected, onClick }: SwatchProps) {
const handleClick = (event: MouseEvent) => {
onClick?.(color);
event.preventDefault();
event.stopPropagation();
};
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
if (!color) {
@@ -11,7 +11,7 @@
outline: none;
&::placeholder {
color: $gray-500;
color: $gray-600;
letter-spacing: 0;
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover } from '@base-ui-components/react/popover';
import { Popover } from '@base-ui/react/popover';
import PopoverContents from '../../popover/Popover';
@@ -11,8 +11,7 @@ interface AppLinkProps {
* Component used to navigate to an editor link inside the same window
* Handles the path to respect Ontime Clouds base URL
*/
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
const { search, children } = props;
export default function AppLink({ search, children }: PropsWithChildren<AppLinkProps>) {
const navigate = useNavigate();
const handleClick = () => navigate({ search });
@@ -12,9 +12,7 @@ interface ExternalLinkProps {
inline?: boolean;
}
export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props;
export default function ExternalLink({ href, inline, children }: ExternalLinkProps) {
const handleClick = (event: MouseEvent) => {
event.preventDefault();
openLink(href);
@@ -5,9 +5,9 @@
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(680px, 90vw);
min-width: min(880px, 90vw);
min-height: min(200px, 10vh);
max-width: min(900px, 90vw);
max-width: min(1200px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import IconButton from '../buttons/IconButton';
@@ -31,7 +31,7 @@ export default function Modal({
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
dismissible={false}
disablePointerDismissal
>
<BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
@@ -1,11 +1,14 @@
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog';
import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost } from '../../../externals';
import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton';
@@ -27,10 +30,12 @@ export default memo(NavigationMenu);
function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const id = useClientStore((store) => store.id);
const name = useClientStore((store) => store.name);
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
return (
@@ -53,25 +58,38 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</IconButton>
</div>
<div className={style.body}>
<NavigationMenuItem active={fullscreen} onClick={toggle}>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
{supportsFullscreen && (
<NavigationMenuItem active={fullscreen} onClick={toggle}>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
)}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
{keepAwake && <span className={style.note}>Active</span>}
</NavigationMenuItem>
)}
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
<hr className={style.separator} />
<EditorNavigation />
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
<ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Cuesheet
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'}>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline />
Operator
</ClientLink>
@@ -79,7 +97,12 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
<ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label}
</ClientLink>
))}
@@ -11,15 +11,22 @@ import style from './ClientLink.module.scss';
interface ClientLinkProps {
current: boolean;
to: string;
postAction?: () => void;
}
export default function ClientLink({ current, to, children }: PropsWithChildren<ClientLinkProps>) {
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent();
const navigate = useNavigate();
if (isElectron) {
return (
<NavigationMenuItem active={current} onClick={() => handleLinks(to)}>
<NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children}
<IoArrowUp className={style.linkIcon} />
</NavigationMenuItem>
@@ -27,7 +34,13 @@ export default function ClientLink({ current, to, children }: PropsWithChildren<
}
return (
<NavigationMenuItem active={current} onClick={() => navigate(`/${to}`)}>
<NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children}
</NavigationMenuItem>
);
@@ -1,5 +1,4 @@
import { IoApps } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { IoApps, IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
import { cx } from '../../../utils/styleUtils';
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react';
import { Popover } from '@base-ui-components/react/popover';
import { Popover } from '@base-ui/react/popover';
import style from './Popover.module.scss';
@@ -11,9 +11,11 @@
}
.pin {
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
input {
font-size: 4rem;
@@ -1,5 +1,5 @@
import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import { Radio } from '@base-ui/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group';
import { cx } from '../../utils/styleUtils';
@@ -0,0 +1,56 @@
@use '../../../theme/ontimeColours' as *;
@use '../../../theme/ontimeStyles' as *;
// copied from index.scss
$track-color: $white-1;
$thumb-color: $white-20;
$thumb-color-hover: $white-60;
.root {
position: relative;
overflow: hidden;
}
.viewport {
height: 100%;
width: 100%;
overscroll-behavior: contain;
}
.scrollbar {
background: transparent;
opacity: 0;
transition: opacity 150ms;
&[data-orientation='vertical'] {
width: 6px;
}
&[data-orientation='horizontal'] {
height: 6px;
}
&[data-orientation='horizontal'][data-has-overflow-x] {
opacity: 1;
pointer-events: auto;
}
&[data-scrolling] {
transition-duration: 0ms;
}
&[data-hovering],
&[data-scrolling] {
opacity: 1;
pointer-events: auto;
}
}
.thumb {
background: $thumb-color;
border-radius: 2px;
&:hover {
background: $thumb-color-hover;
}
}
@@ -0,0 +1,38 @@
import { CSSProperties, PropsWithChildren, Ref } from 'react';
import { ScrollArea } from '@base-ui/react/scroll-area';
import { cx } from '../../utils/styleUtils';
import style from './ScrollArea.module.scss';
interface ScrollAreaProps {
className?: string;
viewportClassName?: string;
contentClassName?: string;
contentStyle?: CSSProperties;
ref?: Ref<HTMLDivElement>;
orientation?: 'vertical' | 'horizontal';
}
export default function StyledScrollArea({
className,
viewportClassName,
contentClassName,
contentStyle,
children,
ref,
orientation = 'vertical',
}: PropsWithChildren<ScrollAreaProps>) {
return (
<ScrollArea.Root className={cx([style.root, className])}>
<ScrollArea.Viewport ref={ref} className={cx([style.viewport, viewportClassName])}>
<ScrollArea.Content className={contentClassName} style={contentStyle}>
{children}
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar className={style.scrollbar} orientation={orientation}>
<ScrollArea.Thumb className={style.thumb} />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
);
}
@@ -62,8 +62,6 @@
padding: 2px;
border-radius: $component-border-radius-md;
color: $ui-white;
overflow-y: auto;
max-height: 20rem;
border: 1px solid $gray-1000;
&[data-side='start'] {
@@ -73,6 +71,16 @@
}
}
.list {
box-sizing: border-box;
position: relative;
padding-block: 0.25rem;
overflow-y: auto;
max-height: 20rem;
max-height: var(--available-height);
scroll-padding-block: 1.5rem;
}
.item {
box-sizing: border-box;
outline: 0;
@@ -1,6 +1,6 @@
import { IoCheckmark } from 'react-icons/io5';
import { LuChevronsUpDown } from 'react-icons/lu';
import { Select as BaseSelect } from '@base-ui-components/react/select';
import { Select as BaseSelect } from '@base-ui/react/select';
import { cx } from '../../utils/styleUtils';
@@ -31,14 +31,17 @@ export default function Select<T>({ options, fluid, size = 'medium', ...selectRo
<BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.Popup className={styles.popup}>
{options.map(({ disabled, label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
</BaseSelect.Item>
))}
<BaseSelect.Arrow />
<BaseSelect.List className={styles.list}>
{options.map(({ disabled, label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
</BaseSelect.Item>
))}
</BaseSelect.List>
</BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
</BaseSelect.Positioner>
@@ -1,4 +1,4 @@
import { Switch as BaseSwitch } from '@base-ui-components/react/switch';
import { Switch as BaseSwitch } from '@base-ui/react/switch';
import { cx } from '../../utils/styleUtils';
@@ -1,7 +1,11 @@
import { ReactNode } from 'react';
import { PropsWithChildren } from 'react';
import style from './Tag.module.scss';
export default function Tag({ children }: { children: ReactNode }) {
return <span className={style.tag}>{children}</span>;
interface TagProps {
className?: string;
}
export default function Tag({ className, children }: PropsWithChildren<TagProps>) {
return <span className={`${style.tag} ${className || ''}`}>{children}</span>;
}
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react';
import { Tooltip as BaseTooltip } from '@base-ui-components/react/tooltip';
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip';
import style from './Tooltip.module.scss';
@@ -1,3 +1,5 @@
import { useRef } from 'react';
import { projectLogoPath } from '../../api/constants';
import './ViewLogo.scss';
@@ -7,13 +9,19 @@ interface ViewLogoProps {
className: string;
}
export default function ViewLogo(props: ViewLogoProps) {
const { name, className } = props;
export default function ViewLogo({ name, className }: ViewLogoProps) {
const imageRef = useRef<HTMLImageElement>(null);
const hideImage = () => {
if (!imageRef.current) return;
imageRef.current.style.display = 'none';
};
// we wrap the image in a div to help maintain the aspect ratio
return (
<div className={className}>
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
<img ref={imageRef} alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' onError={hideImage} />
</div>
);
}
@@ -32,11 +32,11 @@
margin-left: 0.25rem;
width: 0.75em;
height: 0.75em;
background: var(--user-bg);
background: var(--user-bg, $gray-900);
border-radius: 50%;
}
}
.empty {
color: $ui-white;
}
}
@@ -1,7 +1,7 @@
import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { isStringBoolean } from '../../../views/common/viewUtils';
import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select';
@@ -39,7 +39,14 @@ export default function ParamInput({ paramField }: ParamInputProps) {
}
if (type === 'multi-option') {
return <MultiOption paramField={paramField} />;
const optionFromParams = searchParams.getAll(id);
return (
<MultiOption
paramField={paramField}
options={optionFromParams.length ? optionFromParams : paramField.defaultValue ?? ['']}
/>
);
}
if (type === 'boolean') {
@@ -74,19 +81,17 @@ export default function ParamInput({ paramField }: ParamInputProps) {
interface EditFormMultiOptionProps {
paramField: ParamField & { type: 'multi-option' };
options: string[];
}
function MultiOption({ paramField }: EditFormMultiOptionProps) {
const [searchParams] = useSearchParams();
const { id, values, defaultValue = [''] } = paramField;
const optionFromParams = searchParams.getAll(id);
const [paramState, setParamState] = useState<string[]>(optionFromParams.length ? optionFromParams : defaultValue);
function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
const { id, values } = paramField;
const [paramState, setParamState] = useState<string[]>(options);
// synchronise options
useEffect(() => {
const params = searchParams.getAll(id);
setParamState(params.length ? params : defaultValue);
}, [searchParams, id, defaultValue]);
setParamState(options);
}, [options]);
const toggleValue = (value: string, checked: boolean) => {
if (checked) {
@@ -155,7 +160,16 @@ function ControlledSelect({ id, initialValue, options }: ControlledSelectProps)
}, [initialValue]);
return (
<Select size='large' name={id} options={options} value={selected} onValueChange={(value) => setSelected(value)} />
<Select
size='large'
name={id}
options={options}
value={selected}
onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
/>
);
}
@@ -28,7 +28,9 @@
bottom: 0;
width: 40rem;
max-width: 100vw;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
@@ -1,9 +1,10 @@
import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog';
import { Dialog } from '@base-ui/react/dialog';
import { OntimeView } from 'ontime-types';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
const isSmallScreen = useIsSmallScreen();
const handleClose = () => {
close();
@@ -42,6 +44,10 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams);
if (isSmallScreen) {
close();
}
};
return (
@@ -46,7 +46,7 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
options.push({
value: key,
label: value.label,
colour: value.colour || 'transparent',
colour: value.colour,
});
}
@@ -56,13 +56,18 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
/**
* Creates data for a select element that displays project custom data
*/
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] {
return projectData.custom.map((entry, index) => {
export function makeProjectDataOptions(
projectData: ProjectData,
additionalOptions: SelectOption[] = [],
): SelectOption[] {
const generatedOptions = projectData.custom.map((entry, index) => {
return {
value: `${index}-${entry.title}`,
label: entry.title,
};
});
return [...additionalOptions, ...generatedOptions];
}
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
@@ -0,0 +1,24 @@
import { createContext, PropsWithChildren, useContext } from 'react';
import { useEntryActions } from '../hooks/useEntryAction';
type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
interface EntryActionsProviderProps extends PropsWithChildren {
actions: EntryActionsContextValue;
}
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
}
export function useEntryActionsContext(): EntryActionsContextValue {
const context = useContext(EntryActionsContext);
if (!context) {
throw new Error('useEntryActionsContext must be used within EntryActionsProvider');
}
return context;
}
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types';
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants';
@@ -24,22 +24,33 @@ function useProjectList() {
return { data: data ?? placeholderProjectList, status, refetch };
}
export function useOrderedProjectList() {
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
type SortComparator = (a: ProjectFile, b: ProjectFile) => number;
const sortComparators: Record<ProjectSortMode, SortComparator> = {
'alphabetical-asc': (a, b) => a.filename.localeCompare(b.filename),
'alphabetical-desc': (a, b) => b.filename.localeCompare(a.filename),
'modified-asc': (a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
'modified-desc': (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
};
export function useOrderedProjectList(sort: ProjectSortMode = 'modified-desc') {
const response = useProjectList();
const { files, lastLoadedProject } = response.data;
const reorderedProjectFiles = useMemo(() => {
const reorderedProjectFiles: ProjectFileList = useMemo(() => {
if (!files.length) return [];
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
const sorted = [...files].sort(sortComparators[sort]);
if (currentlyLoadedIndex === -1) return files;
// keep loaded always on top
const currentlyLoadedIndex = sorted.findIndex((project) => project.filename === lastLoadedProject);
if (currentlyLoadedIndex > 0) {
const [loaded] = sorted.splice(currentlyLoadedIndex, 1);
sorted.unshift(loaded);
}
const projectFiles = [...files];
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
return [current, ...projectFiles];
}, [files, lastLoadedProject]);
return sorted;
}, [files, lastLoadedProject, sort]);
return { ...response, data: { reorderedProjectFiles, lastLoadedProject: response.data.lastLoadedProject } };
}
@@ -3,7 +3,7 @@ import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants';
import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList, loadRundown, renameRundown } from '../api/rundown';
/**
* Project rundowns
@@ -30,6 +30,26 @@ export function useMutateProjectRundowns() {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: duplicate } = useMutation({
mutationFn: duplicateRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: rename } = useMutation({
mutationFn: ([rundownId, title]: Parameters<typeof renameRundown>) => renameRundown(rundownId, title),
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: remove } = useMutation({
mutationFn: deleteRundown,
@@ -51,5 +71,5 @@ export function useMutateProjectRundowns() {
},
});
return { create, remove, load };
return { create, duplicate, remove, load, rename };
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
@@ -6,9 +6,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import useProjectData from './useProjectData';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
@@ -27,15 +25,15 @@ export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN,
queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId();
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
}
@@ -46,37 +44,20 @@ export function useRundownWithMetadata() {
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const { data: projectData } = useProjectData();
const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]);
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
setFlatRundown(flatRundown);
setPrevRevision(data.revision);
const flatRundown = useMemo(() => {
if (data.revision === -1) {
return [];
}
}, [data.entries, data.flatOrder, data.revision, prevRevision]);
// TODO: should we have a project id field?
// TODO(v4): cleanup as part of load multiple rundowns
// invalidate current version if project changes
useEffect(() => {
if (projectData?.title !== loadedProject.current) {
setPrevRevision(-1);
loadedProject.current = projectData?.title ?? '';
}
}, [projectData]);
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId();
const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
@@ -84,9 +65,13 @@ export function useFlatRundownWithMetadata() {
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*
*/
export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
const { data, status } = useFlatRundown();
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
@@ -100,11 +85,6 @@ export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
export function useEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown();
// track the specific entry we care about
const entry = useMemo(() => {
if (entryId === null) return null;
return rundown.entries[entryId];
}, [entryId, rundown.entries]);
return entry;
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
@@ -1,18 +1,24 @@
import { MouseEvent } from 'react';
import { MouseEvent, useCallback } from 'react';
import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu';
import { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu';
export const useContextMenu = <T extends HTMLElement>(options: DropdownMenuOption[]) => {
type ContextMenuOptions = () => DropdownMenuOption[];
export const useContextMenu = <T extends HTMLElement>(options: ContextMenuOptions) => {
const setContextMenu = useContextMenuStore((state) => state.setContextMenu);
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up
contextMenuEvent.preventDefault();
const localCreateContextMenu = useCallback(
(contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up
contextMenuEvent.preventDefault();
const { pageX, pageY } = contextMenuEvent;
return setContextMenu({ x: pageX, y: pageY }, options);
};
const { pageX, pageY } = contextMenuEvent;
const menuOptions = options();
return setContextMenu({ x: pageX, y: pageY }, menuOptions);
},
[options, setContextMenu],
);
return [localCreateContextMenu];
};
+40 -20
View File
@@ -1,7 +1,8 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
InsertOptions,
isOntimeEvent,
isOntimeGroup,
MaybeString,
@@ -173,7 +174,8 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId),
mutationFn: ([rundownId, entryId, options]: Parameters<typeof postCloneEntry>) =>
postCloneEntry(rundownId, entryId, options),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
@@ -182,14 +184,14 @@ export const useEntryActions = () => {
* Clone an entry
*/
const clone = useCallback(
async (entryId: EntryId) => {
async (entryId: EntryId, options?: InsertOptions) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await cloneEntryMutation([rundownId, entryId]);
await cloneEntryMutation([rundownId, entryId, options]);
} catch (error) {
logAxiosError('Error cloning entry', error);
}
@@ -838,22 +840,40 @@ export const useEntryActions = () => {
[getCurrentRundownData, swapEventsMutation],
);
return {
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
updateTimer,
};
return useMemo(
() => ({
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
updateTimer,
}),
[
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
updateTimer,
],
);
};
/**
@@ -23,6 +23,9 @@ export const useFadeOutOnInactivity = (initialState = false) => {
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
// we call the function on mount, to make sure the menu is hidden
throttledShowMenu();
document.addEventListener('mousemove', throttledShowMenu);
document.addEventListener('keydown', throttledShowMenu);
@@ -1,6 +1,5 @@
import { RefObject, useCallback, useEffect, useRef } from 'react';
import { useSelectedEventId } from './useSocket';
import { RefObject, useCallback, useEffect } from 'react';
import { MaybeString } from 'ontime-types';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>,
@@ -18,37 +17,26 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
// maintain current x scroll position
scrollRef.current.scrollTo(scrollRef.current.scrollLeft, top);
}
interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
}
export default function useFollowComponent(props: UseFollowComponentProps) {
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props;
// when cursor moves, view should follow
export default function useFollowComponent({
followRef,
scrollRef,
doFollow,
topOffset = 100,
setScrollFlag,
followTrigger,
}: UseFollowComponentProps) {
// when trigger moves, view should follow
useEffect(() => {
if (!doFollow) {
if (!doFollow || !followTrigger) {
return;
}
@@ -60,16 +48,14 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
setScrollFlag?.(false);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [followRef?.current, scrollRef?.current]);
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef.current && containerRef.current) {
if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, scrollRef!, offset);
scrollToComponent(componentRef!, containerRef!, offset);
}
},
[followRef, scrollRef, topOffset],
@@ -77,32 +63,3 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
return scrollToRefComponent;
}
export function useFollowSelected(doFollow: boolean, topOffset = 100) {
const selectedEvenId = useSelectedEventId();
const selectedRef = useRef<HTMLTableRowElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!doFollow) {
return;
}
if (selectedEvenId && selectedRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
snapToComponent(
{ current: selectedRef.current } as RefObject<HTMLElement>,
{ current: scrollRef.current } as RefObject<HTMLElement>,
topOffset,
);
});
}
}, [doFollow, selectedEvenId, topOffset]);
return {
selectedRef,
scrollRef,
};
}
@@ -0,0 +1,95 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { throttle } from '../utils/throttle';
export interface UseLongPressOptions {
/** Time in milliseconds to trigger the long press, default is 400ms */
threshold?: number;
/** Callback triggered when the long press starts */
onStart?: (event: React.MouseEvent | React.TouchEvent) => void;
/** Callback triggered when the long press finishes */
onFinish?: (event: React.MouseEvent | React.TouchEvent) => void;
/** Callback triggered when the long press is canceled */
onCancel?: (event: React.MouseEvent | React.TouchEvent) => void;
}
export interface UseLongPressReturnValue {
onMouseDown: (event: React.MouseEvent) => void;
onMouseUp: (event: React.MouseEvent) => void;
onMouseLeave: (event: React.MouseEvent) => void;
onTouchStart: (event: React.TouchEvent) => void;
onTouchEnd: (event: React.TouchEvent) => void;
}
export function useLongPress(
onLongPress: (event: React.MouseEvent | React.TouchEvent) => void,
options: UseLongPressOptions = {},
): UseLongPressReturnValue {
const { threshold = 700, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timeout = useRef<number>(-1);
useEffect(() => () => window.clearTimeout(timeout.current), []);
return useMemo(() => {
if (typeof onLongPress !== 'function') {
return {} as UseLongPressReturnValue;
}
const start = (event: React.MouseEvent | React.TouchEvent) => {
if (!isMouseEvent(event) && !isTouchEvent(event)) {
return;
}
if (onStart) {
onStart(event);
}
isPressed.current = true;
timeout.current = window.setTimeout(() => {
onLongPress(event);
isLongPressActive.current = true;
}, threshold);
};
const cancel = (event: React.MouseEvent | React.TouchEvent) => {
if (!isMouseEvent(event) && !isTouchEvent(event)) {
return;
}
if (isLongPressActive.current) {
onFinish?.(event);
} else if (isPressed.current) {
onCancel?.(event);
}
isLongPressActive.current = false;
isPressed.current = false;
if (timeout.current) {
window.clearTimeout(timeout.current);
}
};
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
onTouchMove: throttle(cancel, 150),
};
}, [onLongPress, threshold, onCancel, onFinish, onStart]);
}
function isTouchEvent(event: React.MouseEvent | React.TouchEvent): event is React.TouchEvent {
return window.TouchEvent ? event.nativeEvent instanceof TouchEvent : 'touches' in event.nativeEvent;
}
function isMouseEvent(event: React.MouseEvent | React.TouchEvent): event is React.MouseEvent {
return event.nativeEvent instanceof MouseEvent;
}
+52 -63
View File
@@ -10,7 +10,9 @@ const createSelector =
export const setClientRemote = {
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload),
setRedirect: (payload: { target: string; redirect: string }) => sendSocket('client', payload),
setRedirect: (payload: { target: string; redirect: string }) => {
sendSocket('client', payload);
},
setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
};
@@ -126,13 +128,9 @@ export const setAuxTimer = {
setDuration: (index: number, time: number) => sendSocket('auxtimer', { [index]: { duration: time } }),
};
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null,
}));
export const useSelectedEventId = createSelector((state: RuntimeStore) => state.eventNow?.id ?? null);
export const useCurrentGroupId = createSelector((state: RuntimeStore) => ({
currentGroupId: state.groupNow?.id ?? null,
}));
export const useCurrentGroupId = createSelector((state: RuntimeStore) => state.groupNow?.id ?? null);
export const setEventPlayback = {
loadEvent: (id: string) => sendSocket('load', { id }),
@@ -145,9 +143,7 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
export const useClock = createSelector((state: RuntimeStore) => ({
clock: state.clock,
}));
export const useClock = createSelector((state: RuntimeStore) => state.clock);
export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.eventFlag?.id ?? null,
@@ -162,68 +158,65 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null,
}));
export const useRundownOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd,
expectedEnd: state.offset.expectedRundownEnd,
}));
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
numEvents: state.rundown.numEvents,
selectedEventIndex: state.rundown.selectedEventIndex,
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
groupExpectedEnd: state.offset.expectedGroupEnd,
}));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.offset.absolute,
}));
export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
mode: state.offset.mode,
currentDay: state.eventNow?.dayOffset ?? 0,
currentDay: state.rundown.currentDay ?? 0,
actualStart: state.rundown.actualStart,
plannedStart: state.rundown.plannedStart,
clock: state.clock,
}));
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
currentDay: state.eventNow?.dayOffset ?? 0,
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.offset.absolute,
}));
export const usePing = createSelector((state: RuntimeStore) => ({
ping: state.ping,
}));
export const usePing = createSelector((state: RuntimeStore) => state.ping);
/** convert ping into a derived value which changes less often */
export const useIsOnline = createSelector((state: RuntimeStore) => ({
isOnline: state.ping > 0,
}));
export const useIsOnline = createSelector((state: RuntimeStore) => state.ping > 0);
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
offsetMode: state.offset.mode,
}));
export const useOffsetMode = createSelector((state: RuntimeStore) => state.offset.mode);
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload);
export const usePlayback = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
});
export const usePlayback = createSelector((state: RuntimeStore) => state.timer.playback);
return useRuntimeStore(featureSelector);
};
/* ======================= Overview data subscriptions ======================= */
export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.rundown.plannedStart,
actualStart: state.rundown.actualStart,
plannedEnd: state.rundown.plannedEnd,
}));
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => state.offset.expectedRundownEnd);
export const useProgressOverview = createSelector((state: RuntimeStore) => ({
numEvents: state.rundown.numEvents,
selectedEventIndex: state.rundown.selectedEventIndex,
}));
export const useOffsetOverview = createSelector((state: RuntimeStore) => ({
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
playback: state.timer.playback,
}));
export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
groupExpectedEnd: state.offset.expectedGroupEnd,
// we can force these numbers to 0 fo this use case to avoid null checks
actualGroupStart: state.rundown.actualGroupStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
playback: state.timer.playback,
}));
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
// we can force these numbers to 0 fo this use case to avoid null checks
actualStart: state.rundown.actualStart ?? 0,
plannedStart: state.rundown.plannedStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
playback: state.timer.playback,
}));
/* ======================= View specific subscriptions ======================= */
@@ -266,11 +259,7 @@ export const useStudioTimersSocket = createSelector((state: RuntimeStore) => ({
eventNow: state.eventNow,
message: state.message,
time: state.timer,
offset: state.offset,
offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
rundown: state.rundown,
}));
export const useTimelineSocket = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.offset.absolute,
expectedRundownEnd: state.offset.expectedRundownEnd,
}));
@@ -1 +0,0 @@
export type Size = 'xs' | 'sm' | 'md' | 'lg';
@@ -1,19 +0,0 @@
export type OverridableOptions = {
keyColour?: string;
textColour?: string;
textBackground?: string;
font?: string;
size?: number;
justifyContent?: 'start' | 'center' | 'end';
alignItems?: 'start' | 'center' | 'end';
left?: string;
top?: string;
hideNav?: boolean;
hideOvertime?: boolean;
hideMessagesOverlay?: boolean;
hideEndMessage?: boolean;
language?: string;
showProgressBar?: boolean;
hideTimerSeconds?: boolean;
removeLeadingZeros?: boolean;
};
@@ -2,10 +2,13 @@ import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: string | null;
setEntryCopyId: (eventId: string | null) => void;
entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
};
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null,
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }),
entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
}));
@@ -1,89 +0,0 @@
import { create } from 'zustand';
type Target = 'cuesheet' | 'timer' | 'clock' | 'countdown' | 'backstage' | 'studio';
interface SelectionState {
[key: string]: boolean;
}
interface ColumnPermissions {
read: string[];
write: string[];
}
interface CuesheetLinksState {
target: Target | null;
readSelected: SelectionState;
writeSelected: SelectionState;
setTarget: (target: Target | null) => void;
setField: (field: 'read' | 'write', key: string, value: boolean) => void;
toggleField: (field: 'read' | 'write', key: string) => void;
selectAll: (field: 'read' | 'write', keys: string[]) => void;
clearAll: (field: 'read' | 'write', keys: string[]) => void;
// Returns arrays of column keys that have read/write permissions if target is 'cuesheet'
getSelections: () => ColumnPermissions | null;
}
export const useCuesheetLinksStore = create<CuesheetLinksState>((set, get) => ({
target: null,
readSelected: {},
writeSelected: {},
setTarget: (target) => set({ target }),
setField: (field, key, value) =>
set((state) => ({
...(field === 'read'
? { readSelected: { ...state.readSelected, [key]: value } }
: { writeSelected: { ...state.writeSelected, [key]: value } }),
})),
toggleField: (field, key) =>
set((state) => ({
...(field === 'read'
? { readSelected: { ...state.readSelected, [key]: !state.readSelected[key] } }
: { writeSelected: { ...state.writeSelected, [key]: !state.writeSelected[key] } }),
})),
selectAll: (field, keys) =>
set((_state) => ({
...(field === 'read'
? {
readSelected: keys.reduce((acc, key) => {
acc[key] = true;
return acc;
}, {} as SelectionState),
}
: {
writeSelected: keys.reduce((acc, key) => {
acc[key] = true;
return acc;
}, {} as SelectionState),
}),
})),
clearAll: (field, keys) =>
set((_state) => ({
...(field === 'read'
? {
readSelected: keys.reduce((acc, key) => {
acc[key] = false;
return acc;
}, {} as SelectionState),
}
: {
writeSelected: keys.reduce((acc, key) => {
acc[key] = false;
return acc;
}, {} as SelectionState),
}),
})),
getSelections: () => {
const state = get();
if (state.target !== 'cuesheet') return null;
return {
read: Object.entries(state.readSelected)
.filter(([_, selected]) => selected)
.map(([key]) => key),
write: Object.entries(state.writeSelected)
.filter(([_, selected]) => selected)
.map(([key]) => key),
};
},
}));
@@ -1,68 +0,0 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../clone';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEntry.Event,
flag: false,
title: 'title',
cue: 'cue',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
parent: 'test',
linkStart: false,
countToEnd: false,
endAction: EndAction.None,
skip: false,
colour: 'F00',
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
triggers: [],
custom: {
lighting: '3',
} as EntryCustomFields,
};
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned.triggers).not.toBe(original.triggers);
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
flag: original.flag,
title: original.title,
note: original.note,
timeStart: original.timeStart,
duration: original.duration,
timeEnd: original.timeEnd,
timerType: original.timerType,
timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd,
linkStart: original.linkStart,
endAction: original.endAction,
skip: original.skip,
colour: original.colour,
revision: 0,
delay: original.delay,
dayOffset: original.dayOffset,
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
});
});
});
@@ -1,6 +1,4 @@
import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv';
import { makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => {
it('joins an array of arrays with commas and newlines', () => {
@@ -13,34 +11,3 @@ after newline,after comma
`);
});
});
describe('aggregateRundowns()', () => {
it('flattens an object of rundowns into a single array', () => {
const rundowns = {
first: {
id: '',
title: '',
revision: 0,
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
'1': { id: '1' } as OntimeEntry,
'2': { id: '2' } as OntimeEntry,
},
},
second: {
id: '',
title: '',
revision: 0,
order: ['3', '4'],
flatOrder: ['3', '4'],
entries: {
'3': { id: '3' } as OntimeEntry,
'4': { id: '4' } as OntimeEntry,
},
} as Rundown,
} as ProjectRundowns;
expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]);
});
});
@@ -14,4 +14,11 @@ describe('linkToOTherHost', () => {
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
});
it('should handle ontime app links', () => {
const serverUrl = 'https://app.getontime.no/user-hash';
const baseUri = 'user-hash';
const destination = linkToOtherHost('app.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://app.getontime.no/user-hash/path');
});
});
@@ -140,7 +140,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
@@ -156,7 +156,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
@@ -172,8 +172,8 @@ describe('initRundownMetadata()', () => {
eventIndex: 3,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: true,
groupId: 'group',
groupColour: 'red',
@@ -188,7 +188,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 4,
isPast: false,
isNextDay: false,
totalGap: 10,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: false,
groupId: 'group',
@@ -204,7 +204,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 5,
isPast: false,
isNextDay: false,
totalGap: 17,
totalGap: 7,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
@@ -1,4 +1,6 @@
import { formatTime, nowInMillis } from '../time';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { formatDuration, formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => {
@@ -38,3 +40,18 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('-01:00');
});
});
describe('formatDuration()', () => {
it('formats durations correctly', () => {
expect(formatDuration(0)).toBe('0m');
expect(formatDuration(-5000)).toBe('0m');
expect(formatDuration(MILLIS_PER_MINUTE)).toBe('1m');
expect(formatDuration(6 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND)).toBe('6m');
expect(formatDuration(MILLIS_PER_MINUTE * 10)).toBe('10m');
expect(formatDuration(MILLIS_PER_MINUTE * 10 + 100)).toBe('10m');
expect(formatDuration(MILLIS_PER_MINUTE * 10 - 100)).toBe('9m');
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE)).toBe('2h6m');
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
expect(formatDuration(599702, false)).toBe('9m59s');
});
});
@@ -175,7 +175,7 @@ describe('generateUrlPresetOptions', () => {
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
});
it('throws on on invalid route', () => {
it('throws on invalid route', () => {
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
});
});
-36
View File
@@ -1,36 +0,0 @@
import { OntimeEvent, SupportedEntry } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
* @param {OntimeEvent} event
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return {
type: SupportedEntry.Event,
flag: event.flag,
title: event.title,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart,
endAction: event.endAction,
skip: event.skip,
colour: event.colour,
parent: event.parent,
revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
triggers: structuredClone(event.triggers),
custom: structuredClone(event.custom),
};
};
@@ -1,4 +1,18 @@
// we need to this as a promise because safari
export default async function copyToClipboard(text: string) {
setTimeout(async () => await navigator.clipboard?.writeText(text));
/**
* copy text to clipboard
* @throws if not supported or permission denied
*/
export async function copyToClipboard(text: string) {
await navigator.clipboard?.writeText(text);
}
/**
* Copy to clipboard but safely ignore errors
*/
export async function safeCopyToClipboard(text: string): Promise<void> {
try {
await copyToClipboard(text);
} catch {
// Silently ignore errors
}
}
-23
View File
@@ -1,5 +1,4 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
/**
* Converts an array of arrays to a CSV file
@@ -7,25 +6,3 @@ import { OntimeEntry, ProjectRundowns } from 'ontime-types';
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays);
}
/**
* Receives an object of rundowns, and flattens them into a single, linear rundown
* Used for CSV export
*/
export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] {
const rundownKeys = Object.keys(rundowns);
if (rundownKeys.length === 0) return [];
const flatRundown: OntimeEntry[] = [];
for (const key of rundownKeys) {
const { order, entries } = rundowns[key];
for (let i = 0; i < order.length; i++) {
const entryId = order[i];
const entry = entries[entryId];
flatRundown.push(entry);
}
}
return flatRundown;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { MaybeNumber } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { millisToString, removeLeadingZero } from 'ontime-utils';
import { enDash } from './styleUtils';
@@ -14,7 +14,7 @@ export function getOffsetText(offset: MaybeNumber): string {
let offsetText = '';
if (offset < 0) offsetText += '-';
if (offset > 0) offsetText += '+';
offsetText += millisToString(Math.abs(offset));
offsetText += removeLeadingZero(millisToString(Math.abs(offset)));
return offsetText;
}
@@ -125,6 +125,7 @@ function processEntry(
if (entry.id === selectedEventId) {
processedData.isLoaded = true;
processedData.isPast = false;
processedData.isLinkedToLoaded = true;
}
if (isOntimeGroup(entry)) {
@@ -150,15 +151,16 @@ function processEntry(
if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast && !processedData.isLoaded) {
/**
* isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event
* b) find a countToEnd event
*/
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
*/
processedData.totalGap += entry.gap;
processedData.isLinkedToLoaded =
entry.linkStart && !processedData.previousEvent?.countToEnd && processedData.isLinkedToLoaded;
}
if (isNewLatest(entry, processedData.latestEvent)) {
@@ -1,8 +0,0 @@
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;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import {
ApiAction,
ApiActionTag,
Log,
MessageTag,
RefetchKey,
@@ -199,7 +199,7 @@ export const connectSocket = () => {
};
};
export function sendSocket<T extends MessageTag | ApiAction>(
export function sendSocket<T extends MessageTag | ApiActionTag>(
tag: T,
payload: T extends MessageTag ? Pick<WsPacketToServer & { tag: T }, 'payload'>['payload'] : unknown,
): void {
+40 -2
View File
@@ -12,6 +12,8 @@ import { APP_SETTINGS } from '../api/constants';
import { useExpectedStartData } from '../hooks/useSocket';
import { ontimeQueryClient } from '../queryClient';
import { ExtendedEntry } from './rundownMetadata';
/**
* Returns current time in milliseconds from midnight
* @returns {number}
@@ -110,11 +112,12 @@ export const formatTime = (
export function formatDuration(duration: number, hideSeconds = true): string {
// durations should never be negative, we handle it here to flag if there is an issue in future
if (duration <= 0) {
return '0h 0m';
return '0m';
}
const hours = Math.floor(duration / MILLIS_PER_HOUR);
const minutes = Math.floor((duration % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE);
let result = '';
if (hours > 0) {
result += `${hours}h`;
@@ -124,11 +127,16 @@ export function formatDuration(duration: number, hideSeconds = true): string {
}
if (!hideSeconds) {
const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
const remainingMs = duration % MILLIS_PER_MINUTE;
const exactSeconds = remainingMs / MILLIS_PER_SECOND;
// cap at 59 to avoid showing 60s
const seconds = Math.min(59, Math.ceil(exactSeconds));
if (seconds > 0) {
result += `${seconds}s`;
}
}
return result;
}
@@ -154,3 +162,33 @@ export function useTimeUntilExpectedStart(
);
return expectedStart - clock;
}
export function getExpectedTimesFromExtendedEvent(
event: Pick<
ExtendedEntry<OntimeEvent>,
'timeStart' | 'dayOffset' | 'delay' | 'totalGap' | 'isLinkedToLoaded' | 'countToEnd' | 'duration'
> | null,
state: ReturnType<typeof useExpectedStartData>,
) {
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
const expectedStart = getExpectedStart(
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
{
totalGap: event.totalGap,
isLinkedToLoaded: event.isLinkedToLoaded,
...state,
},
);
const plannedEnd = event.timeStart + event.duration + event.delay;
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd,
};
}
+5 -5
View File
@@ -33,8 +33,8 @@ export function validateProjectFile(file: File) {
}
// Limit file size of a project file to around 1MB
if (file.size > 1_000_000) {
throw new Error('File size limit (1MB) exceeded');
if (file.size > 2_000_000) {
throw new Error('File size limit (2MB) exceeded');
}
}
@@ -56,8 +56,8 @@ export function validateLogo(file: File) {
throw new Error('File is empty');
}
// Limit file size of a project file to around 1MB
if (file.size > 1_000_000) {
throw new Error('File size limit (1MB) exceeded');
// Limit file size of a project file to around 1.5MB
if (file.size > 1_500_000) {
throw new Error('File size limit (1.5MB) exceeded');
}
}
+5
View File
@@ -19,6 +19,11 @@ declare global {
process: {
type: string;
};
// Experimental browser feature
documentPictureInPicture: {
requestWindow: () => Promise<Window>;
window: Window;
};
}
}
+8 -4
View File
@@ -8,6 +8,7 @@ export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no';
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
@@ -17,12 +18,15 @@ export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
// resolve environment
export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
export const isDocker = import.meta.env.IS_DOCKER; // this env is made available by the vite.config.js define function
export const isProduction = import.meta.env.PROD;
export const isDev = import.meta.env.DEV;
export const currentHostName = window.location.hostname;
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no');
export const isOntimeCloud = document.querySelector('base')?.hasAttribute('data-is-cloud')
export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
export const supportsFullscreen = document.fullscreenEnabled;
// resolve entrypoint URLs

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