Compare commits

...

111 Commits

Author SHA1 Message Date
arc-alex 6397d33eaa feat: option to select all events for countdown 2025-09-16 16:59:29 +02:00
arc-alex 9288566a7f chore: remove unused code 2025-09-16 16:38:11 +02:00
Alex Christoffer Rasmussen 7fd9f83fb0 fix docker ignore (#1780) 2025-09-16 16:27:00 +02:00
Carlos Valente e621f1386e fix(cuesheet): infinite render loop on resizing columns 2025-09-16 06:43:19 +02:00
Carlos Valente b51e7cbd2d fix: maintain multiline in cuesheet cells 2025-09-14 14:19:18 +02:00
Carlos Valente 7cd92ce5f7 docs: add contribution guidelines 2025-09-14 14:19:18 +02:00
Carlos Valente f6a02abf36 fix: skip nested events 2025-09-14 14:19:18 +02:00
Carlos Valente e4b0df42cf feat: allow finding milestones 2025-09-14 14:19:18 +02:00
Carlos Valente ded8bddb2d refactor: improve automated following 2025-09-14 14:19:18 +02:00
Carlos Valente 8d3ce46c56 fix: allow moving a group after another 2025-09-14 14:19:18 +02:00
Carlos Valente 578cb2b244 refactor: improve pin styling 2025-09-14 14:19:18 +02:00
Carlos Valente c79ee193c9 fix: prevent layout reflow on different entry types 2025-09-14 14:19:18 +02:00
Carlos Valente 5810ae0d54 refactor: add default value to secondary source 2025-09-14 14:19:18 +02:00
Carlos Valente bfbf8574e3 fix: phase style overrides in timer 2025-09-14 14:19:18 +02:00
Alex Christoffer Rasmussen 490e429f44 cleanup (#1776)
* chore: cleanup leftover console log

* chore: prevent error in client tsconfig
2025-09-13 16:31:56 +02:00
Alex Christoffer Rasmussen 2e950965e0 fix: sheet default import map (#1775)
* fix: default import map

* fix: update test
2025-09-08 09:49:01 +02:00
arc-alex cc34db775a chore: bump version 2025-09-08 06:51:07 +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 89922fe394 revert: fixed size for editor 2025-09-03 17:48:45 +02:00
Alex Christoffer Rasmussen f219ebb8f8 refactor: settings route (#1753)
* refactor: settings route
2025-09-03 17:48:45 +02:00
Carlos Valente eace97ec7a bump version to 4.0.0-alpha.6 2025-09-03 17:48:45 +02:00
Carlos Valente ec1df8b958 fix: bundle translations 2025-09-03 17:48:45 +02:00
Carlos Valente 8d2d8ef979 feat: add user defined translations (#1756)
* feat: add user defined translations

* add refetch key for translation (#1757)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2025-09-03 16:06:03 +02:00
Carlos Valente ae15f3cdc5 Alpha 5 (#1741)
* refactor: align header columns

* refactor: improve scrollbar visibility

* refactor: center align table elements

* refactor: make param elements stateful

* fix: issue with collapsed elements not loosing value

* fix: prevent search params containing multiple alias references

* fix: the issue where a file disappears if it is both migrated and recovered in the same load operation (#1744)

* refactor: disable group action for elements in groups

* refactor: move context menu items into the event element (#1747)

* feat: sheet import new features for v4 (#1730)

* import milestone

* fixup! import milestone

* test: milestone import

* add entries to group

stop on new group or on group-end type

* fixup! add entries to group

* cleanup

* add event target duration

* link start if undefined

* add skip import type

* extract some to the excel paresing functions

* tweaks to presentation

* move file

---------

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

* fix: notify runtimeStore of events bieng groupd

* fix: improve authentication and stage detection in demo

* chore: ship logo with project

* fix: client is referenced by name

* fix: prevent reflow in event editor

* fix: stale render on selected event due to ref mismatch

* Create/Load/Delete multiple rundowns (#1696)

* refactor: restore last loaded rundown

* refactor: initialise rundown in ProjectService

* feat: allow switching rundowns

ensure on coordination between the db object and the working object

server provide list of rundowns

implement switch in the UI

implement delete

implement new rundown button

* fix: render order for floating button

* refactor: appropriate names to service

* refactor: rundown endpoints

* refactor: save last loaded rundown ID

* refactor: rundown management UI

* refactor: emit refetch all on project load

---------

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

* feat: recover single event subscription

* fixup! feat: sheet import new features for v4 (#1730)

* fix: prevent dropping a group inside another

* fixup! refactor: move context menu items into the event element (#1747)

* fix: prevent stale references to custom fields

* fix: propagate updates to all rundowns

* refactor: client rundown metadata (#1728)

* generate metadata in the hook

* move test

* ensure there is always a last element

* use for-loop

* update metadata in useEfect

* fully extract metadata generation

* use direct assignment

* cleanup

---------

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

* refactor: small imporvement and tests for coerce functions (#1752)

* refactor: small imporvement and tests for coerce functions

add test `coerceString`

add test `coerceBoolean`

add test `coerceColour`

* remove old todo

* fix: consistent quick add behaviour

* refactor: create flat rundown with metadata

* fix: show add buttons on top

* feat: allow editing milestones

* refactor: style tweaks to rundown elements

refactor: milestones are full width

refactor: cuesheet header alignment

fix: editor styling in cuesheet

* refactor: virtualise table

* refactor: improve overscan (#1758)

* bump version to 4.0.0-alpha.5

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2025-09-03 15:50:20 +02:00
Carlos Valente 3c41b40c5f refactor: create fast tracks for cookie parsing 2025-08-18 22:00:16 +02:00
arc-alex 3b38756dda fix: use new token with scope 2025-08-18 22:00:16 +02:00
Carlos Valente 5a4494ba85 refactor: improve timers visibility 2025-08-18 22:00:16 +02:00
Carlos Valente 02ad852dd4 fix: external demo connects to hash 2025-08-18 22:00:16 +02:00
Carlos Valente 7ce1097826 fix: prevent reflow between entry editors 2025-08-18 22:00:16 +02:00
Carlos Valente 7ee0de5341 docs: improve development documentation 2025-08-18 22:00:16 +02:00
Carlos Valente 0db249871b bump version to 4.0.0-alpha.4 2025-08-18 22:00:16 +02:00
Carlos Valente 87df656470 docs: demo project is runtime data documentation 2025-08-18 06:16:53 +02:00
Carlos Valente 54f07c4330 refactor: simplify messages 2025-08-18 06:16:53 +02:00
Carlos Valente ae061cb69a bump version 4.0.0-alpha.3 2025-08-18 06:16:53 +02:00
google-labs-jules[bot] ba529def34 fix(client): use fresh state in row observer to prevent stale closures
The IntersectionObserver callback in rowObserver.ts was capturing the `addVisibleRow` and `removeVisibleRow` actions from the Zustand store only once, at the time of the observer's creation.

This created a potential for a stale closure bug, where the observer would call outdated action functions if the store's state or actions were ever re-initialized. This would lead to the application's state not being updated correctly, causing the bug where visible rows would not render their content.

This commit fixes the issue by calling `useVisibleRowsStore.getState()` inside the observer callback. This ensures that the latest, freshest versions of the action functions are always used, preventing the stale state bug.
2025-08-18 06:16:53 +02:00
Carlos Valente 210a85c463 fix: bug with default field url 2025-08-18 06:16:53 +02:00
Carlos Valente 3ad3ad088f ux: improve warning message in partial merge 2025-08-18 06:16:53 +02:00
Carlos Valente 617da68ab5 fix: storage key conflict across stages 2025-08-18 06:16:53 +02:00
Carlos Valente efceb930e5 refactor: improve navigation and settings locks 2025-08-18 06:16:53 +02:00
Carlos Valente 836fa52757 fix: small style tweaks and bug fixes
fix: remove interactive styles from display elements

fix: automation form reset on submit

fix: prevent empty image src
2025-08-18 06:16:53 +02:00
Carlos Valente 902edf4a85 fix: ensure filename sanitation in all routes 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 0e770655dc Fix: still counting to un-flaged event (#1726)
* create failing test

* ensuere all values a reset before calculations
2025-08-18 06:16:53 +02:00
arc-alex db922a67e2 chore: cleanup 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 048a9c96ea Refactor: split runtime state (#1725)
* refactor: runtime types

* refactor: runtimeState functions to use the split type

* refactor: RuntimeService to use the new split data

* refactor: use the split data in the UI

* update comments

* refactor: rename runtime to offset

* fix utils test
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen ed93cf313a fix: move playing event into group (#1723)
* fix: moving paying event into group

* add comment
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 97a0e17192 feat: abort partial load if there is a version missmatch (#1724)
* abort partial load on version missmatch

* reword comment

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 022a58e142 refactor: runtime service (#1722)
* refactor: trim type

* refactor: update runtime service data push logic

* keep using just deep

* reword didDependencyUpdate

* remove dulicate
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen e0149c1cbf Invert overtime (#1715)
* refactor: invert offset calculation

* chore: update tests

* chore: flip offset in UI

* fix seconds display on group target duration

* update comment

* fix rebase
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 4665f9c2f7 fix: partial load (#1720) 2025-08-18 06:16:53 +02:00
Carlos Valente 9ab6b30aea refactor: small code improvements 2025-08-18 06:16:53 +02:00
Carlos Valente e5d2457717 chore: rename blocks to groups 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 486d89ecf4 refactor: remove finishedAt from runtime data (#1716) 2025-08-18 06:16:53 +02:00
Carlos Valente 94b1f63c7c refactor: remove onAir 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 0299463f62 fix: ensure the restore point is the first thing loaded (#1713) 2025-08-18 06:16:53 +02:00
Carlos Valente f7ccb6bb51 chore: upgrade packages 2025-08-18 06:16:53 +02:00
Carlos Valente 6c6f5c2c0f feat: cuesheet sharing
feat: locked presets

refactor: simplify locked param

refactor: create share links
2025-08-18 06:16:53 +02:00
Carlos Valente 1695b4dc68 refactor: add presets to refetch list 2025-08-18 06:16:53 +02:00
Carlos Valente 1ed08df198 refactor: extract project info data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente c300fc18e1 refactor: extract timeline data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente bea7e2bb2b refactor: extract studio data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente ad7f658f15 refactor: extract backstage data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente f23adfa45c refactor: extract countdown data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente 20ee391525 refactor: extract timer data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente 78184b23b7 refactor: simplify routing 2025-08-18 06:16:53 +02:00
Carlos Valente e204f671be refactor: small code improvements
refactor: remove unused code

refactor: extract loader component

refactor: prevent keyword collision

refactor: cleanup hook pending states
2025-08-18 06:16:53 +02:00
Carlos Valente 8c90f5043f chore: upgrade react router 2025-08-18 06:16:53 +02:00
Carlos Valente 7a322406da chore: directory restructure 2025-08-18 06:16:53 +02:00
Carlos Valente 307f0ffd34 feat: expose presets in view options 2025-08-18 06:16:53 +02:00
Carlos Valente 5f8a5c4995 refactor: small style tweaks 2025-08-18 06:16:53 +02:00
Carlos Valente 3747dbc1a8 refactor: style and ux tweaks 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 9ebb3decdb V3 project migration (#1652)
* migrate settings

* migrate viewsettings

* migrate url preset

* migrate project data

* migrate custom fields

* migrate automations

* migrate rundown

* lint

* return custom field translation table

* check regex

* new url type

* refactor

* migrate old OSC subscriptions

* migrate old http subscriptions

* update comments

* migrate the whole db

* remove automation logging unlis there is an error

* return a new object

* refactor: copy currup is only used in one place

* make a copy of original migrated file

* refactor: ensure image flder is part of project service init

* add entrys to groups

* small cleanup

* just drop incorrect custom fields in the default data parser
2025-08-07 15:48:58 +02:00
Alex Christoffer Rasmussen 35347e8d63 Feat: Time until group and flag (#1708)
* refactor: split event data and state data

* refactor: calculate the start time insted of until

* cleanup

* send next flag expected start from server

* refactor: render

* fix test

* use same icon

* refactor: group duration

* Optimize (#1711)

* refactor: consolidate block, flag and end loading and expected times

* work on test

* cal end value

* lint

* fix test

* remove todo
2025-08-06 21:55:28 +02:00
Carlos Valente e37d2ce50f refactor: tweaks to timeline styles 2025-08-06 21:40:19 +02:00
Carlos Valente 49e923026e feat: expose planned start in operator 2025-08-06 21:40:19 +02:00
Carlos Valente f1a8db9649 feat: timer overview shows waiting timer 2025-08-06 21:40:19 +02:00
Carlos Valente dddeefd544 chore: rename projected > expected 2025-08-06 21:40:19 +02:00
Carlos Valente b4aaa8966f fix: insert into group 2025-08-06 21:40:19 +02:00
Carlos Valente ad9db4edd3 chore: add demo project 2025-08-06 21:40:19 +02:00
Carlos Valente fef9ed4fcc chore: update icon assets 2025-08-06 21:40:19 +02:00
Carlos Valente 9ec47eda6e feat: expose block target duration in header 2025-08-06 21:40:19 +02:00
Alex Christoffer Rasmussen 32edf3404c Fix tag ws api (#1709)
* fix: ws api should also reten tag and not type

* fix: when starting by cue try finding the next first then check before
2025-08-06 21:40:19 +02:00
Alex Christoffer Rasmussen bcf80f9d77 chore: rename offset to offsetAbs and offsetRel (#1706) 2025-08-06 21:40:19 +02:00
Carlos Valente df137402cf bump version to 4.0.0-alpha.2 2025-08-06 21:40:19 +02:00
Carlos Valente daddb7f7b7 fix: open group when selecting from finder 2025-08-06 21:40:19 +02:00
Carlos Valente 82e8b9d66a fix: cleanup behaviour for rundown add buttons 2025-08-06 21:40:19 +02:00
Carlos Valente 765d5d97cf refactor: readonly inputs in cuesheet 2025-08-06 21:40:19 +02:00
Carlos Valente ed4d3ba265 refactor: remove toggle link context menu action 2025-08-06 21:40:19 +02:00
Carlos Valente a358ec0f17 refactor: small style tweaks
refactor: consistent font size on inputs

refactor: style tweaks for block spacing

refactor: improve skipped styles

fix: improve responsiveness in extracted rundown

refactor: style tweaks to overview

refactor: simplify group target duration
2025-08-06 21:40:19 +02:00
Carlos Valente 524721002a fix: prevent nesting groups 2025-08-06 21:40:19 +02:00
Carlos Valente 9ab54edbf5 refactor: small code cleanups
- remove compiler directives
2025-08-06 21:40:19 +02:00
Carlos Valente 8a7f8c8ebc refactor: small style tweaks
- cursor styles on dnd
2025-08-06 21:40:19 +02:00
Carlos Valente 3ce539af2a fix: rendering if countdown in safari 2025-08-06 21:40:19 +02:00
387 changed files with 12360 additions and 7900 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
# Ignore build folders # Ignore build folders
node_modules node_modules
dist **/node_modules
**/dist
# Ignore default volumes created by running docker compose up # Ignore default volumes created by running docker compose up
ontime-db ontime-db
+4
View File
@@ -34,6 +34,10 @@ e2e/tests/fixtures/tmp/*
build/ build/
dist/ dist/
# bundled assets
translations.json
override.css
# working stuff # working stuff
**/TODO.md **/TODO.md
+23 -16
View File
@@ -20,19 +20,20 @@ development.
Locally, we would need to run both the React client and the node.js server in development mode Locally, we would need to run both the React client and the node.js server in development mode
From the project root, run the following commands From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i` - __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev` - __Run dev mode__ by running `pnpm turbo dev`
### Debugging backend ### Debugging backend
To debug backend code in Node.js: The previous command will start the development servers for both the client, server and electron applications.
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories. We do that by creating two terminals an running
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server - __Run the React UI__ by running `pnpm turbo dev --filter=ontime-ui`
applications. - __Run the nodejs server__ by running `pnpm turbo dev --filter=ontime-server`
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
running `pnpm dev:inspect`. - 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`.
## TESTING ## TESTING
@@ -45,7 +46,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils) Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running `turbo run test:pipeline` from the project root. You can run unit tests by running `pnpm turbo test:pipeline` from the project root.
This will run all tests and close test runner. 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 Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
@@ -76,13 +77,13 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i` - __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo run build:electron` - __Build the UI and server__ by running `pnpm turbo run build:electron`
- __Create the package__ by running `turbo run dist-win`, `turbo run dist-mac` or `turbo run dist-linux` - __Create the package__ by running `pnpm turbo run dist-win`, `pnpm turbo run dist-mac` or `pnpm turbo run dist-linux`
The build distribution assets will be at `.apps/electron/dist` 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. Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `turbo run dist-mac:local` command to build a MacOS distribution locally. Use the `pnpm turbo run dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
## DOCKER ## DOCKER
@@ -99,9 +100,15 @@ Other useful commands
- __List running processes__ by running `docker ps` - __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>` - __Kill running process__ by running `docker kill <process-id>`
## General Info ## CONTRIBUTION GUIDELINES
# APP Building If you want to propose changes to the codebase, please reach out before opening a Pull Request.
We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron. For new PRs, please follow the following checklist:
Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present. * [ ] 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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.0.0-alpha.1", "version": "4.0.0-beta.3",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+22 -22
View File
@@ -1,37 +1,37 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.0.0-alpha.1", "version": "4.0.0-beta.3",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui-components/react": "1.0.0-beta.1", "@base-ui-components/react": "1.0.0-beta.3",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1", "@fontsource/open-sans": "^5.2.6",
"@fontsource/open-sans": "^5.0.28", "@mantine/hooks": "^8.2.8",
"@mantine/hooks": "^8.1.2", "@sentry/react": "^10.2.0",
"@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.85.9",
"@tanstack/react-query-devtools": "^5.62.7", "@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.9.0", "axios": "^1.11.0",
"babel-plugin-react-compiler": "19.1.0-rc.2", "babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.6.0",
"prismjs": "^1.29.0", "prismjs": "^1.30.0",
"react": "^19.1.0", "react": "^19.1.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^19.1.0", "react-dom": "^19.1.1",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1", "react-hook-form": "^7.62.0",
"react-icons": "5.4.0", "react-icons": "5.5.0",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.18",
"react-router-dom": "^6.3.0", "react-router": "^7.8.2",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"web-vitals": "^3.1.1", "react-virtuoso": "^4.14.0",
"zustand": "^5.0.3" "web-vitals": "^5.1.0",
"zustand": "^5.0.8"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -64,8 +64,8 @@
"@sentry/vite-plugin": "^2.16.1", "@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4", "@tanstack/eslint-plugin-query": "^5.8.4",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "^19.1.8", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1", "@vitejs/plugin-react": "4.5.1",
+1 -1
View File
@@ -1,4 +1,4 @@
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router';
import { Tooltip } from '@base-ui-components/react/tooltip'; import { Tooltip } from '@base-ui-components/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+156 -53
View File
@@ -1,38 +1,34 @@
import React from 'react'; import { ComponentType, lazy, Suspense, useEffect, useMemo } from 'react';
import { Navigate, Route } from 'react-router-dom'; import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { PresetContext } from './common/context/PresetContext';
import { useClientPath } from './common/hooks/useClientPath'; import { useClientPath } from './common/hooks/useClientPath';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { getRouteFromPreset } from './common/utils/urlPresets';
import Log from './features/log/Log'; import Log from './features/log/Log';
import withPreset from './features/PresetWrapper'; import Loader from './views/common/loader/Loader';
import withData from './features/viewers/ViewWrapper'; import NotFound from './views/common/not-found/NotFound';
import ViewLoader from './views/ViewLoader'; import ViewLoader from './views/ViewLoader';
import { getIsNavigationLocked, sessionScope } from './externals';
import { initializeSentry } from './sentry.config'; import { initializeSentry } from './sentry.config';
const Editor = React.lazy(() => import('./views/editor/ProtectedEditor')); const Timer = lazy(() => import('./views/timer/Timer'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet')); const Countdown = lazy(() => import('./views/countdown/Countdown'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport')); const Backstage = lazy(() => import('./views/backstage/Backstage'));
const StudioClock = lazy(() => import('./views/studio/Studio'));
const Timeline = lazy(() => import('./views/timeline/TimelinePage'));
const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo'));
const TimerView = React.lazy(() => import('./views/timer/Timer')); const Editor = lazy(() => import('./views/editor/ProtectedEditor'));
const Countdown = React.lazy(() => import('./views/countdown/Countdown')); const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/OperatorExport'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage')); const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage')); const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const StudioClock = React.lazy(() => import('./views/studio/Studio')); const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo')); const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
const STimer = withPreset(withData(TimerView));
const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage));
const SProjectInfo = withPreset(ProjectInfo); // NOTE: ProjectInfo does not use the viewWrapper since it has no options
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const PCuesheet = withPreset(Cuesheet);
const POperator = withPreset(Operator);
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
// Initialize Sentry with our configuration // Initialize Sentry with our configuration
const SentryRouter = initializeSentry(); const SentryRouter = initializeSentry();
@@ -42,73 +38,77 @@ export default function AppRouter() {
useClientPath(); useClientPath();
return ( return (
<React.Suspense fallback={null}> <Suspense fallback={<Loader />}>
<SentryRouter> <SentryRouter>
<Route path='/' element={<Navigate to='/timer' />} /> <Route path='/' element={<Navigate to='/timer' />} />
<Route <Route
path='/timer' path='timer'
element={ element={
<ViewLoader> <ViewLoader>
<STimer /> <ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Timer />
</ViewLoader> </ViewLoader>
} }
/> />
<Route <Route
path='/countdown' path='countdown'
element={ element={
<ViewLoader> <ViewLoader>
<SCountdown /> <ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Countdown />
</ViewLoader> </ViewLoader>
} }
/> />
<Route <Route
path='/backstage' path='backstage'
element={ element={
<ViewLoader> <ViewLoader>
<SBackstage /> <ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Backstage />
</ViewLoader> </ViewLoader>
} }
/> />
<Route <Route
path='/studio' path='studio'
element={ element={
<ViewLoader> <ViewLoader>
<SStudio /> <ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<StudioClock />
</ViewLoader> </ViewLoader>
} }
/> />
<Route <Route
path='/timeline' path='timeline'
element={ element={
<ViewLoader> <ViewLoader>
<STimeline /> <ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Timeline />
</ViewLoader> </ViewLoader>
} }
/> />
<Route <Route
path='/info' path='info'
element={ element={
<ViewLoader> <ViewLoader>
<SProjectInfo /> <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />
<ProjectInfo />
</ViewLoader> </ViewLoader>
} }
/> />
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='editor' element={<Editor />} />
<Route path='/cuesheet' element={<PCuesheet />} /> <Route path='cuesheet' element={<Cuesheet />} />
<Route <Route
path='/op' path='op'
element={ element={
<ViewLoader> <ViewLoader>
<POperator /> <Operator />
</ViewLoader> </ViewLoader>
} }
/> />
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
path='/rundown' path='rundown'
element={ element={
<EditorFeatureWrapper> <EditorFeatureWrapper>
<RundownPanel /> <RundownPanel />
@@ -116,7 +116,7 @@ export default function AppRouter() {
} }
/> />
<Route <Route
path='/timercontrol' path='timercontrol'
element={ element={
<EditorFeatureWrapper> <EditorFeatureWrapper>
<TimerControl /> <TimerControl />
@@ -124,7 +124,7 @@ export default function AppRouter() {
} }
/> />
<Route <Route
path='/messagecontrol' path='messagecontrol'
element={ element={
<EditorFeatureWrapper> <EditorFeatureWrapper>
<MessageControl /> <MessageControl />
@@ -132,16 +132,119 @@ export default function AppRouter() {
} }
/> />
<Route <Route
path='/log' path='log'
element={ element={
<EditorFeatureWrapper> <EditorFeatureWrapper>
<Log /> <Log />
</EditorFeatureWrapper> </EditorFeatureWrapper>
} }
/> />
{/*/!* Send to default if nothing found *!/*/} {/**
<Route path='*' element={<STimer />} /> * If the views are prefixed with the "preset" path, we are in a locked preset
* Locked presets do not expose their parameters
*/}
<Route path='preset/:alias' element={<PresetView />} />
{/**
* If we havent matched any views or presets, we may be in an unlocked preset
* Unlocked presets are unwrapped to expose their target and parameters
*/}
<Route path='*' element={<RedirectPreset />} />
</SentryRouter> </SentryRouter>
</React.Suspense> </Suspense>
);
}
const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
[OntimeView.Cuesheet]: Cuesheet,
[OntimeView.Operator]: Operator,
[OntimeView.Timer]: Timer,
[OntimeView.Backstage]: Backstage,
[OntimeView.Timeline]: Timeline,
[OntimeView.StudioClock]: StudioClock,
[OntimeView.Countdown]: Countdown,
[OntimeView.ProjectInfo]: ProjectInfo,
};
/**
* This view will mask a configured canonical route
* and inject the preset search parameters to context
* User are not able to configure the parameters locked presets
*/
function PresetView() {
const { data, status } = useUrlPresets();
const { alias } = useParams();
const preset: URLPreset | undefined = useMemo(() => {
if (status === 'pending' || !alias) return;
return data.find((p) => p.alias === alias && p.enabled);
}, [data, status, alias]);
if (status === 'pending') {
return <Loader />;
}
/**
* We need to check the session scope to determine if the user can navigate
* If the user has a global scope, they can navigate freely
* Otherwise, they are locked to the preset view
*/
const showNav = sessionScope === 'rw';
/**
* If we are in a preset path but cannot find a preset, we will need to show a not found page
* This can happen if the preset was deleted or disabled
*/
if (!preset) {
return (
<>
<ViewNavigationMenu isNavigationLocked={!showNav} suppressSettings />
<NotFound />
</>
);
}
/**
* Locked presets do not allow configuration changes
* Whether the user can navigate is determined by the locked param
*/
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return (
<PresetContext value={preset}>
{preset.target !== OntimeView.Cuesheet && (
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
)}
{Component ? <Component /> : <NotFound />}
</PresetContext>
);
}
function RedirectPreset() {
const { data, status } = useUrlPresets();
const navigate = useNavigate();
const location = useLocation();
// checks if we are in a preset path and resolves a destination URL
const destination = useMemo(() => {
if (status === 'pending') return null;
return getRouteFromPreset(location, data);
}, [data, location, status]);
// if we have a destination, we will navigate to it
useEffect(() => {
if (destination) {
navigate(`/${destination}`, { replace: true });
}
}, [destination, navigate]);
if (status === 'pending') {
return <Loader />;
}
return (
<>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
<NotFound />
</>
); );
} }
+22 -1
View File
@@ -1,6 +1,9 @@
import axios from 'axios'; import axios from 'axios';
import { TranslationObject } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
const assetsPath = `${apiEntryUrl}/assets`; const assetsPath = `${apiEntryUrl}/assets`;
@@ -28,3 +31,21 @@ export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`); const res = await axios.post(`${assetsPath}/css/restore`);
return res.data; return res.data;
} }
/**
* HTTP request to get user translation
*/
export async function getUserTranslation(): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL);
return res.data;
}
/**
* HTTP request to post user translation
*/
export async function postUserTranslation(translation: TranslationObject): Promise<void> {
await axios.post(`${assetsPath}/translations`, {
translation,
});
await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
}
+3
View File
@@ -15,12 +15,15 @@ export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report']; export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
// API URLs // API URLs
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user'; const userAssetsPath = 'user';
const cssOverridePath = 'styles/override.css'; const cssOverridePath = 'styles/override.css';
const customTranslationsPath = 'translations/translations.json';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
+62 -44
View File
@@ -1,17 +1,11 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
EntryId,
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`; const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns =========================
/** /**
* HTTP request to fetch a list of existing rundowns * HTTP request to fetch a list of existing rundowns
@@ -22,37 +16,64 @@ export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
} }
/** /**
* HTTP request to fetch all events * HTTP request to fetch all entries in the currently loaded rundown
*/ */
export async function fetchCurrentRundown(): Promise<Rundown> { export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`); const res = await axios.get(`${rundownPath}/current`);
return res.data; return res.data;
} }
/**
* HTTP request to switch the currently loaded rundown
*/
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${id}/load`);
}
/**
* HTTP request to create a new rundown
*/
export async function createRundown(title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(rundownPath, { title });
}
/**
* HTTP request to delete a rundown
*/
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${id}`);
}
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
/** /**
* HTTP request to post new entry * HTTP request to post new entry
*/ */
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> { export async function postAddEntry(
return axios.post(rundownPath, data); rundownId: string,
data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
} }
/** /**
* HTTP request to edit an entry * HTTP request to edit an entry
*/ */
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> { export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(rundownPath, data); return axios.put(`${rundownPath}/${rundownId}/entry`, data);
} }
type BatchEditEntry = { export type BatchEditEntry = {
data: Partial<OntimeEvent>; data: Partial<OntimeEvent>;
ids: string[]; ids: EntryId[];
}; };
/** /**
* HTTP request to edit multiple events * HTTP request to edit multiple events
*/ */
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> { export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/batch`, data); return axios.put(`${rundownPath}/${rundownId}/batch`, data);
} }
export type ReorderEntry = { export type ReorderEntry = {
@@ -64,60 +85,57 @@ export type ReorderEntry = {
/** /**
* HTTP request to reorder an entry * HTTP request to reorder an entry
*/ */
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> { export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/reorder`, data); return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
} }
export type SwapEntry = {
from: string;
to: string;
};
/** /**
* HTTP request to swap two events * HTTP request to swap two events
*/ */
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> { export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/swap`, data); return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
} }
/** /**
* HTTP request to request application of delay * HTTP request to request application of delay
*/ */
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`); return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
} }
/** /**
* HTTP request for cloning an entry * HTTP request for cloning an entry
*/ */
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> { export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/clone/${entryId}`); return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
} }
/** /**
* HTTP request for dissolving of a block * HTTP request for grouping a list of entries into a group
*/ */
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/ungroup/${blockId}`); return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
} }
/** /**
* HTTP request for grouping a list of entries into a block * HTTP request for dissolving of a group
*/ */
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> { export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/group`, { ids: entryIds }); return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
} }
/** /**
* HTTP request to delete entries * HTTP request to delete entries of a given rundown
*/ */
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> { export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.delete(rundownPath, { data: { ids: entryIds } }); return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
} }
/** /**
* HTTP request to delete all events * HTTP request to delete all entries of a given rundown
*/ */
export async function requestDeleteAll(): Promise<AxiosResponse<MessageResponse>> { export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/all`); return axios.delete(`${rundownPath}/${rundownId}/all`);
} }
// #endregion operations on rundown entries =======================
+3 -8
View File
@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { GetInfo } from 'ontime-types'; import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -16,12 +16,7 @@ export async function getInfo(): Promise<GetInfo> {
/** /**
* HTTP request to get a pre-authenticated URL * HTTP request to get a pre-authenticated URL
*/ */
export async function generateUrl( export async function generateUrl(options: LinkOptions & { baseUrl: string; path: string }): Promise<string> {
baseUrl: string, const res = await axios.post(`${sessionPath}/url`, options);
path: string,
lock: boolean,
authenticate: boolean,
): Promise<string> {
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
return res.data.url; return res.data.url;
} }
@@ -33,7 +33,13 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
if (newPath === '/' || newPath === currentPath) { if (newPath === '/' || newPath === currentPath) {
return; return;
} }
setRedirect({ target: id, redirect: newPath });
if (newPath.startsWith('preset-')) {
setRedirect({ target: id, redirect: newPath.slice(7) });
} else {
setRedirect({ target: id, redirect: newPath });
}
onClose(); onClose();
}; };
@@ -45,7 +51,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
label: view.label, label: view.label,
})), })),
...enabledPresets.map((preset) => ({ ...enabledPresets.map((preset) => ({
value: preset.search, value: `preset-${preset.alias}`,
label: `URL Preset: ${preset.alias}`, label: `URL Preset: ${preset.alias}`,
})), })),
]; ];
@@ -61,32 +67,11 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<> <>
<Info> <Info>
Remotely redirect the client to a different URL. <br /> 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 />
<br /> <br />
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink> <AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info> </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}> <div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span> <span className={style.label}>Enter custom path</span>
<label className={style.textEntry}> <label className={style.textEntry}>
@@ -100,9 +85,34 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
className={style.redirect} className={style.redirect}
onClick={() => handleRedirect(path)} onClick={() => handleRedirect(path)}
> >
Redirect <IoArrowForward /> Redirect
<IoArrowForward />
</Button> </Button>
</div> </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) => 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,4 +1,4 @@
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5'; import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5'; import { IoCopy } from 'react-icons/io5';
@@ -24,32 +24,29 @@ export default function CopyTag({
onClick, onClick,
}: PropsWithChildren<CopyTagProps>) { }: PropsWithChildren<CopyTagProps>) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = () => { const handleClick = () => {
copyToClipboard(copyValue); copyToClipboard(copyValue);
setCopied(true); setCopied(true);
// reset copied state // reset copied state
setTimeout(() => setCopied(false), 1000); if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
}; };
return ( return (
<div className={style.copytag}> <div className={style.copytag}>
{onClick !== undefined ? ( {onClick !== undefined ? (
<Button className={style.action} size={size} tabIndex={-1} onClick={onClick} disabled={disabled}> <Button className={style.action} size={size} onClick={onClick} disabled={disabled}>
{children} {children}
</Button> </Button>
) : ( ) : (
<div className={cx([style.label, style[size]])}>{children}</div> <div className={cx([style.label, style[size]])}>{children}</div>
)} )}
<IconButton <IconButton className={style.copy} variant='primary' size={size} onClick={handleClick} disabled={disabled}>
className={style.copy}
variant='primary'
size={size}
tabIndex={-1}
onClick={handleClick}
disabled={disabled}
>
{copied ? <IoCheckmark /> : <IoCopy />} {copied ? <IoCheckmark /> : <IoCopy />}
</IconButton> </IconButton>
</div> </div>
@@ -13,6 +13,7 @@
border-radius: 3px; border-radius: 3px;
box-shadow: $box-shadow-l1; box-shadow: $box-shadow-l1;
border: 1px solid $gray-1100; border: 1px solid $gray-1100;
outline: none;
} }
.backdrop { .backdrop {
@@ -1,12 +1,13 @@
.list { .list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 0.5rem; gap: 0.5rem;
} }
.swatch { .swatch {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
aspect-ratio: 1;
border-radius: 99px; border-radius: 99px;
border: 2px solid $gray-1200; border: 2px solid $gray-1200;
color: $ui-white; color: $ui-white;
@@ -31,11 +31,7 @@
} }
&[data-checked] { &[data-checked] {
background-color: $gray-1200; background-color: $gray-700;
&:hover {
border-color: $gray-1000;
}
} }
&:focus-visible { &:focus-visible {
@@ -57,6 +53,6 @@
border-radius: 100%; border-radius: 100%;
width: 0.5em; width: 0.5em;
height: 0.5em; height: 0.5em;
background-color: $blue-500; background-color: $ui-white;
} }
} }
@@ -10,7 +10,12 @@
padding-inline: 0.5em; padding-inline: 0.5em;
outline: none; outline: none;
&:hover:not(:disabled) { &::placeholder {
color: $gray-500;
letter-spacing: 0;
}
&:hover:not(:disabled):not(:read-only) {
background-color: $gray-1100; background-color: $gray-1100;
} }
@@ -25,9 +30,8 @@
cursor: not-allowed; cursor: not-allowed;
} }
&::placeholder { &:read-only::placeholder {
color: $gray-500; opacity: 0;
letter-spacing: 0;
} }
} }
@@ -9,9 +9,13 @@
border-radius: $component-border-radius-md; border-radius: $component-border-radius-md;
border: 1px solid transparent; border: 1px solid transparent;
padding-inline: 0.5em;
outline: none; outline: none;
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
&:hover:not(:disabled) { &:hover:not(:disabled) {
background-color: $gray-1100; background-color: $gray-1100;
} }
@@ -26,21 +30,21 @@
cursor: not-allowed; cursor: not-allowed;
} }
&::placeholder { &:read-only::placeholder {
color: $gray-500; opacity: 0;
letter-spacing: 0;
} }
} }
.subtle { .subtle {
background-color: $gray-1200; background-color: $gray-1200;
padding-top: 0.5em; padding-inline: 0.5em;
padding-top: 0.25em;
} }
.ghosted { .ghosted {
background-color: transparent; background-color: transparent;
padding: 0; padding: 0;
padding-top: 0.5em; padding-top: 0.25em;
} }
.fluid { .fluid {
@@ -9,7 +9,7 @@ import style from './TimeInput.module.scss';
interface NullableTimeInputProps<T extends string> { interface NullableTimeInputProps<T extends string> {
id?: T; id?: T;
name: T; name: T;
submitHandler: (field: T, value: string) => void; submitHandler: (field: T, value: number) => void;
time?: number | null; time?: number | null;
emptyDisplay: string; emptyDisplay: string;
placeholder?: string; placeholder?: string;
@@ -71,7 +71,7 @@ export default function NullableTimeInput<T extends string>({
return false; return false;
} }
submitHandler(name, newValue); submitHandler(name, valueInMillis);
return true; return true;
}, },
[name, submitHandler, time], [name, submitHandler, time],
@@ -1,5 +1,5 @@
import { type PropsWithChildren } from 'react'; import { type PropsWithChildren } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import style from './AppLink.module.scss'; import style from './AppLink.module.scss';
@@ -7,7 +7,7 @@
padding-inline: 1rem; padding-inline: 1rem;
min-width: min(680px, 90vw); min-width: min(680px, 90vw);
min-height: min(200px, 10vh); min-height: min(200px, 10vh);
max-width: min(800px, 90vw); max-width: min(900px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -1,6 +1,6 @@
import { memo } from 'react'; import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui-components/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks'; import { useDisclosure, useFullscreen } from '@mantine/hooks';
@@ -1,26 +1,29 @@
import { memo } from 'react'; import { memo } from 'react';
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation'; import FloatingNavigation from './floating-navigation/FloatingNavigation';
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon'; import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
import useViewEditor from './useViewEditor';
interface ViewNavigationMenuProps { interface ViewNavigationMenuProps {
isLockable?: boolean; /** prevent navigation */
isNavigationLocked?: boolean;
/** prevent showing settings */
suppressSettings?: boolean; suppressSettings?: boolean;
} }
export default memo(ViewNavigationMenu); export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable }); const { open: showEditFormDrawer } = useViewParamsEditorStore();
useHotkeys([ useHotkeys([
[ [
'Space', 'Space',
() => { () => {
if (isViewLocked) return; if (isNavigationLocked) return;
menuHandler.toggle(); menuHandler.toggle();
}, },
{ preventDefault: true }, { preventDefault: true },
@@ -28,24 +31,24 @@ function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenu
[ [
'mod + ,', 'mod + ,',
() => { () => {
if (isViewLocked || suppressSettings) return; if (suppressSettings) return;
showEditFormDrawer(); showEditFormDrawer();
}, },
{ preventDefault: true }, { preventDefault: true },
], ],
]); ]);
if (isViewLocked) { if (isNavigationLocked && suppressSettings) {
return <ViewLockedIcon />; return <ViewLockedIcon />;
} }
return ( return (
<> <>
<FloatingNavigation <FloatingNavigation
toggleMenu={menuHandler.toggle} toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : () => showEditFormDrawer()} toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
/> />
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} /> {!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</> </>
); );
} }
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoArrowUp } from 'react-icons/io5'; import { IoArrowUp } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { useElectronEvent } from '../../../hooks/useElectronEvent'; import { useElectronEvent } from '../../../hooks/useElectronEvent';
import { handleLinks } from '../../../utils/linkUtils'; import { handleLinks } from '../../../utils/linkUtils';
@@ -1,5 +1,5 @@
import { IoLockClosedOutline } from 'react-icons/io5'; import { IoLockClosedOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { useIsSmallDevice } from '../../../hooks/useIsSmallDevice'; import { useIsSmallDevice } from '../../../hooks/useIsSmallDevice';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
@@ -14,7 +14,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
row-gap: 1rem; row-gap: 1rem;
padding: 0.5em; padding-top: 0.75rem;
padding-left: 0.5em;
position: fixed; position: fixed;
left: 0; left: 0;
@@ -8,7 +8,7 @@ import IconButton from '../../buttons/IconButton';
import style from './FloatingNavigation.module.scss'; import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps { interface FloatingNavigationProps {
toggleMenu: () => void; toggleMenu?: () => void;
toggleSettings?: () => void; toggleSettings?: () => void;
} }
@@ -20,15 +20,17 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings }: Float
id='fadeable-navigation' id='fadeable-navigation'
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])} className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
> >
<IconButton {toggleMenu && (
variant='subtle-white' <IconButton
size='xlarge' variant='subtle-white'
onClick={toggleMenu} size='xlarge'
aria-label='toggle menu' onClick={toggleMenu}
data-testid='navigation__toggle-menu' aria-label='toggle menu'
> data-testid='navigation__toggle-menu'
<IoApps /> >
</IconButton> <IoApps />
</IconButton>
)}
{toggleSettings && ( {toggleSettings && (
<IconButton <IconButton
variant='subtle-white' variant='subtle-white'
@@ -5,12 +5,17 @@
} }
.interfaces { .interfaces {
padding: 0.5rem 1rem; padding: 0.5rem 0.5rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
} }
.interfaceCopy {
display: flex;
align-items: center;
}
.goIcon { .goIcon {
@include rotate-fourty-five; @include rotate-fourty-five;
margin-left: 0.25rem; margin-left: 0.25rem;
@@ -30,8 +30,10 @@ export default function OtherAddresses({ currentLocation }: OtherAddressesProps)
const address = linkToOtherHost(nif.address, currentLocation); const address = linkToOtherHost(nif.address, currentLocation);
return ( return (
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)}> <CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
{nif.address} <IoArrowUp className={style.goIcon} /> <span className={style.interfaceCopy}>
{nif.address} <IoArrowUp className={style.goIcon} />
</span>
</CopyTag> </CopyTag>
); );
})} })}
@@ -1,23 +0,0 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
interface EditorVisibilityOptions {
isLockable?: boolean;
}
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
const [searchParams] = useSearchParams();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const isViewLocked = useMemo(() => {
if (!isLockable) {
return false;
}
return isStringBoolean(searchParams.get('locked'));
}, [isLockable, searchParams]);
return { showEditFormDrawer, isViewLocked };
}
@@ -11,9 +11,11 @@
} }
.pin { .pin {
margin-top: 0.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 4px;
input { input {
font-size: 4rem; font-size: 4rem;
@@ -1,9 +1,15 @@
.radioGroup { .radioGroup {
display: flex; display: flex;
gap: 0.25rem;
color: $gray-900; color: $gray-900;
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
color: $ui-white; color: $ui-white;
&[data-disabled] {
.item {
opacity: $opacity-disabled;
cursor: not-allowed;
}
}
} }
.horizontal { .horizontal {
@@ -32,8 +38,8 @@
.radio { .radio {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
width: 1rem; width: 16px; // use absolute units to avoid subpixel rendering issues
height: 1rem; height: 16px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 100%; border-radius: 100%;
@@ -46,10 +52,7 @@
} }
&[data-checked] { &[data-checked] {
background-color: $ui-white; background-color: $blue-700;
&:hover {
border-color: $gray-1000;
}
} }
&:focus-visible { &:focus-visible {
@@ -69,8 +72,9 @@
&::before { &::before {
content: ''; content: '';
border-radius: 100%; border-radius: 100%;
width: 0.5rem; width: 6px; // half of the radio size
height: 0.5rem; height: 6px;
background-color: $gray-1200; aspect-ratio: 1;
background-color: $ui-white;
} }
} }
@@ -14,7 +14,6 @@
border: 1px solid transparent; border: 1px solid transparent;
padding-inline: 0.5rem; padding-inline: 0.5rem;
font-size: calc(1rem - 2px);
white-space: nowrap; white-space: nowrap;
&:hover:not([data-disabled]) { &:hover:not([data-disabled]) {
@@ -57,7 +56,7 @@
} }
.popup { .popup {
font-size: calc(1rem - 2px); font-size: 1rem;
background-color: $gray-1200; background-color: $gray-1200;
box-sizing: border-box; box-sizing: border-box;
padding: 2px; padding: 2px;
@@ -9,7 +9,10 @@
} }
.text { .text {
display: block;
margin-inline: auto;;
font-weight: 600; font-weight: 600;
font-size: 2em; font-size: 2em;
max-width: 600px;
} }
} }
@@ -7,13 +7,13 @@ import style from './Empty.module.scss';
interface EmptyProps { interface EmptyProps {
text?: string; text?: string;
style?: CSSProperties; injectedStyles?: CSSProperties;
className?: string; className?: string;
} }
export default function Empty({ text, className, ...rest }: EmptyProps) { export default function Empty({ text, className, injectedStyles }: EmptyProps) {
return ( return (
<div className={cx([style.emptyContainer, className])} {...rest}> <div className={cx([style.emptyContainer, className])} style={injectedStyles}>
<EmptyImage className={style.empty} /> <EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>} {text && <span className={style.text}>{text}</span>}
</div> </div>
@@ -6,15 +6,13 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps { interface EmptyPageProps {
text?: string; text?: string;
style?: CSSProperties; injectedStyles?: CSSProperties;
} }
export default function EmptyPage(props: EmptyPageProps) { export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
const { text, ...rest } = props;
return ( return (
<div className={style.page}> <div className={style.page}>
<Empty text={text} {...rest} /> <Empty text={text} injectedStyles={injectedStyles} />
</div> </div>
); );
} }
@@ -5,6 +5,7 @@
.emptyCell { .emptyCell {
margin-inline: auto; margin-inline: auto;
text-align: center; text-align: center;
padding-top: 10vh;
} }
.empty { .empty {
@@ -22,6 +22,11 @@
outline: 2px solid $blue-500; outline: 2px solid $blue-500;
outline-offset: 2px; outline-offset: 2px;
} }
&[data-disabled] {
opacity: $opacity-disabled;
cursor: not-allowed;
}
} }
.medium { .medium {
@@ -41,7 +46,7 @@
} }
.thumb { .thumb {
aspect-ratio: 1 / 1; aspect-ratio: 1;
height: 100%; height: 100%;
border-radius: 99px; border-radius: 99px;
background-color: $ui-white; background-color: $ui-white;
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import SwatchPicker from '../input/colour-input/SwatchPicker'; import SwatchPicker from '../input/colour-input/SwatchPicker';
@@ -16,10 +16,13 @@ const ensureHex = (value: string) => {
return value; return value;
}; };
export default function InlineColourPicker(props: InlineColourPickerProps) { export default function InlineColourPicker({ name, value }: InlineColourPickerProps) {
const { name, value } = props;
const [colour, setColour] = useState(() => ensureHex(value)); const [colour, setColour] = useState(() => ensureHex(value));
useEffect(() => {
setColour(ensureHex(value));
}, [value]);
return ( return (
<div className={style.inline}> <div className={style.inline}>
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor /> <SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
@@ -1,10 +1,10 @@
import { useState } from 'react'; import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import Checkbox from '../checkbox/Checkbox'; import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import Select from '../select/Select'; import Select, { SelectOption } from '../select/Select';
import Switch from '../switch/Switch'; import Switch from '../switch/Switch';
import InlineColourPicker from './InlineColourPicker'; import InlineColourPicker from './InlineColourPicker';
@@ -35,15 +35,22 @@ export default function ParamInput({ paramField }: ParamInputProps) {
return <span className={style.empty}>No options available</span>; return <span className={style.empty}>No options available</span>;
} }
return <Select size='large' name={id} defaultValue={defaultOptionValue} options={paramField.values} />; return <ControlledSelect id={id} initialValue={defaultOptionValue} options={paramField.values} />;
} }
if (type === 'multi-option') { 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') { if (type === 'boolean') {
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) || defaultValue} />; return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) ?? defaultValue} />;
} }
if (type === 'number') { if (type === 'number') {
@@ -63,27 +70,28 @@ export default function ParamInput({ paramField }: ParamInputProps) {
} }
if (type === 'colour') { if (type === 'colour') {
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`; return <InlineColourPicker name={id} value={searchParams.get(id) ?? defaultValue} />;
return <InlineColourPicker name={id} value={currentvalue} />;
} }
const defaultStringValue = searchParams.get(id) ?? defaultValue; const defaultStringValue = searchParams.get(id) ?? defaultValue ?? '';
const { placeholder } = paramField; const { placeholder } = paramField;
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />; return <ControlledInput id={id} initialValue={defaultStringValue} placeholder={placeholder} />;
} }
interface EditFormMultiOptionProps { interface EditFormMultiOptionProps {
paramField: ParamField & { type: 'multi-option' }; paramField: ParamField & { type: 'multi-option' };
options: string[];
} }
function MultiOption({ paramField }: EditFormMultiOptionProps) { function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
const [searchParams] = useSearchParams(); const { id, values } = paramField;
const { id, values, defaultValue = [''] } = paramField; const [paramState, setParamState] = useState<string[]>(options);
const optionFromParams = searchParams.getAll(id); // synchronise options
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue); useEffect(() => {
setParamState(options);
}, [options]);
const toggleValue = (value: string, checked: boolean) => { const toggleValue = (value: string, checked: boolean) => {
if (checked) { if (checked) {
@@ -129,5 +137,52 @@ interface ControlledSwitchProps {
} }
function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) { function ControlledSwitch({ id, initialValue }: ControlledSwitchProps) {
const [checked, setChecked] = useState(initialValue); const [checked, setChecked] = useState(initialValue);
// synchronise checked state
useEffect(() => {
setChecked(initialValue);
}, [initialValue]);
return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />; return <Switch size='large' name={id} checked={checked} onCheckedChange={setChecked} />;
} }
interface ControlledSelectProps {
id: string;
initialValue?: string;
options: SelectOption[];
}
function ControlledSelect({ id, initialValue, options }: ControlledSelectProps) {
const [selected, setSelected] = useState(initialValue);
// synchronise selected state
useEffect(() => {
setSelected(initialValue);
}, [initialValue]);
return (
<Select size='large' name={id} options={options} value={selected} onValueChange={(value) => setSelected(value)} />
);
}
interface ControlledInputProps<T extends number | string> extends ComponentProps<typeof Input> {
id: string;
initialValue: T;
}
function ControlledInput<T extends number | string>({ id, initialValue, ...inputProps }: ControlledInputProps<T>) {
const [value, setValue] = useState(initialValue);
// synchronise selected state
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return (
<Input
height='large'
name={id}
value={value}
onChange={(event) => setValue(event.target.value as T)}
{...inputProps}
/>
);
}
@@ -1,7 +1,8 @@
import { FormEvent, memo } from 'react'; import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog'; import { Dialog } from '@base-ui-components/react/dialog';
import { OntimeView } from 'ontime-types';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
@@ -11,17 +12,18 @@ import Info from '../info/Info';
import { ViewOption } from './viewParams.types'; import { ViewOption } from './viewParams.types';
import { getURLSearchParamsFromObj } from './viewParams.utils'; import { getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store'; import { useViewParamsEditorStore } from './viewParamsEditor.store';
import { ViewParamsPresets } from './ViewParamsPresets';
import ViewParamsSection from './ViewParamsSection'; import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss'; import style from './ViewParamsEditor.module.scss';
interface EditFormDrawerProps { interface EditFormDrawerProps {
target: OntimeView;
viewOptions: ViewOption[]; viewOptions: ViewOption[];
} }
export default memo(ViewParamsEditor); export default memo(ViewParamsEditor);
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams(); const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
@@ -56,7 +58,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<Dialog.Title>Customise</Dialog.Title> <Dialog.Title>Customise</Dialog.Title>
<IconButton variant='subtle-white' size='large' onClick={handleClose}> <IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
@@ -64,6 +66,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
{viewSettings.overrideStyles && ( {viewSettings.overrideStyles && (
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info> <Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
)} )}
<ViewParamsPresets target={target} />
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}> <form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
{viewOptions.map((section) => ( {viewOptions.map((section) => (
<ViewParamsSection <ViewParamsSection
@@ -79,8 +82,14 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
<Button variant='subtle' size='large' onClick={resetParams} type='reset'> <Button variant='subtle' size='large' onClick={resetParams} type='reset'>
Reset to default Reset to default
</Button> </Button>
<Button variant='primary' size='large' form='edit-params-form' type='submit'> <Button
Save variant='primary'
size='large'
form='edit-params-form'
type='submit'
data-testid='apply-view-params'
>
Apply
</Button> </Button>
</div> </div>
</Dialog.Popup> </Dialog.Popup>
@@ -0,0 +1,25 @@
.presetSection {
background-color: $gray-1350;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem 0.5rem;
margin-bottom: 1rem;
max-height: 10rem;
overflow-y: auto;
scrollbar-gutter: stable;
}
.preset {
display: flex;
align-items: center;
gap: 0.5rem;
&.active {
color: $blue-500;
}
}
.presetActions {
margin-left: auto;
}
@@ -0,0 +1,47 @@
import { useSearchParams } from 'react-router';
import { OntimeView, URLPreset } from 'ontime-types';
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import style from './ViewParamsPresets.module.scss';
/**
* Shows a list of presets for the current view
*/
export function ViewParamsPresets({ target }: { target: OntimeView }) {
const { viewPresets } = useViewUrlPresets(target);
const [searchParams, setSearchParams] = useSearchParams();
const handleRecall = (preset: URLPreset) => {
const newSearch = new URLSearchParams(preset.search);
newSearch.set('alias', preset.alias);
setSearchParams(newSearch);
};
if (viewPresets.length === 0) {
return null;
}
return (
<div className={style.presetSection}>
{viewPresets.map((preset) => {
const active = searchParams.get('alias') === preset.alias;
return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
<div>{preset.alias}</div>
<Button
variant={active ? 'ghosted' : 'subtle-white'}
onClick={() => handleRecall(preset)}
disabled={active}
className={style.presetActions}
>
{active ? 'Applied' : 'Apply'}
</Button>
</div>
);
})}
</div>
);
}
@@ -36,11 +36,15 @@
} }
.closed { .closed {
transition: rotate $transition-time-feedback; transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 0deg; rotate: 0deg;
} }
.open { .open {
transition: rotate $transition-time-feedback; transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 180deg; rotate: 180deg;
} }
.hidden {
display: none;
}
@@ -1,4 +1,3 @@
import { memo } from 'react';
import { IoChevronDown } from 'react-icons/io5'; import { IoChevronDown } from 'react-icons/io5';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
@@ -16,8 +15,7 @@ interface ViewParamsSectionProps {
options: ParamField[]; options: ParamField[];
} }
export default memo(ViewParamsSection); export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const handleCollapse = () => { const handleCollapse = () => {
@@ -49,15 +47,11 @@ interface SectionContentsProps {
} }
function SectionContents({ options, collapsed }: SectionContentsProps) { function SectionContents({ options, collapsed }: SectionContentsProps) {
if (collapsed) {
return null;
}
return ( return (
<> <>
{options.map((option) => { {options.map((option) => {
return ( return (
<label key={option.title} className={style.label}> <label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
<span className={style.title}>{option.title}</span> <span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span> <span className={style.description}>{option.description}</span>
<ParamInput paramField={option} /> <ParamInput paramField={option} />
@@ -16,7 +16,7 @@ export type MultiselectOption = { value: string; label: string; colour: string }
type MultiOptionsField = { type MultiOptionsField = {
type: 'multi-option'; type: 'multi-option';
values: MultiselectOption[]; values: MultiselectOption[];
defaultValue?: string; defaultValue?: string[];
}; };
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string }; type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
@@ -56,13 +56,18 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
/** /**
* Creates data for a select element that displays project custom data * Creates data for a select element that displays project custom data
*/ */
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] { export function makeProjectDataOptions(
return projectData.custom.map((entry, index) => { projectData: ProjectData,
additionalOptions: SelectOption[] = [],
): SelectOption[] {
const generatedOptions = projectData.custom.map((entry, index) => {
return { return {
value: `${index}-${entry.title}`, value: `${index}-${entry.title}`,
label: entry.title, label: entry.title,
}; };
}); });
return [...additionalOptions, ...generatedOptions];
} }
type ViewParamsObj = { [key: string]: string | FormDataEntryValue }; type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
@@ -0,0 +1,4 @@
import { createContext } from 'react';
import { URLPreset } from 'ontime-types';
export const PresetContext = createContext<URLPreset | undefined>(undefined);
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { langEn } from 'ontime-types';
import { getUserTranslation } from '../../common/api/assets';
import { TRANSLATION } from '../../common/api/constants';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
export function useCustomTranslation() {
const { data, status, refetch } = useQuery({
queryKey: TRANSLATION,
queryFn: getUserTranslation,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? langEn, status, refetch };
}
@@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ProjectRundownsList } from 'ontime-types'; import { ProjectRundownsList } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants'; import { PROJECT_RUNDOWNS } from '../api/constants';
import { fetchProjectRundownList } from '../api/rundown'; import { createRundown, deleteRundown, fetchProjectRundownList, loadRundown } from '../api/rundown';
/** /**
* Project rundowns * Project rundowns
@@ -13,10 +13,43 @@ export function useProjectRundowns() {
queryKey: PROJECT_RUNDOWNS, queryKey: PROJECT_RUNDOWNS,
queryFn: fetchProjectRundownList, queryFn: fetchProjectRundownList,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching }; return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
} }
export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient();
const { mutateAsync: create } = useMutation({
mutationFn: createRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: remove } = useMutation({
mutationFn: deleteRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
const { mutateAsync: load } = useMutation({
mutationFn: loadRundown,
onMutate: () => {
ontimeQueryClient.cancelQueries({ queryKey: PROJECT_RUNDOWNS });
},
onSuccess: (response) => {
ontimeQueryClient.setQueryData(PROJECT_RUNDOWNS, response.data);
},
});
return { create, remove, load };
}
@@ -5,6 +5,8 @@ import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown'; import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import useProjectData from './useProjectData'; import useProjectData from './useProjectData';
@@ -26,14 +28,18 @@ export default function useRundown() {
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchCurrentRundown, queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
}
/** /**
* Provides access to a flat rundown * Provides access to a flat rundown
* built from the order and rundown fields * built from the order and rundown fields
@@ -56,6 +62,7 @@ export function useFlatRundown() {
}, [data.entries, data.flatOrder, data.revision, prevRevision]); }, [data.entries, data.flatOrder, data.revision, prevRevision]);
// TODO: should we have a project id field? // TODO: should we have a project id field?
// TODO(v4): cleanup as part of load multiple rundowns
// invalidate current version if project changes // invalidate current version if project changes
useEffect(() => { useEffect(() => {
if (projectData?.title !== loadedProject.current) { if (projectData?.title !== loadedProject.current) {
@@ -64,7 +71,15 @@ export function useFlatRundown() {
} }
}, [projectData]); }, [projectData]);
return { data: flatRundown, status }; return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
} }
/** /**
@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { unobfuscate } from 'ontime-utils'; import { unobfuscate } from 'ontime-utils';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_SETTINGS } from '../api/constants'; import { APP_SETTINGS } from '../api/constants';
import { getSettings } from '../api/settings'; import { getSettings } from '../api/settings';
import { ontimePlaceholderSettings } from '../models/OntimeSettings'; import { ontimePlaceholderSettings } from '../models/OntimeSettings';
@@ -11,10 +10,6 @@ export default function useSettings() {
queryKey: APP_SETTINGS, queryKey: APP_SETTINGS,
queryFn: getSettings, queryFn: getSettings,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
select: (data) => { select: (data) => {
const unobfuscated = { ...data }; const unobfuscated = { ...data };
if (data.editorKey) { if (data.editorKey) {
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { URLPreset } from 'ontime-types'; import { OntimeView, URLPreset } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { URL_PRESETS } from '../api/constants'; import { URL_PRESETS } from '../api/constants';
@@ -21,6 +22,14 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
return { data: data ?? [], status, isError, refetch }; return { data: data ?? [], status, isError, refetch };
} }
export function useViewUrlPresets(view: OntimeView) {
const { data } = useUrlPresets();
const viewPresets = useMemo(() => data.filter((preset) => preset.target === view), [data, view]);
return { viewPresets };
}
export function useUpdateUrlPreset() { export function useUpdateUrlPreset() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -7,7 +7,7 @@ import { VIEW_SETTINGS } from '../api/constants';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type'; import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() { export default function useViewSettings() {
const { data, isPending } = useQuery({ const { data, status } = useQuery({
queryKey: VIEW_SETTINGS, queryKey: VIEW_SETTINGS,
queryFn: getViewSettings, queryFn: getViewSettings,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
@@ -24,5 +24,5 @@ export default function useViewSettings() {
}, },
}); });
return { data: data ?? viewsSettingsPlaceholder, mutateAsync, isPending }; return { data: data ?? viewsSettingsPlaceholder, status, mutateAsync };
} }
@@ -1,5 +1,5 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router';
import { MessageTag } from 'ontime-types'; import { MessageTag } from 'ontime-types';
import { useShallow } from 'zustand/shallow'; import { useShallow } from 'zustand/shallow';
@@ -1,5 +1,5 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router';
import { addDialog } from '../stores/dialogStore'; import { addDialog } from '../stores/dialogStore';
+178 -107
View File
@@ -2,13 +2,13 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
isOntimeBlock,
isOntimeEvent, isOntimeEvent,
isOntimeGroup,
MaybeString, MaybeString,
OntimeBlock,
OntimeEntry, OntimeEntry,
OntimeEvent, OntimeEvent,
Rundown, Rundown,
SupportedEntry,
TimeField, TimeField,
TimeStrategy, TimeStrategy,
TransientEventPayload, TransientEventPayload,
@@ -30,13 +30,12 @@ import {
requestEventSwap, requestEventSwap,
requestGroupEntries, requestGroupEntries,
requestUngroup, requestUngroup,
SwapEntry,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{ export type EventOptions = Partial<{
// options of any new entries (event / delay / block) // options of any new entries (event / delay / group)
after: MaybeString; after: MaybeString;
before: MaybeString; before: MaybeString;
// options of entries of type OntimeEvent // options of entries of type OntimeEvent
@@ -59,15 +58,25 @@ export const useEntryActions = () => {
defaultEndAction, defaultEndAction,
} = useEditorSettings(); } = useEditorSettings();
/**
* Returns the currently loaded rundown
*/
const getCurrentRundownData = useCallback(() => {
return queryClient.getQueryData<Rundown>(RUNDOWN);
}, [queryClient]);
/**
* Looks for an entry with a given ID in the currently loaded rundown
*/
const getEntryById = useCallback( const getEntryById = useCallback(
(eventId: string): OntimeEntry | undefined => { (eventId: EntryId): OntimeEntry | undefined => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN); const cachedRundown = getCurrentRundownData();
if (!cachedRundown?.entries) { if (!cachedRundown?.entries) {
return; return;
} }
return cachedRundown.entries[eventId]; return cachedRundown.entries[eventId];
}, },
[queryClient], [getCurrentRundownData],
); );
/** /**
@@ -75,8 +84,8 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: addEntryMutation } = useMutation({ const { mutateAsync: addEntryMutation } = useMutation({
// TODO(v4): optimistic create entry mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
mutationFn: postAddEntry, onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
@@ -85,13 +94,19 @@ export const useEntryActions = () => {
*/ */
const addEntry = useCallback( const addEntry = useCallback(
async (entry: Partial<OntimeEntry>, options?: EventOptions) => { async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
const rundownData = getCurrentRundownData();
const rundownId = rundownData?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
const newEntry: TransientEventPayload = { ...entry, id: generateId() }; const newEntry: TransientEventPayload = { ...entry, id: generateId() };
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) { if (isOntimeEvent(newEntry)) {
if (options?.lastEventId) { if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const previousEvent = rundownData.entries[options?.lastEventId]; const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd; newEntry.timeStart = previousEvent.timeEnd;
@@ -135,21 +150,21 @@ export const useEntryActions = () => {
} }
try { try {
await addEntryMutation(newEntry); await addEntryMutation([rundownId, newEntry]);
} catch (error) { } catch (error) {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
}, },
[ [
addEntryMutation, getCurrentRundownData,
defaultDangerTime,
defaultDuration,
defaultEndAction,
defaultTimerType,
defaultTimeStrategy,
defaultWarnTime,
linkPrevious, linkPrevious,
queryClient, defaultDuration,
defaultDangerTime,
defaultWarnTime,
defaultTimerType,
defaultEndAction,
defaultTimeStrategy,
addEntryMutation,
], ],
); );
@@ -158,22 +173,28 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: cloneEntryMutation } = useMutation({ const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: postCloneEntry, mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
}); });
/** /**
* Clone a selection * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId) => { async (entryId: EntryId) => {
try { try {
await cloneEntryMutation(entryId); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await cloneEntryMutation([rundownId, entryId]);
} catch (error) { } catch (error) {
logAxiosError('Error cloning entry', error); logAxiosError('Error cloning entry', error);
} }
}, },
[cloneEntryMutation], [cloneEntryMutation, getCurrentRundownData],
); );
/** /**
@@ -181,9 +202,9 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: updateEntryMutation } = useMutation({ const { mutateAsync: updateEntryMutation } = useMutation({
mutationFn: putEditEntry, mutationFn: ([rundownId, newEvent]: Parameters<typeof putEditEntry>) => putEditEntry(rundownId, newEvent),
// we optimistically update here // we optimistically update here
onMutate: async (newEvent) => { onMutate: async ([_rundownId, newEvent]) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -211,7 +232,9 @@ export const useEntryActions = () => {
}, },
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData); if (context?.previousData) {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -226,19 +249,17 @@ export const useEntryActions = () => {
const updateEntry = useCallback( const updateEntry = useCallback(
async (entry: Partial<OntimeEntry>) => { async (entry: Partial<OntimeEntry>) => {
try { try {
await updateEntryMutation(entry); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await updateEntryMutation([rundownId, entry]);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
}, },
[updateEntryMutation], [getCurrentRundownData, updateEntryMutation],
);
const updateCustomField = useCallback(
async (entryId: EntryId, field: string, value: string) => {
updateEntry({ id: entryId, custom: { [field]: value } });
},
[updateEntry],
); );
/** /**
@@ -250,6 +271,11 @@ export const useEntryActions = () => {
*/ */
const updateTimer = useCallback( const updateTimer = useCallback(
async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => { async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
// an empty value with no lock has no domain validity // an empty value with no lock has no domain validity
if (!lockOnUpdate && value === '') { if (!lockOnUpdate && value === '') {
return; return;
@@ -279,7 +305,7 @@ export const useEntryActions = () => {
} }
try { try {
await updateEntryMutation(newEvent); await updateEntryMutation([rundownId, newEvent]);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
@@ -331,7 +357,7 @@ export const useEntryActions = () => {
return previousEnd; return previousEnd;
} }
}, },
[updateEntryMutation, queryClient], [getCurrentRundownData, updateEntryMutation, queryClient],
); );
/** /**
@@ -339,8 +365,8 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: batchUpdateEventsMutation } = useMutation({ const { mutateAsync: batchUpdateEventsMutation } = useMutation({
mutationFn: putBatchEditEvents, mutationFn: ([rundownId, data]: Parameters<typeof putBatchEditEvents>) => putBatchEditEvents(rundownId, data),
onMutate: async ({ ids, data }) => { onMutate: async ([_rundownId, data]) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -348,7 +374,7 @@ export const useEntryActions = () => {
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN); const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousRundown) { if (previousRundown) {
const eventIds = new Set(ids); const eventIds = new Set(data.ids);
const newRundown = { ...previousRundown.entries }; const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => { eventIds.forEach((eventId) => {
@@ -395,14 +421,19 @@ export const useEntryActions = () => {
}); });
const batchUpdateEvents = useCallback( const batchUpdateEvents = useCallback(
async (data: Partial<OntimeEvent>, eventIds: string[]) => { async (data: Partial<OntimeEvent>, eventIds: EntryId[]) => {
try { try {
await batchUpdateEventsMutation({ ids: eventIds, data }); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await batchUpdateEventsMutation([rundownId, { data, ids: eventIds }]);
} catch (error) { } catch (error) {
logAxiosError('Error updating events', error); logAxiosError('Error updating events', error);
} }
}, },
[batchUpdateEventsMutation], [batchUpdateEventsMutation, getCurrentRundownData],
); );
/** /**
@@ -410,9 +441,9 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: deleteEntryMutation } = useMutation({ const { mutateAsync: deleteEntryMutation } = useMutation({
mutationFn: deleteEntries, mutationFn: ([rundownId, entryIds]: Parameters<typeof deleteEntries>) => deleteEntries(rundownId, entryIds),
// we optimistically update here // we optimistically update here
onMutate: async (entryIds: EntryId[]) => { onMutate: async ([_rundownId, entryIds]) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -454,12 +485,17 @@ export const useEntryActions = () => {
const deleteEntry = useCallback( const deleteEntry = useCallback(
async (entryIds: EntryId[]) => { async (entryIds: EntryId[]) => {
try { try {
await deleteEntryMutation(entryIds); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await deleteEntryMutation([rundownId, entryIds]);
} catch (error) { } catch (error) {
logAxiosError('Error deleting event', error); logAxiosError('Error deleting event', error);
} }
}, },
[deleteEntryMutation], [deleteEntryMutation, getCurrentRundownData],
); );
/** /**
@@ -467,7 +503,7 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: deleteAllEntriesMutation } = useMutation({ const { mutateAsync: deleteAllEntriesMutation } = useMutation({
mutationFn: requestDeleteAll, mutationFn: ([rundownId]: Parameters<typeof requestDeleteAll>) => requestDeleteAll(rundownId),
// we optimistically update here // we optimistically update here
onMutate: async () => { onMutate: async () => {
// cancel ongoing queries // cancel ongoing queries
@@ -506,18 +542,24 @@ export const useEntryActions = () => {
*/ */
const deleteAllEntries = useCallback(async () => { const deleteAllEntries = useCallback(async () => {
try { try {
await deleteAllEntriesMutation(); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await deleteAllEntriesMutation([rundownId]);
} catch (error) { } catch (error) {
logAxiosError('Error deleting events', error); logAxiosError('Error deleting events', error);
} }
}, [deleteAllEntriesMutation]); }, [deleteAllEntriesMutation, getCurrentRundownData]);
/** /**
* Calls mutation to apply a delay * Calls mutation to apply a delay
* @private * @private
*/ */
const { mutateAsync: applyDelayMutation } = useMutation({ const { mutateAsync: applyDelayMutation } = useMutation({
mutationFn: requestApplyDelay, mutationFn: ([rundownId, delayId]: Parameters<typeof requestApplyDelay>) => requestApplyDelay(rundownId, delayId),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSuccess: (response) => { onSuccess: (response) => {
if (!response.data) return; if (!response.data) return;
@@ -543,20 +585,26 @@ export const useEntryActions = () => {
const applyDelay = useCallback( const applyDelay = useCallback(
async (delayEventId: EntryId) => { async (delayEventId: EntryId) => {
try { try {
await applyDelayMutation(delayEventId); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await applyDelayMutation([rundownId, delayEventId]);
} catch (error) { } catch (error) {
logAxiosError('Error applying delay', error); logAxiosError('Error applying delay', error);
} }
}, },
[applyDelayMutation], [applyDelayMutation, getCurrentRundownData],
); );
/** /**
* Calls mutation to dissolve a block * Calls mutation to dissolve a group
* @private * @private
*/ */
const { mutateAsync: ungroupMutation } = useMutation({ const { mutateAsync: ungroupMutation } = useMutation({
mutationFn: requestUngroup, mutationFn: ([rundownId, groupId]: Parameters<typeof requestUngroup>) => requestUngroup(rundownId, groupId),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSuccess: (response) => { onSuccess: (response) => {
if (!response.data) return; if (!response.data) return;
@@ -574,25 +622,32 @@ export const useEntryActions = () => {
}); });
/** /**
* Deletes a block and moves its events to the top level * Deletes a group and moves its events to the top level
*/ */
const ungroup = useCallback( const ungroup = useCallback(
async (blockId: EntryId) => { async (groupId: EntryId) => {
try { try {
await ungroupMutation(blockId); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await ungroupMutation([rundownId, groupId]);
} catch (error) { } catch (error) {
logAxiosError('Error dissolving group', error); logAxiosError('Error dissolving group', error);
} }
}, },
[ungroupMutation], [getCurrentRundownData, ungroupMutation],
); );
/** /**
* Calls mutation to create a block with a selection * Calls mutation to create a group with a selection
* @private * @private
*/ */
const { mutateAsync: groupEntriesMutation } = useMutation({ const { mutateAsync: groupEntriesMutation } = useMutation({
mutationFn: requestGroupEntries, mutationFn: ([rundownId, entryIds]: Parameters<typeof requestGroupEntries>) =>
requestGroupEntries(rundownId, entryIds),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSuccess: (response) => { onSuccess: (response) => {
if (!response.data) return; if (!response.data) return;
@@ -610,26 +665,31 @@ export const useEntryActions = () => {
}); });
/** /**
* Create a block with a selection * Create a group with a selection
*/ */
const groupEntries = useCallback( const groupEntries = useCallback(
async (entryIds: EntryId[]) => { async (entryIds: EntryId[]) => {
if (entryIds.length === 0) return; if (entryIds.length === 0) return;
try { try {
const rundownData = getCurrentRundownData();
const rundownId = rundownData?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
if (entryIds.length === 1) { if (entryIds.length === 1) {
await groupEntriesMutation(entryIds); await groupEntriesMutation([rundownId, entryIds]);
} else { } else {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN); // the user selection may be out of order
if (!rundown) return; const orderedIds = orderEntries(entryIds, rundownData.flatOrder);
const orderedIds = orderEntries(entryIds, rundown.flatOrder); await groupEntriesMutation([rundownId, orderedIds]);
await groupEntriesMutation(orderedIds);
} }
} catch (error) { } catch (error) {
logAxiosError('Error grouping entries', error); logAxiosError('Error grouping entries', error);
} }
}, },
[groupEntriesMutation, queryClient], [getCurrentRundownData, groupEntriesMutation],
); );
/** /**
@@ -637,9 +697,8 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: reorderEntryMutation } = useMutation({ const { mutateAsync: reorderEntryMutation } = useMutation({
mutationFn: patchReorderEntry, mutationFn: ([rundownId, data]: Parameters<typeof patchReorderEntry>) => patchReorderEntry(rundownId, data),
// Mutation finished, failed or successful onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
// Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
@@ -650,34 +709,36 @@ export const useEntryActions = () => {
*/ */
const move = useCallback( const move = useCallback(
async (entryId: EntryId, direction: 'up' | 'down') => { async (entryId: EntryId, direction: 'up' | 'down') => {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundown) {
return;
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, rundown.flatOrder, rundown.entries)
: moveDown(entryId, rundown.flatOrder, rundown.entries);
if (!destinationId) {
return; // noop
}
try { try {
const rundownData = getCurrentRundownData();
const rundownId = rundownData?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, rundownData.flatOrder, rundownData.entries)
: moveDown(entryId, rundownData.flatOrder, rundownData.entries);
if (!destinationId) {
return; // noop
}
const reorderObject: ReorderEntry = { const reorderObject: ReorderEntry = {
entryId, entryId,
destinationId, destinationId,
order, order,
}; };
await reorderEntryMutation(reorderObject); await reorderEntryMutation([rundownId, reorderObject]);
// the rundown needs to know whether we moved into a group
return rundownData.entries[destinationId]?.type === SupportedEntry.Group ? destinationId : undefined;
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
} }
// the rundown needs to know whether we moved into a block return undefined;
return rundown.entries[destinationId]?.type === 'block' ? destinationId : undefined;
}, },
[queryClient, reorderEntryMutation], [getCurrentRundownData, reorderEntryMutation],
); );
/** /**
* Reorders a given entry * Reorders a given entry
@@ -685,17 +746,25 @@ export const useEntryActions = () => {
const reorderEntry = useCallback( const reorderEntry = useCallback(
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => { async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
try { try {
const reorderObject: ReorderEntry = { const rundownId = getCurrentRundownData()?.id;
entryId, if (!rundownId) {
destinationId, throw new Error('Rundown not initialised');
order, }
};
await reorderEntryMutation(reorderObject); await reorderEntryMutation([
rundownId,
{
entryId,
destinationId,
order,
},
]);
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
throw error; // rethrow to handle in the component
} }
}, },
[reorderEntryMutation], [getCurrentRundownData, reorderEntryMutation],
); );
/** /**
@@ -703,9 +772,9 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: swapEventsMutation } = useMutation({ const { mutateAsync: swapEventsMutation } = useMutation({
mutationFn: requestEventSwap, mutationFn: ([rundownId, from, to]: Parameters<typeof requestEventSwap>) => requestEventSwap(rundownId, from, to),
// we optimistically update here // we optimistically update here
onMutate: async ({ from, to }) => { onMutate: async ([_rundownId, from, to]) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -754,14 +823,19 @@ export const useEntryActions = () => {
* Swaps the schedule of two events * Swaps the schedule of two events
*/ */
const swapEvents = useCallback( const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => { async (from: EntryId, to: EntryId) => {
try { try {
await swapEventsMutation({ from, to }); const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await swapEventsMutation([rundownId, from, to]);
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
} }
}, },
[swapEventsMutation], [getCurrentRundownData, swapEventsMutation],
); );
return { return {
@@ -779,7 +853,6 @@ export const useEntryActions = () => {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
updateCustomField,
}; };
}; };
@@ -797,14 +870,12 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
} }
function deleteEntry(entry: OntimeEntry) { function deleteEntry(entry: OntimeEntry) {
if (isOntimeBlock(entry) || !entry.parent) { if (isOntimeGroup(entry) || !entry.parent) {
order = order.filter((id) => id !== entry.id); order = order.filter((id) => id !== entry.id);
} else { } else {
const parent = entries[entry.parent]; const parent = entries[entry.parent];
if ('parent' in entries) { if (parent && isOntimeGroup(parent)) {
(parent as OntimeBlock).entries = (parent as OntimeBlock).entries.filter( parent.entries = parent.entries.filter((parentEntry) => parentEntry !== entry.id);
(parentEntry) => parentEntry !== entry.id,
);
} }
} }
@@ -23,6 +23,9 @@ export const useFadeOutOnInactivity = (initialState = false) => {
const throttledShowMenu = throttle(setShowMenuTrue, 1000); 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('mousemove', throttledShowMenu);
document.addEventListener('keydown', throttledShowMenu); document.addEventListener('keydown', throttledShowMenu);
@@ -1,6 +1,5 @@
import { RefObject, useCallback, useEffect, useRef } from 'react'; import { RefObject, useCallback, useEffect } from 'react';
import { MaybeString } from 'ontime-types';
import { useSelectedEventId } from './useSocket';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>, componentRef: RefObject<ComponentRef>,
@@ -18,37 +17,26 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
scrollRef.current.scrollTo({ top, behavior: 'smooth' }); 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 { interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>; followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
topOffset?: number; topOffset?: number;
setScrollFlag?: (newValue: boolean) => void; setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
} }
export default function useFollowComponent(props: UseFollowComponentProps) { export default function useFollowComponent({
const { followRef, scrollRef, doFollow, topOffset = 100, setScrollFlag } = props; followRef,
scrollRef,
// when cursor moves, view should follow doFollow,
topOffset = 100,
setScrollFlag,
followTrigger,
}: UseFollowComponentProps) {
// when trigger moves, view should follow
useEffect(() => { useEffect(() => {
if (!doFollow) { if (!doFollow || !followTrigger) {
return; return;
} }
@@ -60,16 +48,14 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
setScrollFlag?.(false); setScrollFlag?.(false);
}); });
} }
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
// eslint-disable-next-line -- the prompt seems incorrect
}, [followRef?.current, scrollRef?.current]);
const scrollToRefComponent = useCallback( const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => { (componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef.current && containerRef.current) { if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null // @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, scrollRef!, offset); scrollToComponent(componentRef!, containerRef!, offset);
} }
}, },
[followRef, scrollRef, topOffset], [followRef, scrollRef, topOffset],
@@ -77,32 +63,3 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
return scrollToRefComponent; 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,
};
}
+78 -27
View File
@@ -1,4 +1,4 @@
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { sendSocket } from '../utils/socket'; import { sendSocket } from '../utils/socket';
@@ -10,14 +10,15 @@ const createSelector =
export const setClientRemote = { export const setClientRemote = {
setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload), 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), setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload),
}; };
export const useRundownEditor = createSelector((state: RuntimeStore) => ({ export const useRundownEditor = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null, selectedEventId: state.eventNow?.id ?? null,
selectedBlockId: state.blockNow?.id ?? null,
nextEventId: state.eventNext?.id ?? null, nextEventId: state.eventNext?.id ?? null,
})); }));
@@ -59,8 +60,8 @@ export const setMessage = {
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({ export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.rundown.selectedEventIndex,
numEvents: state.runtime.numEvents, numEvents: state.rundown.numEvents,
timerPhase: state.timer.phase, timerPhase: state.timer.phase,
})); }));
@@ -131,8 +132,8 @@ export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null, selectedEventId: state.eventNow?.id ?? null,
})); }));
export const useCurrentBlockId = createSelector((state: RuntimeStore) => ({ export const useCurrentGroupId = createSelector((state: RuntimeStore) => ({
currentBlockId: state.blockNow?.id ?? null, currentGroupId: state.groupNow?.id ?? null,
})); }));
export const setEventPlayback = { export const setEventPlayback = {
@@ -151,7 +152,8 @@ export const useClock = createSelector((state: RuntimeStore) => ({
})); }));
export const useNextFlag = createSelector((state: RuntimeStore) => ({ export const useNextFlag = createSelector((state: RuntimeStore) => ({
nextFlag: state.nextFlag, id: state.eventFlag?.id ?? null,
expectedStart: state.offset.expectedFlagStart,
})); }));
/** Used by the progress bar components */ /** Used by the progress bar components */
@@ -162,37 +164,36 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({ export const useRundownOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart, plannedStart: state.rundown.plannedStart,
actualStart: state.runtime.actualStart, actualStart: state.rundown.actualStart,
plannedEnd: state.runtime.plannedEnd, plannedEnd: state.rundown.plannedEnd,
expectedEnd: state.runtime.expectedEnd, expectedEnd: state.offset.expectedRundownEnd,
})); }));
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
clock: state.clock, clock: state.clock,
numEvents: state.runtime.numEvents, numEvents: state.rundown.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.rundown.selectedEventIndex,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset, offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
blockStartedAt: state.blockNow?.startedAt ?? null, groupExpectedEnd: state.offset.expectedGroupEnd,
blockExpectedEnd: state.blockNow?.expectedEnd ?? null,
})); }));
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
offset: state.runtime.offset, offset: state.offset.absolute,
})); }));
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({ export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
clock: state.clock, offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset, mode: state.offset.mode,
offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0, currentDay: state.eventNow?.dayOffset ?? 0,
actualStart: state.runtime.actualStart, actualStart: state.rundown.actualStart,
plannedStart: state.runtime.plannedStart, plannedStart: state.rundown.plannedStart,
clock: state.clock,
})); }));
export const useCurrentDay = createSelector((state: RuntimeStore) => ({ export const useCurrentDay = createSelector((state: RuntimeStore) => ({
@@ -200,7 +201,7 @@ export const useCurrentDay = createSelector((state: RuntimeStore) => ({
})); }));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({ export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset, offset: state.offset.absolute,
})); }));
export const usePing = createSelector((state: RuntimeStore) => ({ export const usePing = createSelector((state: RuntimeStore) => ({
@@ -213,7 +214,7 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
})); }));
export const useOffsetMode = createSelector((state: RuntimeStore) => ({ export const useOffsetMode = createSelector((state: RuntimeStore) => ({
offsetMode: state.runtime.offsetMode, offsetMode: state.offset.mode,
})); }));
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload); export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload);
@@ -225,3 +226,53 @@ export const usePlayback = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
/* ======================= View specific subscriptions ======================= */
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
eventNext: state.eventNext,
eventNow: state.eventNow,
message: state.message,
time: state.timer,
clock: state.clock,
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
countToEndNow: state.eventNow?.countToEnd ?? false,
auxTimer: {
aux1: state.auxtimer1.current,
aux2: state.auxtimer2.current,
aux3: state.auxtimer3.current,
},
}));
export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
current: state.timer.current,
clock: state.clock,
}));
export const useBackstageSocket = createSelector((state: RuntimeStore) => ({
eventNext: state.eventNext,
eventNow: state.eventNow,
rundown: state.rundown,
selectedEventId: state.eventNow?.id ?? null,
time: state.timer,
}));
export const useStudioClockSocket = createSelector((state: RuntimeStore) => ({
clock: state.clock,
playback: state.timer.playback,
}));
export const useStudioTimersSocket = createSelector((state: RuntimeStore) => ({
eventNext: state.eventNext,
eventNow: state.eventNow,
message: state.message,
time: state.timer,
offset: state.offset,
rundown: state.rundown,
}));
export const useTimelineSocket = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.offset.absolute,
}));
@@ -1,19 +0,0 @@
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
// first set extends TimerState
export type ViewExtendedTimer = {
addedTime: number;
current: MaybeNumber;
duration: MaybeNumber;
elapsed: MaybeNumber;
expectedFinish: MaybeNumber;
finishedAt: MaybeNumber;
phase: TimerPhase;
playback: Playback;
secondaryTimer: MaybeNumber;
startedAt: MaybeNumber;
clock: number;
timerType: TimerType;
countToEnd: boolean;
};
@@ -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;
};
+3 -1
View File
@@ -1,6 +1,8 @@
import { ClientList } from 'ontime-types'; import { ClientList } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
import { makeStageKey } from '../utils/localStorage';
interface ClientStore { interface ClientStore {
name?: string; name?: string;
setName: (newValue: string) => void; setName: (newValue: string) => void;
@@ -15,7 +17,7 @@ interface ClientStore {
setClients: (clients: ClientList) => void; setClients: (clients: ClientList) => void;
} }
const clientNameKey = 'ontime-client-name'; const clientNameKey = makeStageKey('client-name');
function persistNameInStorage(newValue: string) { function persistNameInStorage(newValue: string) {
localStorage.setItem(clientNameKey, newValue); localStorage.setItem(clientNameKey, newValue);
@@ -0,0 +1,89 @@
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 -1
View File
@@ -3,7 +3,7 @@ import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage'; import { booleanFromLocalStorage } from '../utils/localStorage';
enum LocalEventKeys { enum LocalEventKeys {
Mirror = 'ontime-view-mirror', Mirror = 'view-mirror',
} }
type ViewOptionsStore = { type ViewOptionsStore = {
@@ -0,0 +1,302 @@
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { initRundownMetadata } from '../rundownMetadata';
describe('initRundownMetadata()', () => {
it('processes nested rundown data', () => {
const selectedEventId = '12';
const demoEvents = {
'1': {
id: '1',
type: SupportedEntry.Event,
parent: null,
timeStart: 0,
timeEnd: 1,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
group: {
id: 'group',
type: SupportedEntry.Group,
entries: ['11', 'delay', '12', '13'],
colour: 'red',
} as OntimeGroup,
'11': {
id: '11',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 10,
timeEnd: 11,
duration: 1,
dayOffset: 0,
gap: 10,
skip: false,
linkStart: false,
} as OntimeEvent,
delay: {
id: 'delay',
type: SupportedEntry.Delay,
parent: 'group',
duration: 0,
} as OntimeDelay,
'12': {
id: '12',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 11,
timeEnd: 12,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'13': {
id: '13',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 12,
timeEnd: 13,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: null,
timeStart: 20,
timeEnd: 21,
duration: 1,
dayOffset: 0,
gap: 7,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { metadata, process } = initRundownMetadata(selectedEventId);
expect(metadata).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: null,
eventIndex: 0,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
groupEntries: undefined,
isFirstAfterGroup: false,
});
expect(process(demoEvents['1'])).toStrictEqual({
previousEvent: null,
latestEvent: demoEvents['1'],
previousEntryId: null,
thisId: demoEvents['1'].id,
eventIndex: 1, // UI indexes are 1 based
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
groupEntries: undefined,
isFirstAfterGroup: false,
});
expect(process(demoEvents['group'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['1'],
previousEntryId: demoEvents['1'].id,
thisId: demoEvents['group'].id,
eventIndex: 1,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['11'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['group'].id,
thisId: demoEvents['11'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['delay'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['11'].id,
thisId: demoEvents['delay'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['12'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['12'],
previousEntryId: demoEvents['delay'].id,
thisId: demoEvents['12'].id,
eventIndex: 3,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: true,
isLoaded: true,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['13'])).toMatchObject({
previousEvent: demoEvents['12'],
latestEvent: demoEvents['13'],
previousEntryId: demoEvents['12'].id,
thisId: demoEvents['13'].id,
eventIndex: 4,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: true,
isLoaded: false,
groupId: 'group',
groupColour: 'red',
isFirstAfterGroup: false,
});
expect(process(demoEvents['2'])).toMatchObject({
previousEvent: demoEvents['13'],
latestEvent: demoEvents['2'],
previousEntryId: demoEvents['13'].id,
thisId: demoEvents['2'].id,
eventIndex: 5,
isPast: false,
isNextDay: false,
totalGap: 17,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
isFirstAfterGroup: true,
});
});
it('populates previousEntries in groups', () => {
const rundownStartsWithGroup = {
group: {
id: 'group',
type: SupportedEntry.Group,
colour: 'red',
entries: ['1', '2'],
} as OntimeGroup,
'1': {
id: '1',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 1,
timeEnd: 2,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: 'group',
timeStart: 2,
timeEnd: 3,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { process } = initRundownMetadata(null);
expect(process(rundownStartsWithGroup.group)).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: rundownStartsWithGroup.group.id,
eventIndex: 0,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithGroup.group.id,
groupColour: 'red',
groupEntries: 2,
isFirstAfterGroup: false,
});
expect(process(rundownStartsWithGroup['1'])).toStrictEqual({
previousEvent: null,
latestEvent: rundownStartsWithGroup['1'],
previousEntryId: rundownStartsWithGroup.group.id,
thisId: rundownStartsWithGroup['1'].id,
eventIndex: 1,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithGroup.group.id,
groupColour: 'red',
groupEntries: 2,
isFirstAfterGroup: false,
});
expect(process(rundownStartsWithGroup['2'])).toStrictEqual({
previousEvent: rundownStartsWithGroup['1'],
latestEvent: rundownStartsWithGroup['2'],
previousEntryId: rundownStartsWithGroup['1'].id,
thisId: rundownStartsWithGroup['2'].id,
eventIndex: 2,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithGroup.group.id,
groupColour: 'red',
groupEntries: 2,
isFirstAfterGroup: false,
});
});
});
@@ -1,9 +1,11 @@
import { resolvePath } from 'react-router-dom'; import { Path, resolvePath } from 'react-router';
import { OntimeView, URLPreset } from 'ontime-types';
import { import {
arePathsEquivalent, arePathsEquivalent,
generatePathFromPreset, generatePathFromPreset,
generateUrlPresetOptions, generateUrlPresetOptions,
getCurrentPath,
getRouteFromPreset, getRouteFromPreset,
validateUrlPresetPath, validateUrlPresetPath,
} from '../urlPresets'; } from '../urlPresets';
@@ -29,12 +31,13 @@ describe('validateUrlPresetPaths()', () => {
}); });
describe('getRouteFromPreset()', () => { describe('getRouteFromPreset()', () => {
const presets = [ const presets: URLPreset[] = [
{ {
enabled: true, enabled: true,
alias: 'demopage', alias: 'demopage',
target: 'timer', target: OntimeView.Timer,
search: 'user=guest', search: 'user=guest',
options: {},
}, },
]; ];
@@ -70,28 +73,28 @@ describe('getRouteFromPreset()', () => {
describe('handle url sharing edge cases', () => { describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => { it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?locked=true&token=123'); const location = resolvePath('/demopage?n=1&token=123');
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy(); expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy();
}); });
it('appends the feature params to the alias', () => { it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?locked=true&token=123'); const location = resolvePath('/demopage?n=1&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123'); expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
}); });
}); });
}); });
describe('generatePathFromPreset()', () => { describe('generatePathFromPreset()', () => {
test.each([ test.each([
['timer', 'user=guest', 'demopage', 'timer?user=guest&alias=demopage'], ['timer', 'user=guest', 'demopage', false, 'timer?user=guest&alias=demopage'],
['timer', 'user=admin', 'demopage', 'timer?user=admin&alias=demopage'], ['timer', 'user=admin', 'demopage', false, 'timer?user=admin&alias=demopage'],
])('generates a path from a preset: %s', (target, path, alias, expected) => { ])('generates a path from a preset: %s', (target, search, alias, locked, expected) => {
expect(generatePathFromPreset(target, path, alias, null, null)).toEqual(expected); expect(generatePathFromPreset(target, search, alias, locked, null)).toEqual(expected);
}); });
test('appends the feature params to the alias', () => { test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer', 'user=guest', 'demopage', 'true', '123')).toBe( expect(generatePathFromPreset('timer', 'user=guest', 'demopage', true, '123')).toBe(
'timer?user=guest&alias=demopage&locked=true&token=123', 'timer?user=guest&alias=demopage&n=1&token=123',
); );
}); });
}); });
@@ -108,9 +111,13 @@ describe('arePathsEquivalent()', () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy(); expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
}); });
it('checks whether we are in a locked preset', () => {
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy();
});
it('considers edge cases for the url sharing feature', () => { it('considers edge cases for the url sharing feature', () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy(); expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy();
}); });
}); });
@@ -172,3 +179,16 @@ describe('generateUrlPresetOptions', () => {
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow(); expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
}); });
}); });
describe('getCurrentPath()', () => {
test.each([
[resolvePath('http://localhost:4001/timer'), 'timer'],
[resolvePath('http://192.168.0.1:654321/minimal'), 'minimal'],
[resolvePath('https://user-hosted.io/cuesheet'), 'cuesheet'],
[resolvePath('https://cloud.getontime.no/team-hash/op'), 'op'],
[resolvePath('https://cloud.getontime.no/team-hash/backstage/?params-with-slash=true'), 'backstage'],
[resolvePath('https://cloud.getontime.no/team-hash/timeline?params-are-ignored=true'), 'timeline'],
])('resolves the current: %s', (location, expected) => {
expect(getCurrentPath(location as Path)).toEqual(expected);
});
});
@@ -1,3 +1,5 @@
import { baseURI } from '../../externals';
export function booleanFromLocalStorage(key: string, fallback: boolean): boolean { export function booleanFromLocalStorage(key: string, fallback: boolean): boolean {
const valueInStorage = localStorage.getItem(key); const valueInStorage = localStorage.getItem(key);
if (valueInStorage) { if (valueInStorage) {
@@ -7,3 +9,8 @@ export function booleanFromLocalStorage(key: string, fallback: boolean): boolean
return fallback; return fallback;
} }
} }
export function makeStageKey(key: string) {
if (baseURI) return `${baseURI}-${key}`;
return key;
}
+4 -3
View File
@@ -19,7 +19,8 @@ export function getOffsetText(offset: MaybeNumber): string {
} }
export function getOffsetState(offset: MaybeNumber): 'over' | 'under' | 'muted' | null { export function getOffsetState(offset: MaybeNumber): 'over' | 'under' | 'muted' | null {
if (offset === null) return null; if (offset === null) return 'muted';
if (offset === 0) return 'muted'; if (offset === 0) return null;
return offset < 0 ? 'over' : 'under'; // a positive value means that we are in over time aka behind schedule
return offset > 0 ? 'over' : 'under';
} }
+1
View File
@@ -11,3 +11,4 @@ export const isAlphanumeric = /^[a-z0-9]+$/i;
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
export const isASCIIorEmpty = /^$|^[ -~]+$/; //https://catonmat.net/my-favorite-regex export const isASCIIorEmpty = /^$|^[ -~]+$/; //https://catonmat.net/my-favorite-regex
export const isNotEmpty = /\S/; export const isNotEmpty = /\S/;
export const isUrlSafe = /^[a-zA-Z0-9_-]*$/; // https://stackoverflow.com/questions/24419067/validate-a-string-to-be-url-safe-using-regex
@@ -0,0 +1,175 @@
import {
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
MaybeString,
OntimeDelay,
OntimeEntry,
OntimeEvent,
OntimeMilestone,
PlayableEvent,
Rundown,
} from 'ontime-types';
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
export type RundownMetadata = {
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration, used by indicators
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
previousEntryId: MaybeString; // previous entry is used to infer position in the rundown for new events
thisId: MaybeString;
eventIndex: number;
isPast: boolean;
isNextDay: boolean;
totalGap: number;
isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
isLoaded: boolean;
groupId: MaybeString;
groupColour: string | undefined;
groupEntries: number | undefined;
isFirstAfterGroup: boolean;
};
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
export const lastMetadataKey = 'LAST';
export type RundownMetadataObject = Record<string, Readonly<RundownMetadata>>;
/**
* Generates a Rundown Metadata object from a rundown
*/
export function getRundownMetadata(
data: Pick<Rundown, 'entries' | 'flatOrder'>,
selectedEventId: MaybeString,
): RundownMetadataObject {
const { metadata, process } = initRundownMetadata(selectedEventId);
// keep a single reference to the metadata which we override for every entry
let lastSnapshot = metadata;
const rundownMetadata: RundownMetadataObject = {};
for (const id of data.flatOrder) {
const entry = data.entries[id];
lastSnapshot = process(entry);
rundownMetadata[id] = lastSnapshot;
}
// ensure some blank data even for empty rundowns
rundownMetadata[lastMetadataKey] = lastSnapshot;
return rundownMetadata;
}
export function getFlatRundownMetadata(
data: Pick<Rundown, 'entries' | 'flatOrder'>,
selectedEventId: MaybeString,
): ExtendedEntry[] {
const { process } = initRundownMetadata(selectedEventId);
const flatRundown: ExtendedEntry[] = [];
for (const id of data.flatOrder) {
const entry = data.entries[id];
const extendedEntry = { ...entry, ...process(entry) };
flatRundown.push(extendedEntry);
}
return flatRundown;
}
/**
* Creates a process function which aggregates the rundown metadata and event metadata
*/
export function initRundownMetadata(selectedEventId: MaybeString) {
let rundownMeta: RundownMetadata = {
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: null,
eventIndex: 0,
isPast: Boolean(selectedEventId), // all events before the current selected are in the past
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
groupEntries: undefined,
isFirstAfterGroup: false,
};
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
const processedRundownMetadata = processEntry(rundownMeta, selectedEventId, entry);
rundownMeta = processedRundownMetadata;
return rundownMeta;
}
return { metadata: rundownMeta, process };
}
/**
* Receives a rundown entry and processes its place in the rundown
*/
function processEntry(
rundownMetadata: RundownMetadata,
selectedEventId: MaybeString,
entry: Readonly<OntimeEntry>,
): Readonly<RundownMetadata> {
const processedData = { ...rundownMetadata };
// initialise data to be overridden below
processedData.isNextDay = false;
processedData.isLoaded = false;
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
processedData.thisId = entry.id; // we reassign thisId
processedData.previousEvent = processedData.latestEvent;
if (entry.id === selectedEventId) {
processedData.isLoaded = true;
processedData.isPast = false;
processedData.isLinkedToLoaded = true;
}
if (isOntimeGroup(entry)) {
processedData.groupId = entry.id;
processedData.groupColour = entry.colour;
processedData.groupEntries = entry.entries.length;
} else {
// for delays and groups, we insert the group metadata
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
// if the parent is not the current group, we need to update the groupId
processedData.groupId = (entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent;
processedData.groupEntries = undefined;
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent === null) {
// if the entry has no parent, it cannot have a group colour
processedData.groupColour = undefined;
}
}
if (isOntimeEvent(entry)) {
// event indexes are 1 based in UI
processedData.eventIndex += 1;
processedData.isFirstAfterGroup = Boolean(processedData.previousEvent?.parent) && entry.parent === null;
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.isLinkedToLoaded;
}
if (isNewLatest(entry, processedData.latestEvent)) {
// this event is the forward most event in rundown, for next iteration
processedData.latestEvent = entry;
}
}
}
}
return processedData;
}
+23 -11
View File
@@ -10,7 +10,18 @@ import {
} from 'ontime-types'; } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals'; import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, PROJECT_DATA, REPORT, RUNDOWN, RUNTIME, VIEW_SETTINGS } from '../api/constants'; import {
APP_SETTINGS,
CLIENT_LIST,
CUSTOM_FIELDS,
PROJECT_DATA,
REPORT,
RUNDOWN,
RUNTIME,
TRANSLATION,
URL_PRESETS,
VIEW_SETTINGS,
} from '../api/constants';
import { invalidateAllCaches } from '../api/utils'; import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { import {
@@ -133,16 +144,8 @@ export const connectSocket = () => {
break; break;
} }
case MessageTag.RuntimeData: { case MessageTag.RuntimeData: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload patchRuntime(payload);
const { ping, ...serverPayload } = payload; updateDevTools(payload);
patchRuntime(serverPayload);
updateDevTools(serverPayload);
break;
}
case MessageTag.RuntimePatch: {
const patch = payload;
patchRuntime(patch);
updateDevTools(patch);
break; break;
} }
case MessageTag.Refetch: { case MessageTag.Refetch: {
@@ -166,9 +169,18 @@ export const connectSocket = () => {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
break; break;
case RefetchKey.UrlPresets:
ontimeQueryClient.invalidateQueries({ queryKey: URL_PRESETS });
break;
case RefetchKey.ViewSettings: case RefetchKey.ViewSettings:
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS }); ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
break; break;
case RefetchKey.Translation:
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
break;
case RefetchKey.Settings:
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
break;
default: { default: {
target satisfies never; target satisfies never;
break; break;
+14 -9
View File
@@ -1,7 +1,7 @@
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import { import {
calculateTimeUntilStart,
formatFromMillis, formatFromMillis,
getExpectedStart,
MILLIS_PER_HOUR, MILLIS_PER_HOUR,
MILLIS_PER_MINUTE, MILLIS_PER_MINUTE,
MILLIS_PER_SECOND, MILLIS_PER_SECOND,
@@ -9,7 +9,7 @@ import {
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig'; import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants'; import { APP_SETTINGS } from '../api/constants';
import { useTimeUntilData } from '../hooks/useSocket'; import { useExpectedStartData } from '../hooks/useSocket';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
/** /**
@@ -105,8 +105,7 @@ export const formatTime = (
/** /**
* Handles case for formatting a duration time * Handles case for formatting a duration time
* @param duration * eg: "1h 0m 0s" or "0h 30m"
* @returns
*/ */
export function formatDuration(duration: number, hideSeconds = true): string { export function formatDuration(duration: number, hideSeconds = true): string {
// durations should never be negative, we handle it here to flag if there is an issue in future // durations should never be negative, we handle it here to flag if there is an issue in future
@@ -134,18 +133,24 @@ export function formatDuration(duration: number, hideSeconds = true): string {
} }
/** /**
*
* @param totalGap accumulated gap from the current event * @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event * @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @returns * @returns
*/ */
export function useTimeUntilStart( export function useTimeUntilExpectedStart(
// typed like this to make it very clear what the data is // typed like this to make it very clear what the data is
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & { event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> | null,
state: {
totalGap: number; totalGap: number;
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
}, },
): number { ): number {
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData(); const { offset, currentDay, mode, actualStart, plannedStart, clock } = useExpectedStartData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart }); if (event === null) return 0;
const expectedStart = getExpectedStart(
{ ...event },
{ ...state, currentDay, offset, mode, actualStart, plannedStart },
);
return expectedStart - clock;
} }
+74 -63
View File
@@ -1,5 +1,5 @@
import { Path, resolvePath } from 'react-router-dom'; import { Path, resolvePath } from 'react-router';
import { OntimeView, URLPreset } from 'ontime-types'; import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import { checkRegex } from 'ontime-utils'; import { checkRegex } from 'ontime-utils';
/** /**
@@ -27,62 +27,72 @@ export function validateUrlPresetPath(preset: string): { message: string; isVali
return { isValid: true, message: 'ok' }; return { isValid: true, message: 'ok' };
} }
/**
* Utility removes trailing slash from a string
*/
function removeTrailingSlash(text: string): string {
return text.replace(/\/$/, '');
}
/** /**
* Checks whether the current location corresponds to a preset and returns the new path if necessary * Checks whether the current location corresponds to a preset and returns the new path if necessary
*/ */
export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): string | null { export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): string | null {
// current url is the pathname without the leading slash // if we're already on a preset path, no need to redirect
const currentURL = location.pathname.substring(1); if (isPresetPath(location)) {
const searchParams = new URLSearchParams(location.search);
// check if we have token or locked in the search params
const locked = searchParams.get('locked');
const token = searchParams.get('token');
// we need to check if the whole url is an alias
const foundPreset = urlPresets.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
if (foundPreset) {
// if so, we can redirect to the preset path
return generatePathFromPreset(foundPreset.target, foundPreset.search, foundPreset.alias, locked, token);
}
// if the current url is not an alias, we check if the alias is in the search parameters
const presetOnPage = searchParams.get('alias');
if (!presetOnPage) {
return null; return null;
} }
// NOTE: verify that this resolves correctly in cloud
const currentPath = `${location.pathname}${location.search}`.substring(1); const currentPath = `${location.pathname}${location.search}`.substring(1);
const currentURL = getCurrentPath(location);
const token = new URLSearchParams(location.search).get('token');
const isLocked = location.search.includes('n=1');
for (const preset of urlPresets) { for (const preset of urlPresets) {
// if the page has a known enabled alias, we check if we need to redirect if (!preset.enabled) continue;
if (preset.alias === presetOnPage && preset.enabled) { /**
const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, locked, token); * If the page is a known alias it would be like
if (!arePathsEquivalent(currentPath, newPath)) { * /preset/{alias} <- locked to a preset
// if current path is out of date * or
// return new path so we can redirect * /{target}?alias={alias} <- unwrapped preset options
return newPath; *
} * we need to compare the saved preset to the current path to see if we need to redirect
*/
if (preset.alias === currentURL || preset.target === currentURL) {
const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
/**
* if the current path is equivalent to the new path, we return null
* this means we will not redirect
*/
return arePathsEquivalent(currentPath, newPath) ? null : newPath;
} }
} }
return null; return null;
} }
/**
* Resolves the current path accounting for the base URI
* Returns the alias if it's a preset path, or the last segment otherwise
*/
export function getCurrentPath(location: Path): string {
// 1. get path without query parameters
const pathWithoutQuery = location.pathname.split('?')[0];
// 2. split path into segments and filter out empty segments
const segments = pathWithoutQuery.split('/').filter(Boolean);
// If this is a preset path, return the alias (last segment)
if (segments[0] === 'preset' && segments.length > 1) {
return segments[1];
}
// Otherwise return the last segment (view name)
return segments[segments.length - 1] || '';
}
/** /**
* Handles generating a path and search parameters from a preset * Handles generating a path and search parameters from a preset
* This is done when we want to keep the current navigation and unwrap the search params
*/ */
export function generatePathFromPreset( export function generatePathFromPreset(
target: Omit<OntimeView, 'editor'>, target: Omit<OntimeView, 'editor'>,
search: string, search: string,
alias: string, alias: string,
locked: string | null, locked: boolean,
token: string | null, token: string | null,
): string { ): string {
const path = resolvePath(`${target}?${search}`); const path = resolvePath(`${target}?${search}`);
@@ -93,7 +103,7 @@ export function generatePathFromPreset(
// maintain params from the URL search feature // maintain params from the URL search feature
if (locked) { if (locked) {
searchParams.set('locked', locked); searchParams.set('n', '1');
} }
if (token) { if (token) {
@@ -106,28 +116,27 @@ export function generatePathFromPreset(
/** /**
* Utility checks if two paths are equivalent * Utility checks if two paths are equivalent
* Considers the edge cases for url sharing where a path may contain extra arguments from the alias * For preset paths, only compares the path (since params are stored in session)
* - token * For regular paths, compares path and search params (ignoring token)
* - locked
*/ */
export function arePathsEquivalent(currentPath: string, newPath: string): boolean { export function arePathsEquivalent(currentPath: string, newPath: string): boolean {
const currentUrl = new URL(currentPath, document.location.origin); const currentUrl = new URL(currentPath, document.location.origin);
const newUrl = new URL(newPath, document.location.origin); const newUrl = new URL(newPath, document.location.origin);
// check path // For preset paths, only compare the path
if (currentUrl.pathname.startsWith('/preset/') || newUrl.pathname.startsWith('/preset/')) {
return currentUrl.pathname === newUrl.pathname;
}
// For regular paths, compare path and search params (ignoring token)
if (currentUrl.pathname !== newUrl.pathname) { if (currentUrl.pathname !== newUrl.pathname) {
return false; return false;
} }
// check search params
// if the params match, we dont need further checks
if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) {
return true;
}
// if there is no match, we check the edge cases for the url sharing feature
currentUrl.searchParams.delete('token'); currentUrl.searchParams.delete('token');
currentUrl.searchParams.delete('locked'); currentUrl.searchParams.delete('n');
newUrl.searchParams.delete('token');
newUrl.searchParams.delete('n');
return currentUrl.searchParams.toString() === newUrl.searchParams.toString(); return currentUrl.searchParams.toString() === newUrl.searchParams.toString();
} }
@@ -136,33 +145,35 @@ export function arePathsEquivalent(currentPath: string, newPath: string): boolea
* Generates a URL preset from a user given alias and URL. * Generates a URL preset from a user given alias and URL.
*/ */
export function generateUrlPresetOptions(alias: string, userUrl: string): URLPreset { export function generateUrlPresetOptions(alias: string, userUrl: string): URLPreset {
let sanitisedUrl = userUrl.toLowerCase(); let sanitisedUrl = userUrl;
// we need to ensure the URL has a protocol, but it doesnt matter which // we need to ensure the URL has a protocol, but it doesnt matter which
if (!checkRegex.startsWithHttp(sanitisedUrl)) { if (!checkRegex.startsWithHttp(userUrl.toLowerCase())) {
sanitisedUrl = `http://${sanitisedUrl}`; sanitisedUrl = `http://${sanitisedUrl}`;
} }
const url = new URL(sanitisedUrl); const url = new URL(sanitisedUrl);
const target = extractLastSegment(url.pathname); const path = getCurrentPath(url);
if (target === 'editor' || !Object.values(OntimeView).includes(target as OntimeView)) { if (!isPresettableView(path)) {
throw new Error(`Invalid target view: ${target}`); throw new Error(`Invalid target view: ${path}`);
} }
return { return {
alias, alias,
target, target: path,
search: url.searchParams.toString(), search: url.searchParams.toString(),
enabled: true, enabled: true,
}; };
} }
/** function isPresettableView(view: string): view is OntimeViewPresettable {
* the path can contain the stage hash return view !== OntimeView.Editor && Object.values(OntimeView).includes(view as OntimeView);
* "/team-hash/timer" or "/timer" }
* we need to extract ontime view it targets
*/ /**
function extractLastSegment(pathname: string): string { * Check if current location is a preset path
const segments = pathname.split('/').filter(Boolean); */
return segments[segments.length - 1] || ''; export function isPresetPath(location: Path): boolean {
const segments = location.pathname.split('/').filter(Boolean);
return segments[0] === 'preset';
} }
+6
View File
@@ -1,3 +1,5 @@
import { AppMode } from '../ontimeConfig';
declare module '*.scss' { declare module '*.scss' {
const content: Record<string, string>; const content: Record<string, string>;
export default content; export default content;
@@ -26,6 +28,8 @@ declare global {
* - `handleUpdateTimer` callback to update the timer for a specific event * - `handleUpdateTimer` callback to update the timer for a specific event
* - `options-showDelayedTimes` whether to show or hide delayed times * - `options-showDelayedTimes` whether to show or hide delayed times
* - `options-hideTableSeconds` whether to hide seconds in the table * - `options-hideTableSeconds` whether to hide seconds in the table
* - `options-hideIndexColumn` whether to hide the index column
* - `options-cuesheetMode` run or edit mode
* *
* And metadata specific for each column * And metadata specific for each column
* - `canWrite` whether the user can write to this column * - `canWrite` whether the user can write to this column
@@ -39,6 +43,8 @@ declare module '@tanstack/react-table' {
options: { options: {
showDelayedTimes: boolean; showDelayedTimes: boolean;
hideTableSeconds: boolean; hideTableSeconds: boolean;
hideIndexColumn: boolean;
cuesheetMode: AppMode;
}; };
} }
+23
View File
@@ -74,3 +74,26 @@ function resolveBaseURI(): string {
return base; return base;
} }
/**
* Resolves a session scope for the session
*/
export const sessionScope = resolveSessionScope();
export const getIsNavigationLocked = () => new URLSearchParams(window.location.search).get('n') === '1';
/**
* The session scope is read from the cookie and will only exist if the app is password protected
*/
function resolveSessionScope() {
const tokenCookie = document.cookie.split('; ').find((cookie) => cookie.startsWith('token='));
if (tokenCookie) {
try {
const { scope } = JSON.parse(tokenCookie.split('=')[1]);
return scope;
} catch {
return 'rw';
}
}
return 'rw';
}
+1 -1
View File
@@ -1,6 +1,6 @@
/* eslint-disable react/display-name */ /* eslint-disable react/display-name */
import { ComponentType, useEffect } from 'react'; import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router';
import useUrlPresets from '../common/hooks-query/useUrlPresets'; import useUrlPresets from '../common/hooks-query/useUrlPresets';
import { getRouteFromPreset } from '../common/utils/urlPresets'; import { getRouteFromPreset } from '../common/utils/urlPresets';
@@ -12,12 +12,12 @@ interface PanelContentProps {
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) { export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return ( return (
<div className={style.contentWrapper}> <div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}> <div className={style.corner}>
<Button size='large' onClick={onClose}> <Button size='large' onClick={onClose}>
Close settings <IoClose /> Close settings <IoClose />
</Button> </Button>
</div> </div>
<div className={style.content}>{children}</div>
</div> </div>
); );
} }
@@ -106,7 +106,6 @@ $inner-padding: 1rem;
th, th,
td { td {
padding: 0.5rem; padding: 0.5rem;
vertical-align: top;
} }
tr:nth-child(even) { tr:nth-child(even) {
@@ -132,6 +131,7 @@ $inner-padding: 1rem;
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
align-items: center; align-items: center;
gap: 1rem;
padding: 0.5rem 0; padding: 0.5rem 0;
} }
@@ -42,7 +42,6 @@ interface AutomationFormProps {
} }
export default function AutomationForm({ automation, onClose }: AutomationFormProps) { export default function AutomationForm({ automation, onClose }: AutomationFormProps) {
'no memo'; // RHF and react-compiler dont seem to get along
const isEdit = isAutomation(automation); const isEdit = isAutomation(automation);
const { data } = useCustomFields(); const { data } = useCustomFields();
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
@@ -25,8 +25,6 @@ export default function AutomationSettingsForm({
enabledOscIn, enabledOscIn,
oscPortIn, oscPortIn,
}: AutomationSettingsProps) { }: AutomationSettingsProps) {
'no memo'; // RHF and react-compiler dont seem to get along
const { const {
handleSubmit, handleSubmit,
reset, reset,
@@ -38,15 +36,15 @@ export default function AutomationSettingsForm({
} = useForm<AutomationSettingsProps>({ } = useForm<AutomationSettingsProps>({
mode: 'onChange', mode: 'onChange',
defaultValues: { enabledAutomations, enabledOscIn, oscPortIn }, defaultValues: { enabledAutomations, enabledOscIn, oscPortIn },
values: { enabledAutomations, enabledOscIn, oscPortIn },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: false,
}, },
}); });
const onSubmit = async (formData: AutomationSettingsProps) => { const onSubmit = async (formData: AutomationSettingsProps) => {
try { try {
await editAutomationSettings(formData); await editAutomationSettings(formData);
reset(formData);
} catch (error) { } catch (error) {
const message = maybeAxiosError(error); const message = maybeAxiosError(error);
setError('root', { message }); setError('root', { message });
@@ -31,7 +31,6 @@ export default function TriggerForm({
onCancel, onCancel,
postSubmit, postSubmit,
}: TriggerFormProps) { }: TriggerFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const { const {
handleSubmit, handleSubmit,
register, register,
@@ -16,18 +16,18 @@ const staticAutocompleteOptions = [
'{{timer.duration}}', '{{timer.duration}}',
'{{timer.elapsed}}', '{{timer.elapsed}}',
'{{timer.expectedFinish}}', '{{timer.expectedFinish}}',
'{{timer.finishedAt}}',
'{{timer.secondaryTimer}}', '{{timer.secondaryTimer}}',
'{{timer.startedAt}}', '{{timer.startedAt}}',
'{{runtime.selectedEventIndex}}', '{{rundown.selectedEventIndex}}',
'{{runtime.numEvents}}', '{{rundown.numEvents}}',
'{{runtime.offset}}', '{{rundown.plannedStart}}',
'{{runtime.plannedStart}}', '{{rundown.plannedEnd}}',
'{{runtime.plannedEnd}}', '{{rundown.actualStart}}',
'{{runtime.actualStart}}', '{{offset.absolute}}',
'{{runtime.expectedEnd}}', '{{offset.relative}}',
'{{currentBlock.block}}', '{{offset.expectedRundownEnd}}',
'{{currentBlock.startedAt}}', '{{offset.expectedGroupEnd}}',
'{{offset.expectedFlagStart}}',
]; ];
const eventStaticPropertiesNow = [ const eventStaticPropertiesNow = [
@@ -1,10 +1,10 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { isOntimeCloud } from '../../../../externals'; import { isOntimeCloud } from '../../../../externals';
import GenerateLinkFormExport from '../../../sharing/GenerateLinkFormExport';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import InfoNif from '../network-panel/NetworkInterfaces'; import InfoNif from '../network-panel/NetworkInterfaces';
import GenerateLinkFormExport from './GenerateLinkFormExport';
import ReportSettings from './ReportSettings'; import ReportSettings from './ReportSettings';
import URLPresets from './URLPresets'; import URLPresets from './URLPresets';
@@ -1,144 +0,0 @@
'use no memo'; // RHF and react-compiler don't seem to get along
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import QRCode from 'react-qr-code';
import { generateUrl } from '../../../../common/api/session';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch';
import copyToClipboard from '../../../../common/utils/copyToClipboard';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { linkToOtherHost } from '../../../../common/utils/linkUtils';
import { currentHostName, isOntimeCloud, serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './GenerateLinkForm.module.scss';
interface GenerateLinkFormProps {
hostOptions: { value: string; label: string }[];
pathOptions: { value: string; label: string }[];
isLockedToView?: boolean;
}
interface GenerateLinkFormOptions {
baseUrl: string;
path: string;
lock: boolean;
authenticate: boolean;
}
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToView }: GenerateLinkFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const [formState, setFormState] = useState<GenerateLinkState>('pending');
const [url, setUrl] = useState(serverURL);
const {
handleSubmit,
setError,
watch,
setValue,
formState: { errors },
} = useForm<GenerateLinkFormOptions>({
mode: 'onChange',
defaultValues: {
baseUrl: currentHostName,
path: isLockedToView ? pathOptions[0].value : 'timer',
lock: false,
authenticate: false,
},
resetOptions: {
keepDirtyValues: true,
},
});
const onSubmit = async (options: GenerateLinkFormOptions) => {
try {
setFormState('loading');
const baseUrl = linkToOtherHost(options.baseUrl);
const url = await generateUrl(baseUrl, options.path, options.lock, options.authenticate);
await copyToClipboard(url);
setUrl(url);
setFormState('success');
setTimeout(() => {
setFormState('pending');
}, 4000);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
setFormState('error');
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
{!isLockedToView ? (
<Info>You can generate a link to share with your team or to use in automation (such as companion).</Info>
) : (
<Info>You can generate a link to share with your team</Info>
)}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Host IP'
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
/>
<Select
disabled={isOntimeCloud}
options={hostOptions}
value={watch('baseUrl')}
onValueChange={(value) => setValue('baseUrl', value)}
/>
</Panel.ListItem>
{isLockedToView ? (
<input type='hidden' value={watch('path')} />
) : (
<Panel.ListItem>
<Panel.Field title='Ontime view' description='Which view or preset will the link point to' />
<Select options={pathOptions} value={watch('path')} onValueChange={(value) => setValue('path', value)} />
</Panel.ListItem>
)}
<Panel.ListItem>
<Panel.Field
title='Lock navigation'
description='Prevent showing navigation (will only work for non production URLs)'
/>
<Switch
size='large'
name='lock'
checked={watch('lock')}
onCheckedChange={(checked) => setValue('lock', checked)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
<Switch
size='large'
name='authenticate'
checked={watch('authenticate')}
onCheckedChange={(checked) => setValue('authenticate', checked)}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Generate link' description='Fill form and generate link and QR code' />
<Button variant='primary' loading={formState === 'loading'} type='submit' style={{ alignSelf: 'end' }}>
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
</Button>
<div className={style.column}>
<QRCode size={172} value={url} className={style.qrCode} />
<div className={style.copiableLink}>{url}</div>
</div>
</Panel.ListItem>
</Panel.ListGroup>
</form>
);
}
@@ -1,42 +0,0 @@
import { useMemo } from 'react';
import useInfo from '../../../../common/hooks-query/useInfo';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import GenerateLinkForm from './GenerateLinkForm';
interface GenerateLinkFormExportProps {
lockedPath?: { value: string; label: string };
}
export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormExportProps) {
const { data: infoData } = useInfo();
const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined });
const hostOptions = useMemo(
() =>
infoData.networkInterfaces.map((nif) => ({
value: nif.address,
label: `${nif.name} - ${nif.address}`,
})),
[infoData.networkInterfaces],
);
const pathOptions = useMemo(() => {
if (lockedPath) {
return [{ value: lockedPath.value, label: lockedPath.label }];
}
return [
{ value: 'timer', label: 'Timer' },
{ value: 'cuesheet', label: 'Cuesheet' },
{ value: 'op', label: 'Operator' },
{ value: '', label: 'Companion' },
...urlPresetData.map((preset) => ({
value: preset.alias,
label: `URL Preset: ${preset.alias}`,
})),
];
}, [lockedPath, urlPresetData]);
return <GenerateLinkForm hostOptions={hostOptions} pathOptions={pathOptions} isLockedToView={Boolean(lockedPath)} />;
}
@@ -7,6 +7,7 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import Tag from '../../../../common/components/tag/Tag';
import useUrlPresets, { useUpdateUrlPreset } from '../../../../common/hooks-query/useUrlPresets'; import useUrlPresets, { useUpdateUrlPreset } from '../../../../common/hooks-query/useUrlPresets';
import { handleLinks } from '../../../../common/utils/linkUtils'; import { handleLinks } from '../../../../common/utils/linkUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -57,9 +58,8 @@ export default function URLPresets() {
<thead> <thead>
<tr> <tr>
<th>Enabled</th> <th>Enabled</th>
<th>Alias</th>
<th>Target view</th> <th>Target view</th>
<th>URL parameters</th> <th>Alias</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -71,9 +71,10 @@ export default function URLPresets() {
<td> <td>
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} /> <Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
</td> </td>
<td>{preset.alias}</td> <td>
<td>{preset.target}</td> <Tag>{preset.target}</Tag>
<td>{preset.search}</td> </td>
<td style={{ width: '100%' }}>{preset.alias}</td>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
<IconButton <IconButton
variant='ghosted-white' variant='ghosted-white'
@@ -1,10 +1,11 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { OntimeView, URLPreset } from 'ontime-types'; import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils'; import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button'; import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input'; import Input from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Select, { SelectOption } from '../../../../../common/components/select/Select'; import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets'; import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../../common/utils/keyEvent';
@@ -13,7 +14,7 @@ import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss'; import style from './URLPresetForm.module.scss';
const targetOptions: SelectOption[] = [ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.Cuesheet, label: 'Cuesheet' }, { value: OntimeView.Cuesheet, label: 'Cuesheet' },
{ value: OntimeView.Operator, label: 'Operator' }, { value: OntimeView.Operator, label: 'Operator' },
{ value: OntimeView.Timer, label: 'Timer' }, { value: OntimeView.Timer, label: 'Timer' },
@@ -114,7 +115,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<div className={style.expand}> <div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description> <Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<Panel.InlineElements> <Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} required /> <Input placeholder='Paste URL' fluid ref={urlRef} />
<Button onClick={generateOptions}>Generate</Button> <Button onClick={generateOptions}>Generate</Button>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
@@ -126,14 +127,15 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<Select <Select
options={targetOptions} options={targetOptions}
{...register('target', { required: 'Target is required' })} {...register('target', { required: 'Target is required' })}
value={watch('target') as OntimeView} value={watch('target')}
onValueChange={(value) => setValue('target', value)} onValueChange={(value) => setValue('target', value, { shouldDirty: true })}
/> />
</div> </div>
<div> <div>
<Panel.Description>Parameters</Panel.Description> <Panel.Description>Parameters</Panel.Description>
<Input <Textarea
fluid fluid
rows={3}
{...register('search', { {...register('search', {
validate: validateParams, validate: validateParams,
})} })}
@@ -6,14 +6,6 @@
width: 100%; width: 100%;
} }
.fieldForm {
padding: 1rem;
background-color: $gray-1350;
display: flex;
flex-direction: column;
gap: 1rem;
}
.twoCols { .twoCols {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -0,0 +1,63 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import * as Panel from '../../panel-utils/PanelUtils';
type NewRundownFormState = {
title: string;
};
interface ManageRundownForm {
onClose: () => void;
}
export function ManageRundownForm({ onClose }: ManageRundownForm) {
const { create } = useMutateProjectRundowns();
const {
handleSubmit,
register,
setFocus,
setError,
formState: { errors, isSubmitting },
} = useForm<NewRundownFormState>({
defaultValues: { title: '' },
});
const createRundown = async (values: NewRundownFormState) => {
try {
await create(values.title || 'untitled');
onClose();
} catch (error) {
setError('root', { message: `Failed to create rundown. ${error}` });
}
};
// give initial focus to the title field
useEffect(() => {
setFocus('title');
}, [setFocus]);
return (
<Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}>
<Panel.Section>
<label>
<Panel.Description>Rundown title</Panel.Description>
<Input {...register('title')} fluid placeholder='Your rundown name' />
</label>
</Panel.Section>
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={isSubmitting}>
Create rundown
</Button>
</Panel.InlineElements>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
</Panel.Indent>
);
}
@@ -1,18 +1,59 @@
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog'; import Dialog from '../../../../common/components/dialog/Dialog';
import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss'; import style from './ManagePanel.module.scss';
export default function ManageRundowns() { export default function ManageRundowns() {
const { data } = useProjectRundowns(); const { data } = useProjectRundowns();
const [deleteOpen, deleteHandlers] = useDisclosure(); const { remove, load } = useMutateProjectRundowns();
const [loadOpen, loadHandlers] = useDisclosure(); const [isOpenDelete, deleteHandlers] = useDisclosure();
const [isOpenLoad, loadHandlers] = useDisclosure();
const [isNewLoad, newHandlers] = useDisclosure();
const [targetRundown, setTargetRundown] = useState('');
const [actionError, setActionError] = useState<string | null>(null);
const openLoad = (id: string) => {
setActionError(null);
setTargetRundown(id);
loadHandlers.open();
};
const openDelete = (id: string) => {
setActionError(null);
setTargetRundown(id);
deleteHandlers.open();
};
const submitRundownLoad = async () => {
try {
await load(targetRundown);
} catch (error) {
setActionError(`Failed to load rundown. ${maybeAxiosError(error)}`);
} finally {
loadHandlers.close();
}
};
const submitRundownDelete = async () => {
try {
await remove(targetRundown);
} catch (error) {
setActionError(`Failed to delete rundown. ${maybeAxiosError(error)}`);
} finally {
deleteHandlers.close();
}
};
return ( return (
<> <>
@@ -21,49 +62,60 @@ export default function ManageRundowns() {
<Panel.SubHeader> <Panel.SubHeader>
Manage project rundowns Manage project rundowns
<Panel.InlineElements> <Panel.InlineElements>
<Button onClick={() => undefined} disabled> <Button
onClick={() => {
setActionError(null);
newHandlers.open();
}}
>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Table> <Panel.Section>
<thead> {isNewLoad && <ManageRundownForm onClose={newHandlers.close} />}
<tr> {actionError && <Panel.Error>{actionError}</Panel.Error>}
<th># Entries</th> <Panel.Table>
<th style={{ width: '100%' }}>Title</th> <thead>
<th /> <tr>
</tr> <th># Entries</th>
</thead> <th style={{ width: '100%' }}>Title</th>
<tbody> <th />
{data.rundowns.map((rundown) => { </tr>
const isLoaded = data.loaded === rundown.id; </thead>
return ( <tbody>
<tr key={rundown.id} className={cx([isLoaded && style.current])}> {data?.rundowns?.map(({ id, numEntries, title }) => {
<td>{rundown.numEntries}</td> const isLoaded = data.loaded === id;
<td>{`${rundown.title}${isLoaded && ' (loaded)'}`}</td> return (
<Panel.InlineElements as='td'> <tr key={id} className={cx([isLoaded && style.current])}>
<Button size='small' onClick={() => loadHandlers.open()} disabled={isLoaded}> <td>{numEntries}</td>
Load <td>
</Button> {title} {isLoaded && <Tag>Loaded</Tag>}
<Button </td>
size='small' <Panel.InlineElements as='td'>
variant='subtle-destructive' <Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}>
onClick={() => deleteHandlers.open()} Load
disabled={isLoaded} </Button>
> <Button
Delete size='small'
</Button> variant='subtle-destructive'
</Panel.InlineElements> onClick={() => openDelete(id)}
</tr> disabled={isLoaded}
); >
})} Delete
</tbody> </Button>
</Panel.Table> </Panel.InlineElements>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
<Dialog <Dialog
isOpen={deleteOpen} isOpen={isOpenDelete}
onClose={deleteHandlers.close} onClose={deleteHandlers.close}
title='Load rundown' title='Load rundown'
showBackdrop showBackdrop
@@ -78,16 +130,16 @@ export default function ManageRundowns() {
<Button size='large' onClick={deleteHandlers.close}> <Button size='large' onClick={deleteHandlers.close}>
Cancel Cancel
</Button> </Button>
<Button variant='destructive' size='large' onClick={() => undefined}> <Button variant='destructive' size='large' onClick={submitRundownDelete}>
Delete rundown Delete rundown
</Button> </Button>
</> </>
} }
/> />
<Dialog <Dialog
isOpen={loadOpen} isOpen={isOpenLoad}
onClose={loadHandlers.close} onClose={loadHandlers.close}
title='Delete rundown' title='Load rundown'
showBackdrop showBackdrop
showCloseButton showCloseButton
bodyElements={ bodyElements={
@@ -100,7 +152,7 @@ export default function ManageRundowns() {
<Button size='large' onClick={loadHandlers.close}> <Button size='large' onClick={loadHandlers.close}>
Cancel Cancel
</Button> </Button>
<Button variant='primary' size='large' onClick={() => undefined}> <Button variant='primary' size='large' onClick={submitRundownLoad}>
Load rundown Load rundown
</Button> </Button>
</> </>
@@ -32,7 +32,6 @@ export default function CustomFieldForm({
initialLabel, initialLabel,
initialKey, initialKey,
}: CustomFieldsFormProps) { }: CustomFieldsFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const { data } = useCustomFields(); const { data } = useCustomFields();
// we use this to force an update // we use this to force an update
@@ -84,11 +83,7 @@ export default function CustomFieldForm({
const isEditMode = initialKey !== undefined; const isEditMode = initialKey !== undefined;
return ( return (
<form <Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
onSubmit={handleSubmit(setupSubmit)}
className={style.fieldForm}
onKeyDown={(event) => preventEscape(event, onCancel)}
>
<Info> <Info>
Please note that images can quickly deteriorate your app&apos;s performance. Please note that images can quickly deteriorate your app&apos;s performance.
<br /> <br />
@@ -108,7 +103,7 @@ export default function CustomFieldForm({
/> />
</div> </div>
<div className={style.twoCols}> <div className={style.twoCols}>
<div> <label>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description> <Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>} {errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input <Input
@@ -117,7 +112,8 @@ export default function CustomFieldForm({
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'), onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
validate: (value) => { validate: (value) => {
if (value.trim().length === 0) return 'Required field'; if (value.trim().length === 0) return 'Required field';
if (!checkRegex.isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed'; if (!checkRegex.isAlphanumericWithSpace(value))
return 'Only alphanumeric characters and space are allowed';
if (!isEditMode) { if (!isEditMode) {
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique'; if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
} }
@@ -126,17 +122,17 @@ export default function CustomFieldForm({
})} })}
fluid fluid
/> />
</div> </label>
<div> <label>
<Panel.Description>Key (use in Integrations and API)</Panel.Description> <Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} readOnly fluid /> <Input {...register('key')} variant='ghosted' readOnly fluid />
</div> </label>
</div> </div>
<div> <label>
<Panel.Description>Colour</Panel.Description> <Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} /> <SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</div> </label>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'> <Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}> <Button variant='ghosted' onClick={onCancel}>
@@ -146,6 +142,6 @@ export default function CustomFieldForm({
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</form> </Panel.Indent>
); );
} }
@@ -31,7 +31,6 @@ export default function ImportMapForm({
onSubmitExport, onSubmitExport,
onSubmitImport, onSubmitImport,
}: ImportMapFormProps) { }: ImportMapFormProps) {
'use no memo'; // RHF and react-compiler don't seem to get along
const namedImportMap = getPersistedOptions(); const namedImportMap = getPersistedOptions();
const { revoke } = useGoogleSheet(); const { revoke } = useGoogleSheet();
const { const {
@@ -64,11 +64,11 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
} }
export function persistImportMap(options: NamedImportMap) { export function persistImportMap(options: NamedImportMap) {
localStorage.setItem('ontime-import-options', JSON.stringify(options)); localStorage.setItem('import-options', JSON.stringify(options));
} }
function getPersistImportMap(): unknown { function getPersistImportMap(): unknown {
const options = localStorage.getItem('ontime-import-options'); const options = localStorage.getItem('import-options');
if (!options) { if (!options) {
throw new Error('no import options found'); throw new Error('no import options found');
} }
@@ -12,6 +12,7 @@ tr .secondaryRow {
} }
.linkStartActive { .linkStartActive {
flex-shrink: 0;
color: $active-indicator; color: $active-indicator;
transform: rotate(-45deg); transform: rotate(-45deg);
} }
@@ -1,6 +1,6 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { IoLink } from 'react-icons/io5'; import { IoLink } from 'react-icons/io5';
import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } from 'ontime-types'; import { CustomFields, isOntimeEvent, isOntimeGroup, isOntimeMilestone, Rundown } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import Tag from '../../../../../../common/components/tag/Tag'; import Tag from '../../../../../../common/components/tag/Tag';
@@ -53,9 +53,10 @@ export default function PreviewRundown(props: PreviewRundownProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rundown.order.map((entryId) => { {rundown.flatOrder.map((entryId) => {
const entry = rundown.entries[entryId]; const entry = rundown.entries[entryId];
if (isOntimeBlock(entry)) { if (isOntimeGroup(entry)) {
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
return ( return (
<tr key={entry.id}> <tr key={entry.id}>
<td className={style.center}> <td className={style.center}>
@@ -64,11 +65,75 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<td className={style.center}> <td className={style.center}>
<Tag>{entry.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
<td /> <td /> {/** CUE */}
<td colSpan={99}>{entry.title}</td> <td>{entry.title}</td>
<td /> {/** Flag */}
<td /> {/** Time Start */}
<td /> {/** Time End */}
<td /> {/** Duration */}
<td /> {/** Warning Time */}
<td /> {/** Danger Time */}
<td /> {/** Count to end */}
<td /> {/** Skip */}
<td style={{ ...colour }}>{entry.colour}</td>
<td /> {/** Timer Type */}
<td /> {/** End Action */}
{fieldKeys.map((field) => {
let value = '';
if (field in entry.custom) {
value = entry.custom[field];
}
return <td key={field}>{value}</td>;
})}
<td className={style.center}>
<Tag>{entry.id}</Tag>
</td>
</tr> </tr>
); );
} }
if (isOntimeMilestone(entry)) {
const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
return (
<Fragment key={entry.id}>
<tr>
<td /> {/** Index */}
<td className={style.center}>
<Tag>{entry.type}</Tag>
</td>
<td className={style.nowrap}>{entry.cue}</td>
<td>{entry.title}</td>
<td /> {/** Flag */}
<td /> {/** Time Start */}
<td /> {/** Time End */}
<td /> {/** Duration */}
<td /> {/** Warning Time */}
<td /> {/** Danger Time */}
<td /> {/** Count to end */}
<td /> {/** Skip */}
<td style={{ ...colour }}>{entry.colour}</td>
<td /> {/** Timer Type */}
<td /> {/** End Action */}
{fieldKeys.map((field) => {
let value = '';
if (field in entry.custom) {
value = entry.custom[field];
}
return <td key={field}>{value}</td>;
})}
<td className={style.center}>
<Tag>{entry.id}</Tag>
</td>
</tr>
{entry.note && (
<tr>
<td colSpan={99} className={style.secondaryRow}>
Note: {entry.note}
</td>
</tr>
)}
</Fragment>
);
}
if (!isOntimeEvent(entry)) { if (!isOntimeEvent(entry)) {
return null; return null;
} }
@@ -107,14 +172,13 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<td className={style.center}> <td className={style.center}>
<Tag>{entry.endAction}</Tag> <Tag>{entry.endAction}</Tag>
</td> </td>
{isOntimeEvent(entry) && {fieldKeys.map((field) => {
fieldKeys.map((field) => { let value = '';
let value = ''; if (field in entry.custom) {
if (field in entry.custom) { value = entry.custom[field];
value = entry.custom[field]; }
} return <td key={field}>{value}</td>;
return <td key={field}>{value}</td>; })}
})}
<td className={style.center}> <td className={style.center}>
<Tag>{entry.id}</Tag> <Tag>{entry.id}</Tag>
</td> </td>
@@ -15,3 +15,7 @@
cursor: text ; cursor: text ;
user-select: text; user-select: text;
} }
.self {
background-color: $blue-1100;
}
@@ -8,6 +8,7 @@ import { RenameClientModal } from '../../../../../common/components/client-modal
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
import { setClientRemote } from '../../../../../common/hooks/useSocket'; import { setClientRemote } from '../../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../../common/stores/clientStore'; import { useClientStore } from '../../../../../common/stores/clientStore';
import { cx } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './ClientControlPanel.module.scss'; import style from './ClientControlPanel.module.scss';
@@ -71,13 +72,13 @@ export default function ClientList() {
const { identify, name, path } = client; const { identify, name, path } = client;
const isCurrent = id === key; const isCurrent = id === key;
return ( return (
<tr key={key}> <tr key={key} className={cx([isCurrent && style.self])}>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
{isCurrent && <Tag>SELF</Tag>} {isCurrent && <Tag>SELF</Tag>}
{name} {name}
</Panel.InlineElements> </Panel.InlineElements>
<td className={style.copiable}>{path}</td> <td className={style.copiable}>{path}</td>
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner' as='td'>
<Button <Button
size='small' size='small'
className={`${identify ? style.blink : ''}`} className={`${identify ? style.blink : ''}`}
@@ -1,6 +1,6 @@
import { ChangeEvent, useRef, useState } from 'react'; import { ChangeEvent, useRef, useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router';
import { uploadProjectFile } from '../../../../common/api/db'; import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';

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