Compare commits

...

95 Commits

Author SHA1 Message Date
Ary 1bc6c3e852 feat: lots of renaming 2024-02-25 11:32:53 -07:00
Ary 257f2259e0 feat: starting to cleanup 2024-02-25 11:25:06 -07:00
Ary 8ae3bb2df0 feat: create works, just not ideal 2024-02-25 11:19:27 -07:00
Ary ff1496d29f feat: renaming 2024-02-24 13:53:06 -07:00
Ary 04455c08ef feat: some cleanup 2024-02-24 11:54:45 -07:00
Ary 6c86ece1ff feat: deletion 2024-02-24 11:47:30 -07:00
Ary 9a2fa527c6 feat: works, but still need adjustments 2024-02-24 11:42:04 -07:00
Ary 165920b9f2 feat: wip for form 2024-02-24 11:23:05 -07:00
Ary ee3b3c0735 feat: wip 2024-02-15 17:00:23 -07:00
Ary ecaf0a209b wip 2024-02-14 11:52:24 -07:00
Carlos Valente 71dace669b fix: recover cursor logic (#773) 2024-02-13 18:43:41 +01:00
Carlos Valente 67c448572c style: tweaks to settings (#772) 2024-02-12 21:24:49 +01:00
Carlos Valente 53963a9ad7 Integrations data (#770)
* feat: OSC settings

* feat: HTTP settings
2024-02-11 21:25:23 +01:00
Carlos Valente 5355e45b80 refactor: UI for linking events (#763) 2024-02-11 15:18:04 +01:00
Ary 12f81c63ce Project Manager: Quick Start (#762) 2024-02-10 14:30:33 +01:00
Carlos Valente 7f78fac162 wip: UI for integrations (#769) 2024-02-09 13:56:04 +01:00
Carlos Valente e8b5cb6750 wip: add UI to manage custom fields (#767) 2024-02-09 13:51:40 +01:00
Carlos Valente 212e5f9e68 refactor: small type improvements (#759) 2024-02-03 23:17:36 +01:00
Carlos Valente 1890fc49d7 refactor: normalise data (#756)
* chore: remove legal from bundle

* refactor: create normalised dataset

* refactor: cuesheet uses flat rundown

* refactor: multi-selection

* refactor: prevent stale data on server restart

* refactor: increase ID size

* chore: instrument operation

* chore: update csv tests

* fix: resolve directory to test-db (#758)
2024-02-03 21:43:17 +01:00
Ary 47a519bac1 Merge pull request #753 from biancahpp/feat/download-and-export-options
Project Manager: Export
2024-02-01 16:03:41 -07:00
Ary 083b376113 Merge pull request #754 from cpvalente/fix/project_manager_init
Fix: Project manager list is blank on the first app load
2024-02-01 16:03:13 -07:00
Ary 5b20a47ff6 fix: fixed issue where there were no projects on the project list on the first load 2024-01-31 20:28:39 -07:00
Bianca Procopio c3880cd5a7 update downloadCSV and downloadRundown to accept a file name and add two new options to ProjectListItem 2024-01-31 19:11:00 -07:00
Carlos Valente 91fd275564 refactor: rundown service fetches data from data provider (#752) 2024-01-31 19:30:44 +01:00
Carlos Valente cf08bfc796 refactor: type improvements (#751) 2024-01-31 14:42:33 +01:00
Carlos Valente 7eb9ee8d03 refactor: flatten state mutations (#750) 2024-01-31 14:02:27 +01:00
Carlos Valente ec8952be56 chore: organise delay and rundown utils (#749) 2024-01-30 21:26:37 +01:00
Carlos Valente 6a5020424b refactor: improve typing in backend (#748) 2024-01-30 21:12:19 +01:00
Carlos Valente 39711c1edd refactor: time-to-end always updates (#739) 2024-01-30 12:35:54 +01:00
Ary 3c83f4d15b Project manager (#697)
* Project manager: Load, Rename, Duplicate (#712)

* Project Manager: Create, Delete (#723)
2024-01-29 14:06:33 +01:00
Carlos Valente 4c77d26a71 style: tweaks to editor (#738) 2024-01-28 22:42:19 +01:00
Carlos Valente 5de40e8679 fix: update timer on hot reload (#741) 2024-01-27 22:54:03 +01:00
Carlos Valente 32d70b3cd5 chore: split vendor bundle (#740) 2024-01-27 22:30:23 +01:00
Carlos Valente 86cec56a3d refactor: allow import of blocks and skip import (#735) 2024-01-27 15:08:01 +01:00
Carlos Valente f6557a9814 style: editor tweaks (#736)
* style: menu tweaks

* style: editor tweaks

* style: freeze
2024-01-26 14:47:27 +01:00
Carlos Valente c146954a2f refactor: excel cleanup (#734)
* refactor: remove import of project data from excel
2024-01-26 12:55:35 +01:00
Carlos Valente 2df3d376ad feat: many timers (#706)
* wip: create simple timer class

* refactor: service to manage extra timers

* wip:ui for controlling the extra timer

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2024-01-25 16:51:20 +01:00
Carlos Valente 74e1ae609c refactor: update timers (#729)
* refactor: remove duplication

* refactor: previous times

* refactor: event patching
2024-01-25 13:36:45 +01:00
Carlos Valente e748bff0dd chore: upload screenshots to artifacts (#730) 2024-01-24 21:56:01 +01:00
Carlos Valente b4ad533e6a refactor: cleanup store (#727)
* refactor: isolate clock

* refactor: isolate private

* refactor: remove duplicate

* chore: rename loaded > runtime

* fix: handle no events loaded
2024-01-24 00:20:10 +01:00
Alex Christoffer Rasmussen 0cd2ca4191 update API - part 1 (#709)
* api: `test-ontime` -> `version`

* api: `ontime-poll` -> `poll`

* api: `start-next` -> `startnext`

* api: `start-next` ->  `startnext`

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-01-23 08:43:00 +01:00
Carlos Valente cb8ba776e2 Event editor continue (#722)
* refactor: remove info panel

* feat: editor panel

* wip: event info in drawer

* chore: update tests

* chore: add scss to processor
2024-01-22 16:23:11 +01:00
Carlos Valente c7328a33f6 chore: fail tests that contain unhandled errors (#724) 2024-01-22 14:15:23 +01:00
Carlos Valente fc5800bc21 remove info panel (#721)
* refactor: remove info panel

* chore: update tests
2024-01-19 14:08:32 +01:00
Carlos Valente 3c1be1fc10 fix: issue with loosing cursor position on message (#719) 2024-01-17 23:16:22 +01:00
Carlos Valente a2950442d8 refactor: runtime service (#715)
* refactor: rename PlaybackService > RuntimeService

* refactor: stateful runtime service

* refactor: merge event loading

* refactor: roll is part of state mutation
2024-01-16 19:54:40 +01:00
Alex Christoffer Rasmussen ee121bcfcb change all ExcelImportMaps to lowercase (#716)
* fix: change all ExcelImportMaps to lowercase (#716)
2024-01-16 19:35:16 +01:00
Alex Christoffer Rasmussen 4234334fbb fix: test - go to Edit mode befor tying to click Event options button (#708) 2024-01-13 12:45:23 +01:00
Carlos Valente 7f63cde7a5 feat: multiple selection (#703)
feat: multiple event selection

---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Alex <ac@omnivox.dk>
2024-01-12 13:07:56 +01:00
Alex Rohleder f965db148c refactor: setup global timer state (#629)
* refactor: setup global timer state

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-01-12 13:03:58 +01:00
cv e6bb52ad9c chore: cleanup merge 2024-01-12 10:17:12 +01:00
cv 6d067f3d3a chore: cleanup merge 2024-01-12 10:13:49 +01:00
cv 506f9a868b chore: cleanup merge 2024-01-12 10:13:40 +01:00
cv adb8981191 chore: cleanup merge 2024-01-12 10:12:01 +01:00
cv a0b3e56896 chore: cleanup merge 2024-01-12 10:05:51 +01:00
cv 229fff05a4 Merge remote-tracking branch 'origin/master' into v3 2024-01-12 10:05:10 +01:00
Alex Christoffer Rasmussen 5b7045e6a5 chore: remove unused deepmerge (#705) 2024-01-10 19:30:34 +01:00
Alex Christoffer Rasmussen 7cc4364a63 chore: upgrade nanoid to v5 (#704) 2024-01-10 19:28:34 +01:00
Carlos Valente b4e73ff6b5 Refactor/time formatting (#696)
* chore: upgrade related dependencies

* fix: skip sentry on dev

* refactor: unify time formatting

* refactor: display default format for page
2024-01-09 15:30:11 +01:00
Carlos Valente 9031051465 Revert "style: align delay to clock"
This reverts commit 3fb862628c.
2024-01-08 22:55:57 +01:00
Carlos Valente a15a9671d2 Revert "chore: cleanup utils"
This reverts commit ecd13fdc5a.
2024-01-08 22:55:47 +01:00
Carlos Valente ecd13fdc5a chore: cleanup utils 2024-01-08 22:51:38 +01:00
Carlos Valente 3fb862628c style: align delay to clock 2024-01-08 22:48:25 +01:00
Carlos Valente 1f3133d4f3 chore: upgrade deps (#698) 2024-01-08 17:31:04 +01:00
Alex Christoffer Rasmussen 64573aff8a refactor: update lower third (#665)
* init

* auto in out

* fix in out animation and other options

* move some constants to css override

* dont show block if text source is empty

* select trigger

* adjust the animation

* remove the old one and replace it in the router

* return null from useEffect when there is no timer to clear

* typo: manuel -> manual

* fix: delay is excluding transition in time

* rename to top and bottom

* change font size to px and increese the default

* sharper animation timning

* fix: dont need state for options

* fix: e2e test for lower third

* deafault to ffffff00 key color

* typo: scss naming

* style: all white default bg key

* style: add default values to src options

* style: `white-space: nowrap;`

* style: start out animation a bit early

* style: browser tab title

* fix: checking the correct default values in ViewParamsEditor

* fix: defaultValues save improved by @asharonbaltazar

* lint: capitalize enum names

* fix: memoize options

* fix: searchParams test

* fix: readability of `topText` and `bottomText` and memoize them

* fix: enum naming upper case

* refactor: remove cx import

* refactor: try to simplify is else flow
2024-01-07 23:23:25 +01:00
Carlos Valente 655e9e8fb5 refactor: migrate logs (#673) 2024-01-06 21:48:20 +01:00
Carlos Valente bac2dc6b87 feat: create project list (#671) 2024-01-06 21:38:16 +01:00
Ary 65a5aaa665 Keep several project files in user folder - Steps 5A-E (#684) 2024-01-06 21:24:48 +01:00
Carlos Valente c889d00e6b fix: focus cursor (#695)
* fix: prevent cursor from removing focus
2024-01-06 09:10:42 +01:00
Carlos Valente 3753d51d85 chore: cleanup 2024-01-05 21:57:47 +01:00
Carlos Valente cb23b83b9d Merge remote-tracking branch 'origin/master' into v3 2024-01-05 21:55:16 +01:00
Alex Christoffer Rasmussen a567fdc446 build: upgrade lowdb (#689) 2024-01-02 20:27:10 +01:00
Carlos Valente 3d9a9a2226 wip: overview (#688)
* wip: overview
2024-01-01 22:06:23 +01:00
Carlos Valente 9a4d659b04 improvement: hide seconds (#675)
* feat: optional timer seconds
2023-12-31 12:13:05 +01:00
Fabian Posenau a5d8b91f34 add fields to import modal (#685)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-30 22:11:06 +01:00
Carlos Valente 5409c0ac6e refactor: stabilise actionHandler (#683) 2023-12-30 21:13:51 +01:00
Fabian Posenau 3998b8927d Warning and danger per event (#677)
* feat: warning and danger per event

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-30 20:56:57 +01:00
Ary 7b9f64a630 Project files tweaks (#679)
* chore: fixed issue when loading first time; deleting excel file after upload; only json files from project list

* chore: added last loaded project to endpoint response

* chore: incremental file name

---------

Co-authored-by: Name <Email>
2023-12-30 19:32:17 +01:00
Alex Christoffer Rasmussen c5c0401ac4 refactor: switch out steno for lowdb (#682)
* refactor: switch out steno for lowdb
2023-12-30 10:03:02 +01:00
Alex Christoffer Rasmussen 96280e5932 Feat: reorder events with alt+ctrl + arrow up/down (#645)
* fix: key guard

* feat: reorder events

* refactor: return index from the getEvent functions
2023-12-28 13:41:43 +01:00
Alex Christoffer Rasmussen b2c01a141a Studio clock (#663)
* refactor: trim rundown shows only upcoming

* style: small tweaks

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2023-12-28 13:25:45 +01:00
Ary dc4ba4012c feat: Several project files user folder (Steps 3-5) (#664)
* feat: steps 3-5 for several project files user folder

* feat: common types, better listing function

* feat: error response type
2023-12-28 13:23:51 +01:00
Carlos Valente d1e201ebb5 refactor: remove deprecated event data (#674) 2023-12-27 20:00:09 +01:00
Carlos Valente a463cb491b feat: app settings (#658)
* feat: app settings

* refactor: cleanup routes

* style: smaller base font

* chore: migrate about modal

* fix: import links
2023-12-25 21:25:09 +01:00
Carlos Valente 6ffc314513 chore: automated screenshots (#667)
* chore: take screenshots on routes

* chore: update test data

* chore: update test

* chore: upgrade node in CI
2023-12-25 19:41:45 +01:00
Ary f2632412aa Several project files user folder (#617)
* feat: save loaded file across sessions
2023-12-21 13:52:09 +01:00
Carlos Valente 062dc0c914 refactor: pass HTTP to integration controller (#652)
* refactor: pass HTTP to integration controller

* refactor: deprecate onair control

* refactor: remove playback router
2023-12-19 21:44:43 +01:00
Carlos Valente fe9f12a14b chore: lock node version to electron 2023-12-19 10:23:27 +01:00
Carlos Valente 3144888eb1 chore: add italian to languages (#656) 2023-12-19 10:05:09 +01:00
Carlos Valente 820eacde91 refactor: prevent accidental edit in operator view (#659)
* refactor: prevent accidental edit in operator view
2023-12-19 10:05:09 +01:00
Matteo Gheza 6886a3236f Add italian client translation (#655)
* Add Italian client translation
2023-12-19 10:05:09 +01:00
Carlos Valente bb541c551f chore: upgrade build deps (#661)
* chore: upgrade build dependencies

* chore: add bundle analysis script

* chore: upgrade sentry deps

* chore: upgrade node types

* docs: declare new engine versions
2023-12-19 10:03:59 +01:00
Carlos Valente 3196bd7802 chore: version bump 2023-12-17 20:57:49 +01:00
Carlos Valente ce0657cc6d style: smaller base font 2023-12-17 20:56:58 +01:00
Carlos Valente 44c2b1b5e3 refactor: cleanup routes 2023-12-17 20:56:42 +01:00
434 changed files with 16469 additions and 13168 deletions
+11 -21
View File
@@ -8,25 +8,12 @@
"jest": true "jest": true
}, },
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"extends": [ "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"],
"eslint:recommended", "plugins": ["@typescript-eslint", "prettier"],
"plugin:@typescript-eslint/recommended",
"prettier",
"eslint-config-prettier"
],
"plugins": [
"@typescript-eslint",
"prettier"
],
"overrides": [ "overrides": [
{ {
"files": [ "files": ["e2e/**/**.spec.ts", "e2e/**/**.test.ts"],
"e2e/**/**.spec.ts", "extends": ["plugin:playwright/playwright-test"]
"e2e/**/**.test.ts"
],
"extends": [
"plugin:playwright/playwright-test"
]
} }
], ],
"rules": { "rules": {
@@ -35,10 +22,13 @@
"no-console": [ "no-console": [
"warn", "warn",
{ {
"allow": [ "allow": ["warn", "error"]
"warn", }
"error" ],
] "no-restricted-imports": [
"error",
{
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
} }
], ],
"@typescript-eslint/no-non-null-assertion": "warn", "@typescript-eslint/no-non-null-assertion": "warn",
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18.18.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
@@ -49,7 +49,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18.18.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
@@ -82,7 +82,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18.18.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
+8 -2
View File
@@ -17,7 +17,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18.18.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
@@ -73,7 +73,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18.18.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v2 uses: pnpm/action-setup@v2
@@ -98,3 +98,9 @@ jobs:
name: playwright-report name: playwright-report
path: playwright-report/ path: playwright-report/
retention-days: 30 retention-days: 30
- uses: actions/upload-artifact@v3
with:
name: automated-screenshots
path: automated-screenshots/
retention-days: 14
+1
View File
@@ -27,6 +27,7 @@ ontime.code-workspace
test-results test-results
playwright-report playwright-report
/playwright/.cache/ /playwright/.cache/
automated-screenshots
# production # production
build/ build/
+2 -2
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- __node__ (>=16.16) - __node__ (~18.18.2)
- __pnpm__ (>=7) - __pnpm__ (>=8)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
+23 -23
View File
@@ -1,36 +1,35 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "2.28.11", "version": "3.0.0-alpha",
"private": true, "private": true,
"type": "module",
"dependencies": { "dependencies": {
"@chakra-ui/react": "^2.7.0", "@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.0.8", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^7.0.2", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.1", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.46.0", "@sentry/react": "^7.92.0",
"@sentry/tracing": "^7.46.0", "@tanstack/react-query": "^5.17.9",
"@tanstack/react-query": "^5.8.4", "@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-query-devtools": "^5.8.4", "@tanstack/react-table": "^8.11.3",
"@tanstack/react-table": "^8.9.2",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.2.0", "axios": "^1.2.0",
"color": "^4.2.3", "color": "^4.2.3",
"csv-stringify": "^6.2.3", "csv-stringify": "^6.4.5",
"deepmerge": "^4.3.0",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.43.5", "react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.11", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"typeface-open-sans": "^1.1.13", "typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
"zustand": "^4.3.6" "zustand": "^4.4.7"
}, },
"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",
@@ -45,7 +44,8 @@
"lint-staged": "eslint", "lint-staged": "eslint",
"test": "vitest", "test": "vitest",
"test:pipeline": "vitest run", "test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build" "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
"analyse": "npx vite-bundle-visualizer"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@@ -60,7 +60,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "^0.4.0", "@sentry/vite-plugin": "^2.14.0",
"@tanstack/eslint-plugin-query": "^5.8.4", "@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1", "@testing-library/react": "^13.1.1",
@@ -71,7 +71,7 @@
"@types/testing-library__jest-dom": "^5.14.5", "@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^3.0.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.53.0", "eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.6.0", "eslint-plugin-jest": "^27.6.0",
@@ -86,10 +86,10 @@
"prettier": "^3.0.3", "prettier": "^3.0.3",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^4.3.1", "vite": "^5.1.0",
"vite-plugin-compression2": "^0.9.0", "vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^2.4.0", "vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.2.0", "vite-tsconfig-paths": "^4.3.1",
"vitest": "^0.30.1" "vitest": "^1.2.2"
} }
} }
+1 -18
View File
@@ -15,7 +15,7 @@ const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage')); const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
const Public = lazy(() => import('./features/viewers/public/Public')); const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper')); const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock')); const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withAlias(withData(TimerView)); const STimer = withAlias(withData(TimerView));
@@ -31,27 +31,20 @@ const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'
const RundownPanel = lazy(() => import('./features/rundown/RundownExport')); const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport')); const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport')); const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
const Info = lazy(() => import('./features/info/InfoExport'));
export default function AppRouter() { export default function AppRouter() {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<Routes> <Routes>
<Route path='/' element={<Navigate to='/timer' />} /> <Route path='/' element={<Navigate to='/timer' />} />
<Route path='/speaker' element={<STimer />} />
<Route path='/presenter' element={<STimer />} />
<Route path='/stage' element={<STimer />} />
<Route path='/timer' element={<STimer />} /> <Route path='/timer' element={<STimer />} />
<Route path='/minimal' element={<SMinimalTimer />} /> <Route path='/minimal' element={<SMinimalTimer />} />
<Route path='/minimalTimer' element={<SMinimalTimer />} />
<Route path='/simpleTimer' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} /> <Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} /> <Route path='/countdown' element={<SCountdown />} />
<Route path='/sm' element={<SBackstage />} />
<Route path='/backstage' element={<SBackstage />} /> <Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} /> <Route path='/public' element={<SPublic />} />
@@ -65,8 +58,6 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} /> <Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/cuelist' element={<Cuesheet />} />
<Route path='/table' element={<Cuesheet />} />
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
@@ -93,14 +84,6 @@ export default function AppRouter() {
</EditorFeatureWrapper> </EditorFeatureWrapper>
} }
/> />
<Route
path='/info'
element={
<EditorFeatureWrapper>
<Info />
</EditorFeatureWrapper>
}
/>
{/*/!* Send to default if nothing found *!/*/} {/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} /> <Route path='*' element={<STimer />} />
</Routes> </Routes>
+8 -6
View File
@@ -1,19 +1,21 @@
// REST stuff // REST stuff
export const PROJECT_DATA = ['project'];
export const ALIASES = ['aliases']; export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN = ['rundown'];
export const APP_INFO = ['appinfo']; export const APP_INFO = ['appinfo'];
export const OSC_SETTINGS = ['oscSettings'];
export const HTTP_SETTINGS = ['httpSettings'];
export const APP_SETTINGS = ['appSettings']; export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings']; export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore']; export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState']; export const SHEET_STATE = ['sheetState'];
export const USERFIELDS = ['userFields'];
export const VIEW_SETTINGS = ['viewSettings'];
const location = window.location; const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production'; export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
const STATIC_PORT = 4001; const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT; export const serverPort = isProduction ? location.port : STATIC_PORT;
+1 -8
View File
@@ -42,12 +42,5 @@ export function logAxiosError(prepend: string, error: unknown) {
* Utility function invalidates react-query caches * Utility function invalidates react-query caches
*/ */
export async function invalidateAllCaches() { export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] }); await ontimeQueryClient.invalidateQueries();
await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] });
} }
+16 -3
View File
@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { GetRundownCached, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; import { OntimeEvent, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { rundownURL } from './apiConstants'; import { rundownURL } from './apiConstants';
@@ -7,7 +7,7 @@ import { rundownURL } from './apiConstants';
* @description HTTP request to fetch all events * @description HTTP request to fetch all events
* @return {Promise} * @return {Promise}
*/ */
export async function fetchCachedRundown(): Promise<GetRundownCached> { export async function fetchCachedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownURL}/cached`); const res = await axios.get(`${rundownURL}/cached`);
return res.data; return res.data;
} }
@@ -26,7 +26,7 @@ export async function fetchRundown(): Promise<OntimeRundown> {
* @description HTTP request to post new event * @description HTTP request to post new event
* @return {Promise} * @return {Promise}
*/ */
export async function requestPostEvent(data: OntimeRundownEntry) { export async function requestPostEvent(data: Partial<OntimeRundownEntry>) {
return axios.post(rundownURL, data); return axios.post(rundownURL, data);
} }
@@ -38,6 +38,19 @@ export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
return axios.put(rundownURL, data); return axios.put(rundownURL, data);
} }
type BatchEditEntry = {
data: Partial<OntimeEvent>;
ids: string[];
};
/**
* @description HTTP request to put multiple events
* @returns {Promise}
*/
export async function requestBatchPutEvents(data: BatchEditEntry) {
return axios.put(`${rundownURL}/batchEdit`, data);
}
export type ReorderEntry = { export type ReorderEntry = {
eventId: string; eventId: string;
from: number; from: number;
+103 -27
View File
@@ -4,10 +4,11 @@ import {
DatabaseModel, DatabaseModel,
GetInfo, GetInfo,
HttpSettings, HttpSettings,
MessageResponse,
OntimeRundown, OntimeRundown,
OSCSettings, OSCSettings,
OscSubscription,
ProjectData, ProjectData,
ProjectFileListResponse,
Settings, Settings,
UserFields, UserFields,
ViewSettings, ViewSettings,
@@ -72,13 +73,29 @@ export async function getAliases(): Promise<Alias[]> {
} }
/** /**
* @description HTTP request to mutate aliases * @description HTTP request to create an alias
* @return {Promise} * @return {Promise}
*/ */
export async function postAliases(data: Alias[]) { export async function postAlias(data: Alias) {
return axios.post(`${ontimeURL}/aliases`, data); return axios.post(`${ontimeURL}/aliases`, data);
} }
/**
* @description HTTP request to update aliases
* @return {Promise}
*/
export async function updateAliases(data: Partial<Alias>) {
return axios.put(`${ontimeURL}/aliases`, data);
}
/**
* @description HTTP request to delete alias
* @return {Promise}
*/
export async function deleteAlias(alias: string) {
return axios.delete(`${ontimeURL}/aliases/${alias}`);
}
/** /**
* @description HTTP request to retrieve user fields * @description HTTP request to retrieve user fields
* @return {Promise} * @return {Promise}
@@ -105,6 +122,14 @@ export async function getOSC(): Promise<OSCSettings> {
return res.data; return res.data;
} }
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(`${ontimeURL}/osc`, data);
}
/** /**
* @description HTTP request to retrieve http settings * @description HTTP request to retrieve http settings
* @return {Promise} * @return {Promise}
@@ -118,38 +143,26 @@ export async function getHTTP(): Promise<HttpSettings> {
* @description HTTP request to mutate http settings * @description HTTP request to mutate http settings
* @return {Promise} * @return {Promise}
*/ */
export async function postHTTP(data: HttpSettings) { export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(`${ontimeURL}/http`, data); return axios.post(`${ontimeURL}/http`, data);
} }
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
*/
export async function postOSC(data: OSCSettings) {
return axios.post(`${ontimeURL}/osc`, data);
}
/**
* @description HTTP request to mutate osc subscriptions
* @return {Promise}
*/
export async function postOscSubscriptions(data: OscSubscription) {
return axios.post(`${ontimeURL}/osc-subscriptions`, data);
}
/** /**
* @description HTTP request to download db in CSV format * @description HTTP request to download db in CSV format
*/ */
export const downloadCSV = () => { export const downloadCSV = (fileName?: string) => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' }); return fileDownload(ontimeURL, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
}; };
/** /**
* @description HTTP request to download db in JSON format * @description HTTP request to download db in JSON format
*/ */
export const downloadRundown = () => { export const downloadRundown = (fileName?: string) => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' }); return fileDownload(
ontimeURL,
{ name: fileName ?? 'rundown', type: 'json' },
{ type: 'application/json;charset=utf-8;' },
);
}; };
// TODO: should this be extracted to shared code? // TODO: should this be extracted to shared code?
@@ -195,7 +208,6 @@ export async function patchData(patchDb: Partial<DatabaseModel>) {
type PostPreviewExcelResponse = { type PostPreviewExcelResponse = {
rundown: OntimeRundown; rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields; userFields: UserFields;
}; };
@@ -242,8 +254,22 @@ export async function getLatestVersion(): Promise<HasUpdate> {
}; };
} }
export async function postNew(initialData: Partial<ProjectData>) { /**
return axios.post(`${ontimeURL}/new`, initialData); * @description HTTP request to get the list of available project files
*/
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${ontimeURL}/projects`);
return res.data;
}
/**
* @description HTTP request to load a project file
*/
export async function loadProject(filename: string): Promise<MessageResponse> {
const res = await axios.post(`${ontimeURL}/load-project`, {
filename,
});
return res.data;
} }
/** /**
@@ -318,3 +344,53 @@ export const postPushSheet = async (id: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options }); const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options });
return response.data.data; return response.data.data;
}; };
/**
* @description HTTP request to rename a project file
*/
export async function renameProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}/rename`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* @description HTTP request to duplicate a project file
*/
export async function duplicateProject(filename: string, newFilename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}/duplicate`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, {
newFilename,
});
return res.data;
}
/**
* @description HTTP request to delete a project file
*/
export async function deleteProject(filename: string): Promise<MessageResponse> {
const url = `${ontimeURL}/project/${filename}`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.delete(decodedUrl);
return res.data;
}
/**
* @description HTTP request to create a project file
*/
export async function createProject(
project: Partial<
ProjectData & {
filename: string;
}
>,
): Promise<MessageResponse> {
const url = `${ontimeURL}/project`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, project);
return res.data;
}
@@ -0,0 +1,12 @@
@use "../../../theme/mixins" as *;
.link {
@include action-link;
font-size: $inner-section-text-size;
}
.linkIcon {
margin-left: $element-inner-spacing;
display: inline-block;
transform: rotate(45deg);
}
@@ -0,0 +1,26 @@
import { MouseEvent, ReactNode } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { openLink } from '../../utils/linkUtils';
import style from './AppLink.module.scss';
interface AppLinkProps {
href: string;
children: ReactNode;
}
export default function AppLink(props: AppLinkProps) {
const { href, children } = props;
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
{children} <IoArrowUp className={style.linkIcon} />
</a>
);
}
@@ -10,14 +10,12 @@ import {
IconButton, IconButton,
Tooltip, Tooltip,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FiPower } from '@react-icons/all-files/fi/FiPower'; import { IoPowerOutline } from '@react-icons/all-files/io5/IoPowerOutline';
import { Size } from '../../models/Util.type';
import { useEmitLog } from '../../stores/logger'; import { useEmitLog } from '../../stores/logger';
interface QuitIconBtnProps { interface QuitIconBtnProps {
clickHandler: () => void; clickHandler: () => void;
size?: Size;
disabled?: boolean; disabled?: boolean;
} }
@@ -42,7 +40,7 @@ const quitBtnStyle = {
}; };
export default function QuitIconBtn(props: QuitIconBtnProps) { export default function QuitIconBtn(props: QuitIconBtnProps) {
const { clickHandler, size = 'lg', disabled, ...rest } = props; const { clickHandler, disabled, ...rest } = props;
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { emitInfo } = useEmitLog(); const { emitInfo } = useEmitLog();
const onClose = () => setIsOpen(false); const onClose = () => setIsOpen(false);
@@ -67,8 +65,8 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<Tooltip label='Quit Application'> <Tooltip label='Quit Application'>
<IconButton <IconButton
aria-label='Quit Application' aria-label='Quit Application'
size={size} size='lg'
icon={<FiPower />} icon={<IoPowerOutline />}
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
isDisabled={disabled} isDisabled={disabled}
{...quitBtnStyle} {...quitBtnStyle}
@@ -81,7 +79,7 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<AlertDialogHeader fontSize='lg' fontWeight='bold'> <AlertDialogHeader fontSize='lg' fontWeight='bold'>
Ontime Shutdown Ontime Shutdown
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogBody>This will shutdown the program and all running servers. Are you sure?</AlertDialogBody> <AlertDialogBody>This will shutdown the Ontime server. Are you sure?</AlertDialogBody>
<AlertDialogFooter> <AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant='ghost'> <Button ref={cancelRef} onClick={onClose} variant='ghost'>
Cancel Cancel
@@ -2,7 +2,7 @@ import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react'; import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps { interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: (event?: MouseEvent) => void | Promise<void>; clickHandler: (event: MouseEvent) => void | Promise<void>;
tooltip: string; tooltip: string;
openDelay?: number; openDelay?: number;
} }
@@ -1,24 +0,0 @@
@use '../../../theme/v2Styles' as *;
.header {
font-size: $inner-section-text-size;
font-weight: 600;
display: flex;
justify-content: space-between;
color: $section-white;
margin-top: $main-spacing;
border-bottom: 1px solid $border-color-ondark;
padding-bottom: $element-inner-spacing;
margin-bottom: $element-spacing;
cursor: pointer;
}
.moreExpanded {
transform: scaleY(-1);
transition: transform $transition-time-feedback;
}
.moreCollapsed {
transform: scaleY(1);
transition: transform $transition-time-feedback;
}
@@ -1,20 +0,0 @@
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import style from './CollapseBar.module.scss';
interface CollapseBarProps {
title: string;
isCollapsed: boolean;
onClick: () => void;
}
export default function CollapseBar(props: CollapseBarProps) {
const { title = 'Collapse bar', isCollapsed, onClick } = props;
return (
<div className={style.header} onClick={onClick}>
{title}
<FiChevronUp className={isCollapsed ? style.moreCollapsed : style.moreExpanded} />
</div>
);
}
@@ -1,6 +1,5 @@
.contextMenuButton { .contextMenuButton {
position: absolute; position: absolute;
cursor: pointer;
} }
.contextMenuBackdrop { .contextMenuBackdrop {
@@ -1,11 +1,13 @@
// logic (with some modifications) culled from: // logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx // https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { Fragment, ReactElement } from 'react'; import { ReactElement } from 'react';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react'; import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files'; import { IconType } from '@react-icons/all-files';
import { create } from 'zustand'; import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
import style from './ContextMenu.module.scss'; import style from './ContextMenu.module.scss';
type ContextMenuCoords = { type ContextMenuCoords = {
@@ -13,14 +15,23 @@ type ContextMenuCoords = {
y: number; y: number;
}; };
export type Option = { export type OptionWithoutGroup = {
label: string; label: string;
isDisabled?: boolean;
icon: IconType; icon: IconType;
onClick: () => void; onClick: () => void;
withDivider?: boolean; withDivider?: boolean;
isDisabled?: boolean;
}; };
export type OptionWithGroup = {
label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[];
};
export type Option = OptionWithoutGroup | OptionWithGroup;
const isOptionWithGroup = (option: Option): option is OptionWithGroup => 'group' in option;
type ContextMenuStore = { type ContextMenuStore = {
coords: ContextMenuCoords; coords: ContextMenuCoords;
options: Option[]; options: Option[];
@@ -69,14 +80,17 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
}} }}
/> />
<MenuList> <MenuList>
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => ( {options.map((option) =>
<Fragment key={label}> isOptionWithGroup(option) ? (
{withDivider && <MenuDivider />} <MenuGroup key={option.label} title={option.label}>
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}> {option.group.map((groupOption) => (
{label} <ContextMenuOption key={groupOption.label} {...groupOption} />
</MenuItem> ))}
</Fragment> </MenuGroup>
))} ) : (
<ContextMenuOption key={option.label} {...option} />
),
)}
</MenuList> </MenuList>
</Menu> </Menu>
</> </>
@@ -0,0 +1,12 @@
import { MenuDivider, MenuItem } from '@chakra-ui/react';
import { OptionWithoutGroup } from './ContextMenu';
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
<>
{withDivider && <MenuDivider />}
<MenuItem icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
);
@@ -1,5 +1,3 @@
@use '../../../theme/v2Styles' as *;
.delaySymbol { .delaySymbol {
svg { svg {
font-size: 1.5rem; font-size: 1.5rem;
@@ -17,7 +17,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (typeof delayValue === 'number') { if (typeof delayValue === 'number') {
if (delayValue < 0) { if (delayValue < 0) {
return ( return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}> <Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<span className={style.delaySymbol}> <span className={style.delaySymbol}>
<IoChevronDown /> <IoChevronDown />
</span> </span>
@@ -27,7 +27,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (delayValue > 0) { if (delayValue > 0) {
return ( return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}> <Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<span className={style.delaySymbol}> <span className={style.delaySymbol}>
<IoChevronUp /> <IoChevronUp />
</span> </span>
@@ -3,7 +3,7 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module. // skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { runtime } from '@/common/stores/runtime'; import { runtimeStore } from '@/common/stores/runtime';
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket'; import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
import style from './ErrorBoundary.module.scss'; import style from './ErrorBoundary.module.scss';
@@ -23,13 +23,13 @@ class ErrorBoundary extends React.Component {
componentDidCatch(error, info) { componentDidCatch(error, info) {
this.setState({ this.setState({
error: error, error,
errorInfo: info, errorInfo: info,
}); });
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setExtras('error', error); scope.setExtras('error', error);
scope.setExtras('store', runtime.getState()); scope.setExtras('store', runtimeStore.getState());
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts }); scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
@@ -1,5 +1,3 @@
@use '../../../theme/v2Styles' as *;
.errorContainer { .errorContainer {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -0,0 +1,17 @@
.link {
display: flex;
align-items: center;
gap: 0.25rem;
color: $blue-500;
transition-property: color;
transition-duration: $transition-time-action;
&.inline {
display: inline-flex;
}
&:hover {
color: $ontime-color;
}
}
@@ -0,0 +1,29 @@
import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import style from './ExternalLink.module.scss';
interface ExternalLinkProps {
href: string;
children: ReactNode;
inline?: boolean;
}
export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props;
const classes = cx([style.link, inline ? style.inline : null]);
const handleClick = (event: MouseEvent) => {
event.preventDefault();
openLink(href);
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
{children} <IoOpenOutline />
</a>
);
}
@@ -4,6 +4,8 @@ import { Textarea, TextareaProps } from '@chakra-ui/react';
import autosize from 'autosize/dist/autosize'; import autosize from 'autosize/dist/autosize';
export const AutoTextArea = (props: TextareaProps) => { export const AutoTextArea = (props: TextareaProps) => {
const { value } = props;
const ref = useRef<HTMLTextAreaElement>(null); const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => { useEffect(() => {
@@ -12,7 +14,7 @@ export const AutoTextArea = (props: TextareaProps) => {
return () => { return () => {
autosize.destroy(node); autosize.destroy(node);
}; };
}, []); }, [value]);
return ( return (
<Textarea <Textarea
@@ -1,24 +1,24 @@
.list { .list {
display: flex; display: flex;
gap: 4px; flex-wrap: wrap;
gap: 0.5rem
} }
.swatch { .swatch {
cursor: pointer; cursor: pointer;
aspect-ratio: 1;
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
border-radius: 16px; aspect-ratio: 1;
border: 4px solid #262626; border-radius: 99px;
border: 2px solid $gray-1200;
&.selected { &.selected {
border: 2px solid #578AF4; border: 2px solid $blue-500;
} }
} }
.center { .center {
display: grid; display: grid;
place-content: center; place-content: center;
color: #578AF4; color: $ui-white;
} }
@@ -1,15 +1,13 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
import Swatch from './Swatch'; import Swatch from './Swatch';
import style from './SwatchSelect.module.scss'; import style from './SwatchSelect.module.scss';
interface ColourInputProps { interface ColourInputProps {
value: string; value: string;
name: EditorUpdateFields; name: 'colour';
handleChange: (newValue: EditorUpdateFields, name: string) => void; handleChange: (newValue: 'colour', name: string) => void;
} }
const colours = [ const colours = [
@@ -1,5 +1,3 @@
@use '../../../../theme/v2Styles' as *;
$input-font-size: 15px; $input-font-size: 15px;
.delayInput { .delayInput {
@@ -1,6 +1,3 @@
@use "../../../../theme/_ontimeColours" as *;
@use "../../../../theme/_v2Styles" as *;
.swatch { .swatch {
width: 24px; width: 24px;
height: 24px; height: 24px;
@@ -1,7 +1,7 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { Input, Textarea } from '@chakra-ui/react'; import { Input, Textarea } from '@chakra-ui/react';
import { EventEditorSubmitActions } from '../../../../features/event-editor/EventEditor'; import { EventEditorSubmitActions } from '../../../../features/rundown/event-editor/EventEditor';
import { Size } from '../../../models/Util.type'; import { Size } from '../../../models/Util.type';
import useReactiveTextInput from './useReactiveTextInput'; import useReactiveTextInput from './useReactiveTextInput';
@@ -1,37 +1,4 @@
@use "../../../../theme/v2Styles" as *;
$input-font-size: 15px;
$input-delayed-border-color: #E69056;
.timeInput { .timeInput {
width: fit-content !important;
.inputLeft {
max-width: fit-content;
}
.inputLeft,
.inputButton {
aspect-ratio: 1;
}
.inputField {
font-size: $input-font-size;
letter-spacing: 1px; letter-spacing: 1px;
width: 7.5em; width: 6.5em;
padding: 0 0 0 2.6em;
}
.warn {
&::after {
content: "*";
color: $warning-orange;
}
}
&.delayed {
.inputField {
border: 1px solid $input-delayed-border-color;
}
}
} }
@@ -1,42 +1,23 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react'; import { Input } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import { useEmitLog } from '../../../stores/logger'; import { useEmitLog } from '../../../stores/logger';
import { forgivingStringToMillis } from '../../../utils/dateConfig'; import { forgivingStringToMillis } from '../../../utils/dateConfig';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
import { TimeEntryField } from '../../../utils/timesManager';
import style from './TimeInput.module.scss'; import style from './TimeInput.module.scss';
interface TimeInputProps<T extends string> {
interface TimeInputProps { name: T;
id?: TimeEntryField; submitHandler: (field: T, value: string) => void;
name: TimeEntryField;
submitHandler: (field: TimeEntryField, value: number) => void;
time?: number; time?: number;
delay?: number;
placeholder: string; placeholder: string;
previousEnd?: number; disabled?: boolean;
tooltip?: string; className?: string;
} }
function ButtonInitial(name: TimeEntryField) { export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
if (name === 'timeStart') return 'S'; const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
if (name === 'timeEnd') return 'E';
if (name === 'durationOverride') return 'D';
return '';
}
function ButtonTooltip(name: TimeEntryField, tooltip?: string) {
if (name === 'timeStart') return `Start${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'timeEnd') return `End${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'durationOverride') return `Duration${tooltip ? `: ${tooltip}` : ''}`;
return '';
}
export default function TimeInput(props: TimeInputProps) {
const { id, name, submitHandler, time = 0, delay = 0, placeholder, previousEnd = 0 } = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
@@ -72,32 +53,20 @@ export default function TimeInput(props: TimeInputProps) {
return false; return false;
} }
let newValMillis = 0; // we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) {
// check for known aliases submitHandler(name, newValue);
if (newValue === 'p' || newValue === 'prev' || newValue === 'previous') {
// string to pass should be the time of the end before
if (previousEnd != null) {
newValMillis = previousEnd;
}
} else if (newValue.startsWith('+') || newValue.startsWith('p+') || newValue.startsWith('p +')) {
// string to pass should add to the end before
const val = newValue.substring(1);
newValMillis = previousEnd + forgivingStringToMillis(val);
} else {
// convert entered value to milliseconds
newValMillis = forgivingStringToMillis(newValue);
} }
// check if time is different from before const valueInMillis = forgivingStringToMillis(newValue);
if (newValMillis === time) return false; if (valueInMillis === time) {
return false;
// update entry }
submitHandler(name, newValMillis);
submitHandler(name, newValue);
return true; return true;
}, },
[name, previousEnd, submitHandler, time], [name, submitHandler, time],
); );
/** /**
@@ -107,15 +76,11 @@ export default function TimeInput(props: TimeInputProps) {
const validateAndSubmit = useCallback( const validateAndSubmit = useCallback(
(newValue: string) => { (newValue: string) => {
const success = handleSubmit(newValue); const success = handleSubmit(newValue);
if (success) { if (!success) {
const ms = forgivingStringToMillis(newValue);
const delayed = name === 'timeEnd' ? Math.max(0, ms + delay) : Math.max(0, ms + delay);
setValue(millisToString(delayed));
} else {
resetValue(); resetValue();
} }
}, },
[delay, handleSubmit, name, resetValue], [handleSubmit, resetValue],
); );
/** /**
@@ -155,51 +120,26 @@ export default function TimeInput(props: TimeInputProps) {
resetValue(); resetValue();
}, [resetValue, time]); }, [resetValue, time]);
const isDelayed = delay !== 0; const timeInputClasses = cx([style.timeInput, className]);
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null]);
const TooltipLabel = useMemo(() => {
return ButtonTooltip(name, '');
}, [name]);
const ButtonText = useMemo(() => {
return ButtonInitial(name);
}, [name]);
return ( return (
<InputGroup size='sm' className={inputClasses}> <Input
<InputLeftElement className={style.inputLeft}> disabled={disabled}
<Tooltip label={TooltipLabel} openDelay={tooltipDelayFast} variant='ontime-ondark'> size='sm'
<Button ref={inputRef}
size='sm' data-testid={`time-input-${name}`}
variant='ontime-subtle-white' className={timeInputClasses}
className={buttonClasses} fontSize='1rem'
tabIndex={-1} type='text'
border={isDelayed ? '1px solid #E69056' : '1px solid transparent'} placeholder={placeholder}
borderRight='1px solid transparent' variant='ontime-filled'
borderRadius='2px 0 0 2px' onFocus={handleFocus}
> onChange={(event) => setValue(event.target.value)}
{ButtonText} onBlur={onBlurHandler}
</Button> onKeyDown={onKeyDownHandler}
</Tooltip> value={value}
</InputLeftElement> maxLength={8}
<Input autoComplete='off'
ref={inputRef} />
id={id}
data-testid={`time-input-${name}`}
className={style.inputField}
type='text'
placeholder={placeholder}
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
/>
</InputGroup>
); );
} }
@@ -0,0 +1,12 @@
$input-delayed-border-color: $ontime-delay-text;
.timeInput {
border: 1px solid transparent;
&.delayed {
border: 1px solid $input-delayed-border-color;
}
}
.inputField {
max-width: 7.75em;
}
@@ -0,0 +1,40 @@
import { PropsWithChildren } from 'react';
import { InputGroup, Tooltip } from '@chakra-ui/react';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import { cx } from '../../../utils/styleUtils';
import TimeInput from './TimeInput';
import style from './TimeInputWithButton.module.scss';
interface TimeInputWithButtonProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
hasDelay?: boolean;
disabled?: boolean;
placeholder: string;
}
export default function TimeInputWithButton<T extends string>(props: PropsWithChildren<TimeInputWithButtonProps<T>>) {
const { name, submitHandler, time, hasDelay, placeholder, disabled, children } = props;
const inputClasses = cx([style.timeInput, hasDelay ? style.delayed : null]);
return (
<InputGroup size='sm' className={inputClasses} width='fit-content'>
<Tooltip label={placeholder} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<TimeInput<T>
name={name}
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
className={style.inputField}
disabled={disabled}
/>
</Tooltip>
{children}
</InputGroup>
);
}
@@ -1,14 +1,16 @@
import { MaybeNumber } from 'ontime-types';
import { clamp } from '../../utils/math'; import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss'; import './MultiPartProgressBar.scss';
interface MultiPartProgressBar { interface MultiPartProgressBar {
now: number | null; now: MaybeNumber;
complete: number; complete: number;
normalColor: string; normalColor: string;
warning: number; warning?: MaybeNumber;
warningColor: string; warningColor: string;
danger: number; danger?: MaybeNumber;
dangerColor: string; dangerColor: string;
hidden?: boolean; hidden?: boolean;
className?: string; className?: string;
@@ -19,8 +21,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100); const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = clamp((danger / complete) * 100, 0, 100); const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = clamp((warning / complete) * 100, 0, 100); const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
return ( return (
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}> <div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
@@ -1,6 +1,4 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/mixins' as *; @use '../../../theme/mixins' as *;
@use '../../../theme/ontimeColours' as *;
$menu-bg: $gray-1200; $menu-bg: $gray-1200;
$menu-hover-bg: $gray-1350; $menu-hover-bg: $gray-1350;
@@ -71,7 +69,6 @@ $button-size: 48px;
@include action-link; @include action-link;
justify-content: space-between; justify-content: space-between;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
cursor: pointer;
&:hover { &:hover {
background-color: $menu-hover-bg; background-color: $menu-hover-bg;
@@ -1,4 +1,4 @@
import { KeyboardEvent, memo, useEffect, useRef, useState } from 'react'; import { memo, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Link, useLocation, useSearchParams } from 'react-router-dom'; import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react'; import { useDisclosure } from '@chakra-ui/react';
@@ -13,6 +13,7 @@ import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside'; import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen'; import useFullscreen from '../../hooks/useFullscreen';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import RenameClientModal from './rename-client-modal/RenameClientModal'; import RenameClientModal from './rename-client-modal/RenameClientModal';
@@ -53,7 +54,6 @@ function NavigationMenu() {
}; };
}, []); }, []);
const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter';
const handleFullscreen = () => toggleFullScreen(); const handleFullscreen = () => toggleFullScreen();
const handleMirror = () => toggleMirror(); const handleMirror = () => toggleMirror();
@@ -30,7 +30,7 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
const handleRename = async () => { const handleRename = async () => {
if (newName) { if (newName) {
await setClientName(newName); setClientName(newName);
persistName(newName); persistName(newName);
onClose(); onClose();
} }
@@ -1,5 +1,3 @@
@use '../../../theme/v2Styles' as *;
.container { .container {
display: grid; display: grid;
place-content: center; place-content: center;
@@ -4,7 +4,8 @@ import { formatTime } from '../../utils/time';
import './Schedule.scss'; import './Schedule.scss';
const formatOptions = { const formatOptions = {
format: 'hh:mm a', format12: 'hh:mm a',
format24: 'HH:mm',
}; };
interface ScheduleItemProps { interface ScheduleItemProps {
@@ -1,5 +1,3 @@
@use '../../../theme/ontimeColours' as *;
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
@@ -1,6 +1,6 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { ReactComponent as Emptyimage } from '@/assets/images/empty.svg'; import EmptyImage from '../../../assets/images/empty.svg?react';
import style from './Empty.module.scss'; import style from './Empty.module.scss';
@@ -13,7 +13,7 @@ export default function Empty(props: EmptyProps) {
const { text, ...rest } = props; const { text, ...rest } = props;
return ( return (
<div className={style.emptyContainer} {...rest}> <div className={style.emptyContainer} {...rest}>
<Emptyimage className={style.empty} /> <EmptyImage className={style.empty} />
<span className={style.text}>{text}</span> <span className={style.text}>{text}</span>
</div> </div>
); );
@@ -1,33 +0,0 @@
import { memo } from 'react';
import { formatDisplay } from 'ontime-utils';
import './TimerDisplay.scss';
interface TimerDisplayProps {
time?: number | null;
}
/**
* Displays time in ms in formatted timetag
* @param props
* @constructor
*/
const TimerDisplay = (props: TimerDisplayProps) => {
const { time } = props;
let display = '';
if (time === null || typeof time === 'undefined' || isNaN(time)) {
display = '-- : -- : --';
} else {
display = formatDisplay(time);
}
const isNegative = (time ?? 0) < 0;
const classes = `timer ${isNegative ? 'timer--finished' : ''}`;
return <div className={classes}>{display}</div>;
};
export default memo(TimerDisplay);
@@ -1,6 +1,3 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
.drawerContent { .drawerContent {
background-color: $gray-1250; background-color: $gray-1250;
} }
@@ -12,7 +12,7 @@ import {
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage'; import { useLocalStorage } from '../../hooks/useLocalStorage';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { ParamField } from './types'; import { ParamField } from './types';
@@ -23,14 +23,14 @@ type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>; type SavedViewParams = Record<string, ViewParamsObj>;
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => { const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.map(({ defaultValue }) => String(defaultValue)); const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
return { ...acc, [id]: String(defaultValue) };
}, {});
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => { return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) { if (typeof value === 'string' && value.length) {
if (defaultValues.includes(value)) { if (defaultValues[id] === value) {
return newSearchParams; return newSearchParams;
} }
newSearchParams.set(id, value); newSearchParams.set(id, value);
return newSearchParams; return newSearchParams;
@@ -1,18 +1,27 @@
import { UserFields } from 'ontime-types'; import { UserFields } from 'ontime-types';
import { TimeFormat } from 'ontime-types/src/definitions/core/TimeFormat.type';
import { ParamField } from './types'; import { ParamField } from './types';
export const getTimeOption = (timeFormat: TimeFormat): ParamField => ({ const getTimeOption = (timeFormat: string): ParamField => {
id: 'format', const placeholder = `${timeFormat} (default)`;
title: '12 / 24 hour timer', return {
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences', id: 'timeformat',
type: 'option', title: 'Time format string, taken from the Application Settings',
values: { '12': '12 hour AM/PM', '24': '24 hour' }, description: 'Format for auxiliar time fields (not the running), eg. HH:mm:ss or hh:mm:ss a, see docs for help',
defaultValue: timeFormat, type: 'string',
}); placeholder,
};
};
export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [ const hideTimerSeconds: ParamField = {
id: 'hideTimerSeconds',
title: 'Hide seconds in timer',
description: 'Whether to hide seconds in the running timer',
type: 'boolean',
defaultValue: false,
};
export const getClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat), getTimeOption(timeFormat),
{ {
id: 'key', id: 'key',
@@ -84,8 +93,9 @@ export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [
}, },
]; ];
export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [ export const getTimerOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat), getTimeOption(timeFormat),
hideTimerSeconds,
{ {
id: 'hideClock', id: 'hideClock',
title: 'Hide Time Now', title: 'Hide Time Now',
@@ -124,6 +134,7 @@ export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [
]; ];
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
hideTimerSeconds,
{ {
id: 'key', id: 'key',
title: 'Key Colour', title: 'Key Colour',
@@ -215,13 +226,97 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
}, },
]; ];
export const LOWER_THIRDS_OPTIONS: ParamField[] = [ export const LOWER_THIRD_OPTIONS: ParamField[] = [
{ {
id: 'size', id: 'trigger',
title: 'Size', title: 'Animation Trigger',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)', description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'event',
},
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
lowerMsg: 'Lower Thrid Message',
},
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the text source for the bottom element',
type: 'option',
values: {
title: 'Title',
subtitle: 'Subtitle',
presenter: 'Presenter',
lowerMsg: 'Lower Thrid Message',
},
defaultValue: 'subtitle',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number', type: 'number',
placeholder: '1 (default)', prefix: '%',
placeholder: '45 (default)',
}, },
{ {
id: 'transition', id: 'transition',
@@ -231,39 +326,31 @@ export const LOWER_THIRDS_OPTIONS: ParamField[] = [
placeholder: '3 (default)', placeholder: '3 (default)',
}, },
{ {
id: 'text', id: 'delay',
title: 'Text Colour', title: 'Delay',
description: 'Text colour in hexadecimal', description: 'Delay between transition in and out in seconds (default 3)',
prefix: '#', type: 'number',
type: 'string', placeholder: '3 (default)',
placeholder: 'fffffa (default)',
},
{
id: 'bg',
title: 'Text Background',
description: 'Text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
}, },
{ {
id: 'key', id: 'key',
title: 'Key Colour', title: 'Key Colour',
description: 'Screen background colour in hexadecimal', description: 'Colour of the background',
prefix: '#', prefix: '#',
type: 'string', type: 'string',
placeholder: '00000033 (default)', placeholder: 'ffffffff (default)',
}, },
{ {
id: 'fadeout', id: 'line-colour',
title: 'Fadeout', title: 'Line Colour',
description: 'Time (in seconds) the lower third displays before fading out', description: 'Colour of the line',
type: 'number', prefix: '#',
placeholder: '3 (default)', type: 'string',
placeholder: 'ff0000ff (default)',
}, },
]; ];
export const getBackstageOptions = (timeFormat: TimeFormat): ParamField[] => [ export const getBackstageOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat), getTimeOption(timeFormat),
{ {
id: 'hidePast', id: 'hidePast',
@@ -288,7 +375,7 @@ export const getBackstageOptions = (timeFormat: TimeFormat): ParamField[] => [
}, },
]; ];
export const getPublicOptions = (timeFormat: TimeFormat): ParamField[] => [ export const getPublicOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat), getTimeOption(timeFormat),
{ {
id: 'hidePast', id: 'hidePast',
@@ -312,27 +399,14 @@ export const getPublicOptions = (timeFormat: TimeFormat): ParamField[] => [
placeholder: '7 (default)', placeholder: '7 (default)',
}, },
]; ];
export const getStudioClockOptions = (timeFormat: TimeFormat): ParamField[] => [ export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat), getTimeOption(timeFormat),
{ hideTimerSeconds,
id: 'seconds',
title: 'Show Seconds',
description: 'Shows seconds in clock',
type: 'boolean',
defaultValue: false,
},
]; ];
export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeFormat): ParamField[] => { export const getOperatorOptions = (userFields: UserFields, timeFormat: string): ParamField[] => {
return [ return [
getTimeOption(timeFormat), getTimeOption(timeFormat),
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
defaultValue: false,
},
{ {
id: 'hidepast', id: 'hidepast',
title: 'Hide Past Events', title: 'Hide Past Events',
@@ -389,3 +463,5 @@ export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeForma
}, },
]; ];
}; };
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
@@ -1,5 +1,4 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { HttpSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/apiConstants'; import { HTTP_SETTINGS } from '../api/apiConstants';
@@ -20,13 +19,16 @@ export function useHttpSettings() {
}); });
// we need to jump through some hoops because of the type op port // we need to jump through some hoops because of the type op port
return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch }; return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
} }
export function usePostHttpSettings() { export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({ const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP, mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error), onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }), onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
}); });
return { isPending, mutateAsync }; return { isPending, mutateAsync };
@@ -3,17 +3,14 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants'; import { OSC_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils'; import { logAxiosError } from '../api/apiUtils';
import { getOSC, postOSC, postOscSubscriptions } from '../api/ontimeApi'; import { getOSC, postOSC } from '../api/ontimeApi';
import { oscPlaceholderSettings } from '../models/OscSettings'; import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() { export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS, queryKey: OSC_SETTINGS,
queryFn: async () => { queryFn: getOSC,
const oscData = await getOSC();
return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) };
},
placeholderData: oscPlaceholderSettings, placeholderData: oscPlaceholderSettings,
retry: 5, retry: 5,
retryDelay: (attempt: number) => attempt * 2500, retryDelay: (attempt: number) => attempt * 2500,
@@ -28,16 +25,9 @@ export function useOscSettingsMutation() {
const { isPending, mutateAsync } = useMutation({ const { isPending, mutateAsync } = useMutation({
mutationFn: postOSC, mutationFn: postOSC,
onError: (error) => logAxiosError('Error saving OSC settings', error), onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data), onSuccess: (res) => {
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }), ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
}); },
return { isPending, mutateAsync };
}
export function usePostOscSubscriptions() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postOscSubscriptions,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }), onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
}); });
return { isPending, mutateAsync }; return { isPending, mutateAsync };
@@ -16,5 +16,5 @@ export default function useProjectData() {
networkMode: 'always', networkMode: 'always',
}); });
return { data, status, isFetching, isError, refetch }; return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
} }
@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { ProjectFileListResponse } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/apiConstants';
import { getProjects } from '../api/ontimeApi';
const placeholderProjectList: ProjectFileListResponse = {
files: [],
lastLoadedProject: '',
};
export function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: getProjects,
placeholderData: placeholderProjectList,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? placeholderProjectList, status, refetch };
}
@@ -1,15 +1,16 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { GetRundownCached } from 'ontime-types'; import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig'; import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN } from '../api/apiConstants'; import { RUNDOWN } from '../api/apiConstants';
import { fetchCachedRundown } from '../api/eventsApi'; import { fetchCachedRundown } from '../api/eventsApi';
const cachedRundownPlaceholder = { rundown: [], revision: -1 }; // revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
// TODO: can we leverage structural sharing to see if data has changed?
export default function useRundown() { export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<GetRundownCached>({ const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchCachedRundown, queryFn: fetchCachedRundown,
placeholderData: cachedRundownPlaceholder, placeholderData: cachedRundownPlaceholder,
@@ -17,13 +18,24 @@ export default function useRundown() {
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval, refetchInterval: queryRefetchInterval,
networkMode: 'always', networkMode: 'always',
// structuralSharing: (oldData: GetRundownCached | undefined, newData: GetRundownCached) => {
// if (oldData === undefined) {
// return cachedRundownPlaceholder;
// }
// const hasDataChanged = oldData?.revision === newData.revision;
// return hasDataChanged ? oldData : newData;
// },
}); });
return { data: data?.rundown ?? [], status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
export function useFlatRundown() {
const { data, status } = useRundown();
const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.rundown[id]);
setFlatRunDown(flatRundown);
setPrevRevision(data.revision);
}
}, [data.order, data.revision, data.rundown, prevRevision]);
return { data: flatRunDown, status };
} }
+198 -58
View File
@@ -1,13 +1,14 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types'; import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils'; import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/apiConstants'; import { RUNDOWN } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils'; import { logAxiosError } from '../api/apiUtils';
import { import {
ReorderEntry, ReorderEntry,
requestApplyDelay, requestApplyDelay,
requestBatchPutEvents,
requestDelete, requestDelete,
requestDeleteAll, requestDeleteAll,
requestEventSwap, requestEventSwap,
@@ -17,6 +18,7 @@ import {
SwapEntry, SwapEntry,
} from '../api/eventsApi'; } from '../api/eventsApi';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
/** /**
* @description Set of utilities for events * @description Set of utilities for events
@@ -32,8 +34,6 @@ export const useEventAction = () => {
* @private * @private
*/ */
const _addEventMutation = useMutation({ const _addEventMutation = useMutation({
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
mutationFn: requestPostEvent, mutationFn: requestPostEvent,
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
@@ -67,21 +67,13 @@ export const useEventAction = () => {
after: options?.after, after: options?.after,
}; };
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? []; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this has a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
if (newEvent?.cue === undefined) { const { rundown } = rundownData;
newEvent.cue = getCueCandidate(rundown, options?.after);
}
// hard coding duration value to be as expected for now
// this until timeOptions gets implemented
if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) {
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
}
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) { if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId); const previousEvent = rundown[applicationOptions.lastEventId];
if (previousEvent !== undefined && previousEvent.type === 'event') { if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd; newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd; newEvent.timeEnd = previousEvent.timeEnd;
} }
@@ -98,7 +90,6 @@ export const useEventAction = () => {
} }
try { try {
// @ts-expect-error -- we know that the object is well formed now
await _addEventMutation.mutateAsync(newEvent); await _addEventMutation.mutateAsync(newEvent);
} catch (error) { } catch (error) {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
@@ -119,19 +110,15 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const eventId = newEvent.id;
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN); if (previousData && eventId) {
if (previousData) {
// optimistically update object // optimistically update object
const optimisticRundown = [...previousData.rundown]; const newRundown = { ...previousData.rundown };
const index = optimisticRundown.findIndex((event) => event.id === newEvent.id); // @ts-expect-error -- we expect the events to be of same type
if (index > -1) { newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
// @ts-expect-error -- we expect the event types to match queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
optimisticRundown[index] = { ...optimisticRundown[index], ...newEvent };
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -163,6 +150,154 @@ export const useEventAction = () => {
[_updateEventMutation], [_updateEventMutation],
); );
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
/**
* Updates time of existing event
*/
const updateTimer = useCallback(
async (eventId: string, field: TimeField, value: string) => {
const getPreviousEnd = (): number => {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) {
return 0;
}
const index = cachedRundown.order.indexOf(eventId);
if (index === 0) {
return 0;
}
let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) {
const event = cachedRundown.rundown[cachedRundown.order[i]];
if (isOntimeEvent(event)) {
previousEnd = event.timeEnd;
break;
}
}
return previousEnd;
};
let newValMillis = 0;
// check for previous keyword
if (value === 'p' || value === 'prev' || value === 'previous') {
newValMillis = getPreviousEnd();
// check for adding time keyword
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + forgivingStringToMillis(remainingString);
} else {
newValMillis = forgivingStringToMillis(value);
}
const newEvent = {
id: eventId,
[field]: newValMillis,
};
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation, queryClient],
);
/**
* Toggles link of an event to the previous
*/
const linkTimer = useCallback(
async (eventId: string, linkStart: MaybeString) => {
let newEvent: Partial<OntimeEvent> = { id: eventId };
if (!linkStart) {
newEvent.linkStart = null;
} else {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown) {
return;
}
const currentEvent = cachedRundown.rundown[eventId] as OntimeEvent;
if (!isOntimeEvent(currentEvent)) {
return;
}
const { previousEvent } = getPreviousEventNormal(cachedRundown.rundown, cachedRundown.order, eventId);
if (!previousEvent) {
newEvent.linkStart = null;
} else {
newEvent.linkStart = previousEvent.id;
const timePatch = getLinkedTimes(currentEvent, previousEvent);
newEvent = { ...newEvent, ...timePatch };
}
}
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation, queryClient],
);
/**
* Calls mutation to edit multiple events
* @private
*/
const _batchUpdateEventsMutation = useMutation({
mutationFn: requestBatchPutEvents,
onMutate: async ({ ids, data }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousEvents) {
const eventIds = new Set(ids);
const newRundown = { ...previousEvents.rundown };
eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
};
}
}
});
queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous and new events
return { previousEvents };
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousEvents);
},
networkMode: 'always',
});
const batchUpdateEvents = useCallback(
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
try {
await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
} catch (error) {
logAxiosError('Error updating events', error);
}
},
[_batchUpdateEventsMutation],
);
/** /**
* Calls mutation to delete an event * Calls mutation to delete an event
* @private * @private
@@ -175,20 +310,19 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const optimisticRundown = [...previousData.rundown]; const newOrder = previousData.order.filter((id) => id !== eventId);
const index = optimisticRundown.findIndex((event) => event.id === eventId); const newRundown = { ...previousData.rundown };
if (index > -1) { delete newRundown[eventId];
optimisticRundown.splice(index, 1);
queryClient.setQueryData(RUNDOWN, { queryClient.setQueryData(RUNDOWN, {
rundown: optimisticRundown, order: newOrder,
revision: -1, rundown: newRundown,
}); revision: -1,
} });
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -233,10 +367,10 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
// optimistically update object // optimistically update object
queryClient.setQueryData(RUNDOWN, { rundown: [], revision: -1 }); queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 });
// Return a context with the previous and new events // Return a context with the previous and new events
return { previousData }; return { previousData };
@@ -304,15 +438,13 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const optimisticRundown = [...previousData.rundown]; const newOrder = reorderArray(previousData.order, data.from, data.to);
const [reorderedItem] = optimisticRundown.splice(data.from, 1);
optimisticRundown.splice(data.to, 0, reorderedItem);
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 }); queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 });
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -338,9 +470,9 @@ export const useEventAction = () => {
async (eventId: string, from: number, to: number) => { async (eventId: string, from: number, to: number) => {
try { try {
const reorderObject: ReorderEntry = { const reorderObject: ReorderEntry = {
eventId: eventId, eventId,
from: from, from,
to: to, to,
}; };
await _reorderEventMutation.mutateAsync(reorderObject); await _reorderEventMutation.mutateAsync(reorderObject);
} catch (error) { } catch (error) {
@@ -362,15 +494,22 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const fromEventIndex = previousData.rundown.findIndex((event) => event.id === from); const newRundown = { ...previousData.rundown };
const toEventIndex = previousData.rundown.findIndex((event) => event.id === to); const eventA = previousData.rundown[from];
const eventB = previousData.rundown[to];
const optimisticRundown = swapOntimeEvents(previousData.rundown, fromEventIndex, toEventIndex); if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return;
}
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 }); const { newA, newB } = swapEventData(eventA, eventB);
newRundown[from] = newA;
newRundown[to] = newB;
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
} }
// Return a context with the previous events // Return a context with the previous events
@@ -394,8 +533,6 @@ export const useEventAction = () => {
*/ */
const swapEvents = useCallback( const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => { async ({ from, to }: SwapEntry) => {
// TODO: before calling `/swapEvents`,
// we should determine the events are of type `OntimeEvent`
try { try {
await _swapEvents.mutateAsync({ from, to }); await _swapEvents.mutateAsync({ from, to });
} catch (error) { } catch (error) {
@@ -407,11 +544,14 @@ export const useEventAction = () => {
return { return {
addEvent, addEvent,
updateEvent, applyDelay,
batchUpdateEvents,
deleteEvent, deleteEvent,
deleteAllEvents, deleteAllEvents,
applyDelay, linkTimer,
reorderEvent, reorderEvent,
swapEvents, swapEvents,
updateEvent,
updateTimer,
}; };
}; };
@@ -0,0 +1,38 @@
/**
* Shamelessly from https://ahooks.js.org/hooks/use-memoized-fn/
* Interesting technique discussed by Dan Abramov
* https://overreacted.io/making-setinterval-declarative-with-react-hooks/
*/
import { useMemo, useRef } from 'react';
import { isDev } from '../api/apiConstants';
type noop = (this: any, ...args: any[]) => any;
type PickFunction<T extends noop> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>;
export const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function';
export default function useMemoisedFn<T extends noop>(fn: T) {
if (isDev) {
if (!isFunction(fn)) {
console.error(`useMemoisedFn expected function as parameter, got ${typeof fn}`);
}
}
const fnRef = useRef<T>(fn);
// why not write `fnRef.current = fn`?
// https://github.com/alibaba/hooks/issues/728
fnRef.current = useMemo(() => fn, [fn]);
const memoizedFn = useRef<PickFunction<T>>();
if (!memoizedFn.current) {
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args);
};
}
return memoizedFn.current as T;
}
+97 -44
View File
@@ -1,73 +1,73 @@
import { RuntimeStore } from 'ontime-types'; import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { deepCompare, useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
export const useRundownEditor = () => { export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.playback, playback: state.timer.playback,
selectedEventId: state.loaded.selectedEventId, selectedEventId: state.eventNow?.id ?? null,
nextEventId: state.loaded.nextEventId, nextEventId: state.eventNext?.id ?? null,
}); });
return useRuntimeStore(featureSelector, deepCompare); return useRuntimeStore(featureSelector);
}; };
export const useOperator = () => { export const useOperator = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.playback, playback: state.timer.playback,
selectedEventId: state.loaded.selectedEventId, selectedEventId: state.eventNow?.id ?? null,
}); });
return useRuntimeStore(featureSelector, deepCompare); return useRuntimeStore(featureSelector);
}; };
export const useMessageControl = () => { export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
timerMessage: state.timerMessage, timer: state.message.timer,
publicMessage: state.publicMessage, public: state.message.public,
lowerMessage: state.lowerMessage, lower: state.message.lower,
externalMessage: state.externalMessage, external: state.message.external,
onAir: state.onAir, onAir: state.onAir,
}); });
return useRuntimeStore(featureSelector, deepCompare); return useRuntimeStore(featureSelector);
}; };
export const setMessage = { export const setMessage = {
presenterText: (payload: string) => socketSendJson('set-timer-message-text', payload), timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
presenterVisible: (payload: boolean) => socketSendJson('set-timer-message-visible', payload), timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
publicText: (payload: string) => socketSendJson('set-public-message-text', payload), publicText: (payload: string) => socketSendJson('message', { public: { text: payload } }),
publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload), publicVisible: (payload: boolean) => socketSendJson('message', { public: { visible: payload } }),
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload), lowerText: (payload: string) => socketSendJson('message', { lower: { text: payload } }),
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload), lowerVisible: (payload: boolean) => socketSendJson('message', { lower: { visible: payload } }),
externalText: (payload: string) => socketSendJson('set-external-message-text', payload), externalText: (payload: string) => socketSendJson('message', { external: { visible: payload } }),
externalVisible: (payload: boolean) => socketSendJson('set-external-message-visible', payload), externalVisible: (payload: boolean) => socketSendJson('message', { external: { visible: payload } }),
onAir: (payload: boolean) => socketSendJson('set-onAir', payload), onAir: (payload: boolean) => socketSendJson('onAir', payload),
timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload), timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload), timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
}; };
export const usePlaybackControl = () => { export const usePlaybackControl = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.playback, playback: state.timer.playback,
selectedEventIndex: state.loaded.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.loaded.numEvents, numEvents: state.runtime.numEvents,
}); });
return useRuntimeStore(featureSelector, deepCompare); return useRuntimeStore(featureSelector);
}; };
export const setPlayback = { export const setPlayback = {
start: () => socketSendJson('start'), start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'), pause: () => socketSendJson('pause'),
roll: () => socketSendJson('roll'), roll: () => socketSendJson('roll'),
startNext: () => socketSendJson('start-next'), startNext: () => socketSendJson('start', { next: true }),
previous: () => { previous: () => {
socketSendJson('previous'); socketSendJson('load', { previous: true });
}, },
next: () => { next: () => {
socketSendJson('next'); socketSendJson('load', { next: true });
}, },
stop: () => { stop: () => {
socketSendJson('stop'); socketSendJson('stop');
@@ -84,29 +84,52 @@ export const useInfoPanel = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
eventNow: state.eventNow, eventNow: state.eventNow,
eventNext: state.eventNext, eventNext: state.eventNext,
playback: state.playback, playback: state.timer.playback,
selectedEventIndex: state.loaded.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.loaded.numEvents, numEvents: state.runtime.numEvents,
}); });
return useRuntimeStore(featureSelector, deepCompare); return useRuntimeStore(featureSelector);
};
export const useExtraTimerTime = () => {
const featureSelector = (state: RuntimeStore) => state.timer1.current;
return useRuntimeStore(featureSelector);
};
export const useExtraTimerControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer1.playback,
direction: state.timer1.direction,
});
return useRuntimeStore(featureSelector);
};
export const setExtraTimer = {
start: () => socketSendJson('extratimer', SimplePlayback.Start),
pause: () => socketSendJson('extratimer', SimplePlayback.Pause),
stop: () => socketSendJson('extratimer', SimplePlayback.Stop),
setDirection: (direction: SimpleDirection) => socketSendJson('extratimer', { direction }),
setTime: (time: number) => socketSendJson('extratimer', { settime: time }),
}; };
export const useCuesheet = () => { export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.playback, playback: state.timer.playback,
selectedEventId: state.loaded.selectedEventId, selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.loaded.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.loaded.numEvents, numEvents: state.runtime.numEvents,
titleNow: state.eventNow?.title || '', titleNow: state.eventNow?.title || '',
}); });
return useRuntimeStore(featureSelector, deepCompare); return useRuntimeStore(featureSelector);
}; };
export const setEventPlayback = { export const setEventPlayback = {
loadEvent: (eventId: string) => socketSendJson('loadid', eventId), loadEvent: (id: string) => socketSendJson('load', { id }),
startEvent: (eventId: string) => socketSendJson('startid', eventId), startEvent: (id: string) => socketSendJson('start', { id }),
start: () => socketSendJson('start'), start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'), pause: () => socketSendJson('pause'),
}; };
@@ -116,7 +139,37 @@ export const useTimer = () => {
...state.timer, ...state.timer,
}); });
return useRuntimeStore(featureSelector, deepCompare); return useRuntimeStore(featureSelector);
};
export const useClock = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
});
return useRuntimeStore(featureSelector);
};
/** Used by the progress bar components */
export const useProgressData = () => {
const featureSelector = (state: RuntimeStore) => ({
addedTime: state.timer.addedTime,
current: state.timer.current,
duration: state.timer.duration,
timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null,
});
return useRuntimeStore(featureSelector);
}; };
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName); export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
export const useRuntimeOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
});
return useRuntimeStore(featureSelector);
};
+1 -8
View File
@@ -2,12 +2,5 @@ import { HttpSettings } from 'ontime-types';
export const httpPlaceholder: HttpSettings = { export const httpPlaceholder: HttpSettings = {
enabledOut: false, enabledOut: false,
subscriptions: { subscriptions: [],
onLoad: [],
onStart: [],
onUpdate: [],
onPause: [],
onStop: [],
onFinish: [],
},
}; };
+2 -16
View File
@@ -1,20 +1,6 @@
import { GetInfo, OSCSettings } from 'ontime-types'; import { GetInfo } from 'ontime-types';
export const oscPlaceholderSettings: OSCSettings = { import { oscPlaceholderSettings } from './OscSettings';
portIn: 0,
portOut: 0,
targetIP: '',
enabledIn: false,
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
};
export const ontimePlaceholderInfo: GetInfo = { export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [], networkInterfaces: [],
+5 -18
View File
@@ -1,23 +1,10 @@
import { OSCSettings } from 'ontime-types'; import { OSCSettings } from 'ontime-types';
// in the placeholder, we pass strings to satisfy input type export const oscPlaceholderSettings: OSCSettings = {
export interface PlaceholderSettings extends Omit<OSCSettings, 'portIn' | 'portOut'> { portIn: 8888,
portIn: string; portOut: 9999,
portOut: string; targetIP: '127.0.0.1',
}
export const oscPlaceholderSettings: PlaceholderSettings = {
portIn: '',
portOut: '',
targetIP: '',
enabledIn: false, enabledIn: false,
enabledOut: false, enabledOut: false,
subscriptions: { subscriptions: [],
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
}; };
@@ -1,18 +1,19 @@
import { Playback, TimerType } from 'ontime-types'; import { MaybeNumber, Playback, TimerType } from 'ontime-types';
export type TimeManagerType = { // first set extends TimerState
clock: number; export type ViewExtendedTimer = {
current: null | number;
elapsed: null | number;
duration: null | number;
timerBehaviour?: string;
timerType: TimerType;
expectedFinish: null | number;
addedTime: number; addedTime: number;
startedAt: null | number; current: MaybeNumber;
finishedAt: null | number; duration: MaybeNumber;
secondaryTimer: null | number; elapsed: MaybeNumber;
expectedFinish: MaybeNumber;
finished: boolean; finishedAt: MaybeNumber;
playback: Playback; playback: Playback;
secondaryTimer: MaybeNumber;
startedAt: MaybeNumber;
clock: number;
timeDanger: MaybeNumber;
timeWarning: MaybeNumber;
timerType: TimerType;
}; };
@@ -14,4 +14,5 @@ export type OverridableOptions = {
hideEndMessage?: boolean; hideEndMessage?: boolean;
language?: string; language?: string;
showProgressBar?: boolean; showProgressBar?: boolean;
hideTimerSeconds?: boolean;
}; };
@@ -4,8 +4,6 @@ export const viewsSettingsPlaceholder: ViewSettings = {
overrideStyles: false, overrideStyles: false,
normalColor: '#ffffffcc', normalColor: '#ffffffcc',
warningColor: '#FFAB33', warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333', dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '', endMessage: '',
}; };
+13 -47
View File
@@ -8,63 +8,29 @@ export enum AppMode {
const appModeKey = 'ontime-app-mode'; const appModeKey = 'ontime-app-mode';
function getModeFromSession() { function getModeFromSession() {
return localStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit; return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
} }
async function persistModeToSession(mode: AppMode) { function persistModeToSession(mode: AppMode) {
localStorage.setItem(appModeKey, mode); sessionStorage.setItem(appModeKey, mode);
} }
type AppModeStore = { type AppModeStore = {
mode: AppMode; mode: AppMode;
cursor: string | null;
editId: string | null;
setMode: (mode: AppMode) => void; setMode: (mode: AppMode) => void;
setCursor: (id: string | null, isEditable?: boolean) => void; cursor: string | null;
setEditId: (id: string | null) => void; setCursor: (cursor: string | null) => void;
}; };
export const useAppMode = create<AppModeStore>()((set) => ({ export const useAppMode = create<AppModeStore>()((set) => ({
mode: getModeFromSession(), mode: getModeFromSession(),
cursor: null, cursor: null,
editId: null, setMode: (mode: AppMode) => {
setMode: (mode: AppMode) => persistModeToSession(mode);
set((state) => {
persistModeToSession(mode); return set(() => {
return mode === AppMode.Edit return { mode };
? { });
editId: state.cursor, },
mode: mode, setCursor: (cursor: string | null) => set(() => ({ cursor })),
}
: {
editId: null,
mode: mode,
};
}),
setCursor: (id: string | null, isEditable?: boolean) =>
set((state) => {
if (isEditable) {
return state.mode === AppMode.Edit
? {
cursor: id,
editId: id,
}
: {
cursor: id,
};
} else {
return { cursor: id, editId: null };
}
}),
setEditId: (id: string | null) =>
set((state) => {
return state.mode === AppMode.Edit
? {
cursor: id,
editId: id,
}
: {
editId: id,
};
}),
})); }));
@@ -6,7 +6,6 @@ type EditorSettings = {
showQuickEntry: boolean; showQuickEntry: boolean;
startTimeIsLastEnd: boolean; startTimeIsLastEnd: boolean;
defaultPublic: boolean; defaultPublic: boolean;
showNif: boolean;
}; };
type EditorSettingsStore = { type EditorSettingsStore = {
@@ -15,14 +14,12 @@ type EditorSettingsStore = {
setShowQuickEntry: (showQuickEntry: boolean) => void; setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void; setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void; setDefaultPublic: (defaultPublic: boolean) => void;
setShowNif: (showNif: boolean) => void;
}; };
enum EditorSettingsKeys { enum EditorSettingsKeys {
ShowQuickEntry = 'ontime-show-quick-entry', ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end', StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public', DefaultPublic = 'ontime-default-public',
ShowNif = 'ontime-show-nif',
} }
export const useEditorSettings = create<EditorSettingsStore>((set) => ({ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
@@ -30,7 +27,6 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false), showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true), startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true), defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
showNif: booleanFromLocalStorage(EditorSettingsKeys.ShowNif, true),
}, },
setLocalEventSettings: (value) => setLocalEventSettings: (value) =>
@@ -58,10 +54,4 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic)); localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } }; return { eventSettings: { ...state.eventSettings, defaultPublic } };
}), }),
setShowNif: (showNif) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowNif, String(showNif));
return { eventSettings: { ...state.eventSettings, showNif } };
}),
})); }));
+43 -42
View File
@@ -1,64 +1,65 @@
import isEqual from 'react-fast-compare'; import isEqual from 'react-fast-compare';
import { Playback, RuntimeStore } from 'ontime-types'; import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { useStore } from 'zustand'; import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
import { createStore } from 'zustand/vanilla';
export const runtimeStorePlaceholder: RuntimeStore = { export const runtimeStorePlaceholder: RuntimeStore = {
clock: 0,
timer: { timer: {
clock: 0, addedTime: 0,
current: null, current: null,
duration: null,
elapsed: null, elapsed: null,
expectedFinish: null, expectedFinish: null,
addedTime: 0,
startedAt: null,
finishedAt: null, finishedAt: null,
playback: Playback.Stop,
secondaryTimer: null, secondaryTimer: null,
selectedEventId: null, startedAt: null,
duration: null,
timerType: null,
endAction: null,
},
playback: Playback.Stop,
timerMessage: {
text: '',
visible: false,
timerBlink: false,
timerBlackout: false,
},
publicMessage: {
text: '',
visible: false,
},
lowerMessage: {
text: '',
visible: false,
},
externalMessage: {
text: '',
visible: false,
}, },
onAir: false, onAir: false,
loaded: { message: {
timer: {
text: '',
visible: false,
blink: false,
blackout: false,
},
public: {
text: '',
visible: false,
},
lower: {
text: '',
visible: false,
},
external: {
text: '',
visible: false,
},
},
runtime: {
numEvents: 0, numEvents: 0,
selectedEventIndex: null, selectedEventIndex: null,
selectedEventId: null,
selectedPublicEventId: null,
nextEventId: null,
nextPublicEventId: null,
}, },
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
publicEventNow: null, publicEventNow: null,
publicEventNext: null, publicEventNext: null,
timer1: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
},
}; };
export const runtime = createStore<RuntimeStore>(() => ({ const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
...runtimeStorePlaceholder,
}));
export const deepCompare = <T>(a: T, b: T) => isEqual(a, b); export const runtimeStore = createWithEqualityFn<RuntimeStore>(
() => ({
...runtimeStorePlaceholder,
}),
deepCompare,
);
export const useRuntimeStore = <T>( export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
selector: (state: RuntimeStore) => T, useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
equalityFn?: (a: unknown, b: unknown) => boolean,
) => useStore(runtime, selector, equalityFn);
@@ -1,95 +1,4 @@
import { import { forgivingStringToMillis, millisToDelayString } from '../dateConfig';
forgivingStringToMillis,
millisToDelayString,
millisToMinutes,
millisToSeconds,
secondsInMillis,
} from '../dateConfig';
describe('test secondsInMillis function', () => {
it('return 0 if value is null', () => {
expect(secondsInMillis(null)).toBe(0);
});
it('returns the seconds value of a millis date', () => {
const date = 1686255053619; // Thu Jun 08 2023 20:10:53
const seconds = secondsInMillis(date);
expect(seconds).toBe(53);
});
});
describe('test millisToSeconds function', () => {
it('test with null values', () => {
const t = { val: null, result: 0 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with valid millis', () => {
const t = { val: 3600000, result: 3600 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with negative millis', () => {
const t = { val: -3600000, result: -3600 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with 0', () => {
const t = { val: 0, result: 0 };
expect(millisToSeconds(t.val)).toBe(t.result);
});
it('test with -0', () => {
const t = { val: -0, result: -0 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
});
it('test with 86401000 (24 hours and 1 second)', () => {
const t = { val: 86401000, result: 86401 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
});
it('test with -86401000 (-24 hours and 1 second)', () => {
const t = { val: -86401000, result: -86401 };
expect(millisToSeconds(t.val, false)).toBe(t.result);
});
});
describe('test millisToMinutes function', () => {
it('test with null values', () => {
const t = { val: null, result: 0 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with valid millis', () => {
const t = { val: 3600000, result: 60 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with negative millis', () => {
const t = { val: -3600000, result: -60 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with 0', () => {
const t = { val: 0, result: 0 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with -0', () => {
const t = { val: -0, result: -0 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with 86401000 (24 hours and 1 second)', () => {
const t = { val: 86401000, result: 1440 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
it('test with -86401000 (-24 hours and 1 second)', () => {
const t = { val: -86401000, result: -1440 };
expect(millisToMinutes(t.val, false)).toBe(t.result);
});
});
describe('test forgivingStringToMillis()', () => { describe('test forgivingStringToMillis()', () => {
describe('function handles time with no separators', () => { describe('function handles time with no separators', () => {
@@ -358,10 +267,10 @@ describe('millisToDelayString()', () => {
}); });
describe('converts values in seconds', () => { describe('converts values in seconds', () => {
it('shows a simple string with value in seconds', () => { it('shows a simple string with value in seconds', () => {
expect(millisToDelayString(10000)).toBe('+10 sec'); expect(millisToDelayString(10000, true)).toBe('+10 sec');
}); });
it('... and its negative counterpart', () => { it('... and its negative counterpart', () => {
expect(millisToDelayString(-10000)).toBe('-10 sec'); expect(millisToDelayString(-10000, true)).toBe('-10 sec');
}); });
const underAMinute = [1, 500, 1000, 6000, 55000, 59999]; const underAMinute = [1, 500, 1000, 6000, 55000, 59999];
@@ -375,32 +284,32 @@ describe('millisToDelayString()', () => {
describe('converts values in minutes', () => { describe('converts values in minutes', () => {
it('shows a simple string with value in minutes', () => { it('shows a simple string with value in minutes', () => {
expect(millisToDelayString(720000)).toBe('+12 min'); expect(millisToDelayString(720000, true)).toBe('+12 min');
}); });
it('... and its negative counterpart', () => { it('... and its negative counterpart', () => {
expect(millisToDelayString(-720000)).toBe('-12 min'); expect(millisToDelayString(-720000, true)).toBe('-12 min');
}); });
it('shows a simple string with value in minutes and seconds', () => { it('shows a simple string with value in minutes and seconds', () => {
expect(millisToDelayString(630000)).toBe('+00:10:30'); expect(millisToDelayString(630000, true)).toBe('+00:10:30');
}); });
it('... and its negative counterpart', () => { it('... and its negative counterpart', () => {
expect(millisToDelayString(-630000)).toBe('-00:10:30'); expect(millisToDelayString(-630000, true)).toBe('-00:10:30');
}); });
const underAnHour = [60000, 360000, 720000]; const underAnHour = [60000, 360000, 720000];
underAnHour.forEach((value) => { underAnHour.forEach((value) => {
it(`handles ${value}`, () => { it(`handles ${value}`, () => {
expect(millisToDelayString(value)?.endsWith('min')).toBe(true); expect(millisToDelayString(value, true)?.endsWith('min')).toBe(true);
}); });
}); });
}); });
describe('converts values with full time string', () => { describe('converts values with full time string', () => {
it('positive added time', () => { it('positive added time', () => {
expect(millisToDelayString(45015000)).toBe('+12:30:15'); expect(millisToDelayString(45015000, true)).toBe('+12:30:15');
}); });
it('negative added time', () => { it('negative added time', () => {
expect(millisToDelayString(-45015000)).toBe('-12:30:15'); expect(millisToDelayString(-45015000, true)).toBe('-12:30:15');
}); });
}); });
}); });
@@ -31,6 +31,8 @@ describe('cloneEvent()', () => {
user7: 'user7', user7: 'user7',
user8: 'user8', user8: 'user8',
user9: 'user9', user9: 'user9',
timeWarning: 120000,
timeDanger: 60000,
} as OntimeEvent; } as OntimeEvent;
const cloned = cloneEvent(original); const cloned = cloneEvent(original);
@@ -51,5 +53,7 @@ describe('cloneEvent()', () => {
expect(cloned.colour).toBe(original.colour); expect(cloned.colour).toBe(original.colour);
expect(cloned.type).toBe(SupportedEvent.Event); expect(cloned.type).toBe(SupportedEvent.Event);
expect(cloned.revision).toBe(0); expect(cloned.revision).toBe(0);
expect(cloned.timeWarning).toBe(original.timeWarning);
expect(cloned.timeDanger).toBe(original.timeDanger);
}); });
}); });
@@ -1,4 +1,4 @@
import { isIPAddress, isOnlyNumbers, startsWithHttp } from '../regex'; import { isIPAddress, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex';
describe('simple tests for regex', () => { describe('simple tests for regex', () => {
test('isOnlyNumbers', () => { test('isOnlyNumbers', () => {
@@ -36,4 +36,16 @@ describe('simple tests for regex', () => {
expect(startsWithHttp.test(t)).toBe(false); expect(startsWithHttp.test(t)).toBe(false);
}); });
}); });
test('startsWithSlash', () => {
const right = ['//test'];
const wrong = ['testing', '123.0.1'];
right.forEach((t) => {
expect(startsWithSlash.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(startsWithSlash.test(t)).toBe(false);
});
});
}); });
@@ -1,23 +1,28 @@
import { formatTime } from '../time'; import { formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => {
const mockDate = new Date(2022, 1, 1, 13, 0, 0); // This date corresponds to 13:00:00
const expectedMillis = 13 * 60 * 60 * 1000;
const dateSpy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate as any);
const result = nowInMillis();
expect(result).toBe(expectedMillis);
dateSpy.mockRestore();
});
});
describe('formatTime()', () => { describe('formatTime()', () => {
it('parses 24h strings', () => { it('parses 24h strings', () => {
const ms = 13 * 60 * 60 * 1000; const ms = 13 * 60 * 60 * 1000;
const options = { const time = formatTime(ms, {format12: "hh:mm:ss", format24: "HH:mm:ss" }, (_format12, format24) => format24);
showSeconds: true,
format: 'irrelevant',
};
const time = formatTime(ms, options, () => '24');
expect(time).toStrictEqual('13:00:00'); expect(time).toStrictEqual('13:00:00');
}); });
it('parses same string in 12h strings', () => { it('parses same string in 12h strings', () => {
const ms = 13 * 60 * 60 * 1000; const ms = 13 * 60 * 60 * 1000;
const options = { const time = formatTime(ms, {format12: "hh:mm:ss a", format24: "HH:mm:ss" }, (format12, _format24) => format12);
showSeconds: true,
format: 'hh:mm:ss a',
};
const time = formatTime(ms, options, () => '12');
expect(time).toStrictEqual('01:00:00 PM'); expect(time).toStrictEqual('01:00:00 PM');
}); });
@@ -27,13 +32,9 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('...'); expect(time).toStrictEqual('...');
}); });
it('shows 12h format without times', () => { it('handles negative times', () => {
const ms = 13 * 60 * 60 * 1000; const ms = 1 * 60 * 60 * 1000;
const options = { const time = formatTime(-ms, {format12: "hh:mm a", format24: "HH:mm" }, (_format12, format24) => format24);
showSeconds: false, expect(time).toStrictEqual('-01:00');
format: 'hh:mm a',
};
const time = formatTime(ms, options, () => '12');
expect(time).toStrictEqual('01:00 PM');
}); });
}); });
+23 -54
View File
@@ -1,37 +1,4 @@
import { formatFromMillis } from 'ontime-utils'; import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { mth, mtm, mts } from './timeConstants';
export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
export function secondsInMillis(millis: number | null) {
if (!millis) {
return 0;
}
return Math.floor((millis % mtm) / mts);
}
/**
* @description Converts milliseconds to seconds
* @param {number | null} millis - time in seconds
* @returns {number} Amount in seconds
*/
export const millisToSeconds = (millis: number | null): number => {
if (millis === null) {
return 0;
}
return millis < 0 ? Math.ceil(millis / mts) : Math.floor(millis / mts);
};
/**
* @description Converts milliseconds to seconds
* @param {number} millis - time in milliseconds
* @returns {number} Amount in seconds
*/
export const millisToMinutes = (millis: number): number => {
return millis < 0 ? Math.ceil(millis / mtm) : Math.floor(millis / mtm);
};
/** /**
* @description safe parse string to int * @description safe parse string to int
@@ -88,7 +55,7 @@ function checkMatchers(value: string) {
const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0; const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0;
if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) { if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) {
return hoursMatchValue * mth + minutesMatchValue * mtm + secondsMatchValue * mts; return hoursMatchValue * MILLIS_PER_HOUR + minutesMatchValue * MILLIS_PER_MINUTE + secondsMatchValue * MILLIS_PER_SECOND;
} }
return { hoursMatchValue }; return { hoursMatchValue };
} }
@@ -105,13 +72,13 @@ function inferSeparators(value: string, isAM: boolean, isPM: boolean) {
let addAM = 0; let addAM = 0;
if (length === 1) { if (length === 1) {
if (isPM || isAM) { if (isPM || isAM) {
inferredMillis = parse(value) * mth; inferredMillis = parse(value) * MILLIS_PER_HOUR;
if (isAM) { if (isAM) {
// this ensures we dont add 12 hours in the end // this ensures we dont add 12 hours in the end
addAM = inferredMillis; addAM = inferredMillis;
} }
} else { } else {
inferredMillis = parse(value) * mtm; inferredMillis = parse(value) * MILLIS_PER_MINUTE;
} }
} else if (length === 2) { } else if (length === 2) {
if (isPM || isAM) { if (isPM || isAM) {
@@ -121,22 +88,22 @@ function inferSeparators(value: string, isAM: boolean, isPM: boolean) {
addAM = 12; addAM = 12;
} }
} else { } else {
inferredMillis = parse(value) * mtm; inferredMillis = parse(value) * MILLIS_PER_MINUTE;
} }
} else if (length === 3) { } else if (length === 3) {
inferredMillis = parse(value[0]) * mth + parse(value.substring(1)) * mtm; inferredMillis = parse(value[0]) * MILLIS_PER_HOUR + parse(value.substring(1)) * MILLIS_PER_MINUTE;
} else if (length === 4) { } else if (length === 4) {
inferredMillis = parse(value.substring(0, 2)) * mth + parse(value.substring(2)) * mtm; inferredMillis = parse(value.substring(0, 2)) * MILLIS_PER_HOUR + parse(value.substring(2)) * MILLIS_PER_MINUTE;
} else if (length === 5) { } else if (length === 5) {
const hours = parse(value.substring(0, 2)); const hours = parse(value.substring(0, 2));
const minutes = parse(value.substring(2, 4)); const minutes = parse(value.substring(2, 4));
const seconds = parse(value.substring(4)); const seconds = parse(value.substring(4));
inferredMillis = hours * mth + minutes * mtm + seconds * mts; inferredMillis = hours * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + seconds * MILLIS_PER_SECOND;
} else if (length >= 6) { } else if (length >= 6) {
const hours = parse(value.substring(0, 2)); const hours = parse(value.substring(0, 2));
const minutes = parse(value.substring(2, 4)); const minutes = parse(value.substring(2, 4));
const seconds = parse(value.substring(4)); const seconds = parse(value.substring(4));
inferredMillis = hours * mth + minutes * mtm + seconds * mts; inferredMillis = hours * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + seconds * MILLIS_PER_SECOND;
} }
return { inferredMillis, addAM }; return { inferredMillis, addAM };
} }
@@ -167,9 +134,9 @@ export const forgivingStringToMillis = (value: string): number => {
if (first != null && second != null && third != null) { if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds] // if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * mth; millis = parse(first) * MILLIS_PER_HOUR;
millis += parse(second) * mtm; millis += parse(second) * MILLIS_PER_MINUTE;
millis += parse(third) * mts; millis += parse(third) * MILLIS_PER_SECOND;
} else if (first != null && second == null && third == null) { } else if (first != null && second == null && third == null) {
// we only have one section, infer separators // we only have one section, infer separators
const { inferredMillis, addAM } = inferSeparators(first, isAM, isPM); const { inferredMillis, addAM } = inferSeparators(first, isAM, isPM);
@@ -177,30 +144,32 @@ export const forgivingStringToMillis = (value: string): number => {
hoursMatchValue = addAM; hoursMatchValue = addAM;
} }
if (first != null && second != null && third == null) { if (first != null && second != null && third == null) {
millis = parse(first) * mth; millis = parse(first) * MILLIS_PER_HOUR;
millis += parse(second) * mtm; millis += parse(second) * MILLIS_PER_MINUTE;
} }
// Add 12 hours if it is PM // Add 12 hours if it is PM
if (isPM && hoursMatchValue < 12) { if (isPM && hoursMatchValue < 12) {
millis += 12 * mth; millis += 12 * MILLIS_PER_HOUR;
} }
return millis; return millis;
}; };
export function millisToDelayString(millis: number | null): undefined | string | null { export function millisToDelayString(millis: number | null, small = false): undefined | string | null {
if (millis == null || millis === 0) { if (millis == null || millis === 0) {
return null; return null;
} }
const isNegative = millis < 0; const isNegative = millis < 0;
const absMillis = Math.abs(millis); const absMillis = Math.abs(millis);
const delayed = small ? '+' : 'delayed by ';
const ahead = small ? '-' : 'ahead by ';
if (absMillis < mtm) { if (absMillis < MILLIS_PER_MINUTE) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 's')} sec`; return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 's')} sec`;
} else if (absMillis < mth && absMillis % mtm === 0) { } else if (absMillis < MILLIS_PER_HOUR && absMillis % MILLIS_PER_MINUTE === 0) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'm')} min`; return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'm')} min`;
} else { } else {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'HH:mm:ss')}`; return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
} }
} }
@@ -8,13 +8,12 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
*/ */
type ClonedEvent = Omit< type ClonedEvent = Omit<
OntimeEvent, OntimeEvent,
'id' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9' 'id' | 'cue' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
>; >;
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => { export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
return { return {
type: SupportedEvent.Event, type: SupportedEvent.Event,
title: event.title, title: event.title,
cue: event.cue,
subtitle: event.subtitle, subtitle: event.subtitle,
presenter: event.presenter, presenter: event.presenter,
note: event.note, note: event.note,
@@ -22,11 +21,15 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
duration: event.duration, duration: event.duration,
timeEnd: event.timeEnd, timeEnd: event.timeEnd,
timerType: event.timerType, timerType: event.timerType,
timeStrategy: event.timeStrategy,
linkStart: event.linkStart,
endAction: event.endAction, endAction: event.endAction,
isPublic: event.isPublic, isPublic: event.isPublic,
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
after: after, after,
revision: 0, revision: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
}; };
}; };
+9
View File
@@ -0,0 +1,9 @@
import { KeyboardEvent } from 'react';
export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Enter';
}
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape';
}
+6
View File
@@ -1,3 +1,9 @@
/**
* Simple regex patterns for common use cases
* mostly used in form validation
*/
export const isOnlyNumbers = /^\d+$/; export const isOnlyNumbers = /^\d+$/;
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/; export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
export const startsWithHttp = /^http:\/\//; export const startsWithHttp = /^http:\/\//;
export const startsWithSlash = /^\//;
+17 -29
View File
@@ -4,7 +4,7 @@ import { isProduction, RUNTIME, websocketUrl } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { socketClientName } from '../stores/connectionName'; import { socketClientName } from '../stores/connectionName';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { runtime } from '../stores/runtime'; import { runtimeStore } from '../stores/runtime';
export let websocket: WebSocket | null = null; export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -63,52 +63,40 @@ export const connectSocket = (preferredClientName?: string) => {
break; break;
} }
case 'ontime': { case 'ontime': {
runtime.setState(payload as RuntimeStore); runtimeStore.setState(payload as RuntimeStore);
if (!isProduction) { if (!isProduction) {
ontimeQueryClient.setQueryData(RUNTIME, data.payload); ontimeQueryClient.setQueryData(RUNTIME, data.payload);
} }
break; break;
} }
case 'ontime-playback': { case 'ontime-playback': {
const state = runtime.getState(); const state = runtimeStore.getState();
state.playback = payload; state.timer.playback = payload;
runtime.setState(state); runtimeStore.setState(state);
break; break;
} }
case 'ontime-timer': { case 'ontime-timer': {
const state = runtime.getState(); const state = runtimeStore.getState();
state.timer = payload; state.timer = payload;
runtime.setState(state); runtimeStore.setState(state);
break; break;
} }
case 'ontime-loaded': { case 'ontime-runtime': {
const state = runtime.getState(); const state = runtimeStore.getState();
state.loaded = payload; state.runtime = payload;
runtime.setState(state); runtimeStore.setState(state);
break; break;
} }
case 'ontime-timerMessage': { case 'ontime-message': {
const state = runtime.getState(); const state = runtimeStore.getState();
state.timerMessage = payload; state.message = payload;
runtime.setState(state); runtimeStore.setState(state);
break;
}
case 'ontime-publicMessage': {
const state = runtime.getState();
state.publicMessage = payload;
runtime.setState(state);
break;
}
case 'ontime-lowerMessage': {
const state = runtime.getState();
state.lowerMessage = payload;
runtime.setState(state);
break; break;
} }
case 'ontime-onAir': { case 'ontime-onAir': {
const state = runtime.getState(); const state = runtimeStore.getState();
state.onAir = payload; state.onAir = payload;
runtime.setState(state); runtimeStore.setState(state);
break; break;
} }
} }
+60 -20
View File
@@ -1,6 +1,7 @@
import { Settings } from 'ontime-types'; import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis, millisToString } from 'ontime-utils'; import { formatFromMillis } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/apiConstants'; import { APP_SETTINGS } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
@@ -22,36 +23,75 @@ export const nowInMillis = () => {
/** /**
* @description Resolves format from url and store * @description Resolves format from url and store
* @return {string|undefined} * @return {string|null} A format string like "hh:mm:ss a" or null
*/ */
export const resolveTimeFormat = () => { function getFormatFromParams() {
const params = new URL(document.location.href).searchParams; const params = new URL(document.location.href).searchParams;
const urlOptions = params.get('format'); return params.get('timeformat');
const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS); }
return urlOptions || settings?.timeFormat; /**
}; * Gets the format options from the applicaton settings
* @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss
*/
export function getFormatFromSettings(): TimeFormat {
const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS);
return settings?.timeFormat ?? '24';
}
export function getDefaultFormat(
currentSettings?: TimeFormat,
format12: string = FORMAT_12,
format24: string = FORMAT_24,
): string {
if (currentSettings === '12') {
return format12;
}
return format24;
}
function resolveTimeFormat(fallback12: string, fallback24: string): string {
// if the user has an option, we use that
const formatFromParams = getFormatFromParams();
if (formatFromParams) {
return formatFromParams;
}
// otherwise we use the view defined, with respect to the 12-24 hour settings
const formatFromSettings = getFormatFromSettings();
if (formatFromSettings === '12') {
return fallback12;
}
return fallback24;
}
type FormatOptions = { type FormatOptions = {
showSeconds?: boolean; format12: string;
format?: string; format24: string;
}; };
/** /**
* @description utility function to format a date in 12 or 24 hour format * @description viewer specific utility function to format a date in 12 or 24 hour format
* @param {number | null} milliseconds * @param {MaybeNumber} milliseconds
* @param {object} [options] * @param {object} [options]
* @param {boolean} [options.showSeconds] * @param {string} [options.format.format12] format string if 12 hour time
* @param {string} [options.format] * @param {string} [options.format.format24] format string if 24 hour time
* @param {function} resolver * @param {Function} resolver DI for testing
* @return {string} * @return {string}
*/ */
export const formatTime = (milliseconds: number | null, options?: FormatOptions, resolver = resolveTimeFormat) => { export const formatTime = (
milliseconds: MaybeNumber,
options?: FormatOptions,
resolver = resolveTimeFormat,
): string => {
if (milliseconds === null) { if (milliseconds === null) {
return '...'; return '...';
} }
const timeFormat = resolver();
const fallback = options?.showSeconds ? 'hh:mm:ss a' : 'hh:mm a'; const timeFormat = resolver(options?.format12 ?? FORMAT_12, options?.format24 ?? FORMAT_24);
const { showSeconds = false, format: formatString = fallback } = options || {}; const display = formatFromMillis(Math.abs(milliseconds), timeFormat);
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
const isNegative = milliseconds < 0;
return `${isNegative ? '-' : ''}${display}`;
}; };
@@ -1,17 +0,0 @@
/**
* millis to seconds
* @type {number}
*/
export const mts = 1000;
/**
* millis to minutes
* @type {number}
*/
export const mtm = 1000 * 60;
/**
* millis to hours
* @type {number}
*/
export const mth = 1000 * 60 * 60;
@@ -1 +0,0 @@
export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride';
+1
View File
@@ -1,4 +1,5 @@
export const githubUrl = 'https://www.github.com/cpvalente/ontime'; export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest'; export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no';
export const gitbookUrl = 'https://ontime.gitbook.io'; export const gitbookUrl = 'https://ontime.gitbook.io';
@@ -1,5 +1,3 @@
@use '../theme/v2Styles' as *;
.wrapper { .wrapper {
background: $bg-container-l1; background: $bg-container-l1;
width: 100%; width: 100%;
@@ -0,0 +1,10 @@
.container {
grid-area: main;
width: 100%;
padding: 1rem;
display: flex;
gap: 0.25rem;
overflow: hidden;
}
@@ -0,0 +1,41 @@
import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
import LogPanel from './panel/log-panel/LogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel';
import ProjectSettingsPanel from './panel/project-settings-panel/ProjectSettingsPanel';
import UrlPresetPanel from './panel/url-preset-panel/UrlPresetPanel';
import PanelContent from './panel-content/PanelContent';
import PanelList from './panel-list/PanelList';
import { useSettingsStore } from './settingsStore';
import style from './AppSettings.module.scss';
export default function AppSettings() {
const setShowSettings = useSettingsStore((state) => state.setShowSettings);
const selectedPanel = useSettingsStore((state) => state.showSettings);
const closeSettings = () => {
setShowSettings(null);
};
useKeyDown(closeSettings, 'Escape');
return (
<div className={style.container}>
<ErrorBoundary>
<PanelList />
<PanelContent onClose={closeSettings}>
{selectedPanel === 'project' && <ProjectPanel />}
{selectedPanel === 'integrations' && <IntegrationsPanel />}
{selectedPanel === 'project_settings' && <ProjectSettingsPanel />}
{selectedPanel === 'url_presets' && <UrlPresetPanel />}
{selectedPanel === 'about' && <AboutPanel />}
{selectedPanel === 'log' && <LogPanel />}
</PanelContent>
</ErrorBoundary>
</div>
);
}
@@ -0,0 +1,20 @@
.corner {
position: absolute;
top: 1rem;
right: 1rem;
}
.contentWrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
position: relative;
}
.content {
margin: 1rem;
overflow-y: auto;
flex-grow: 1;
}
@@ -0,0 +1,22 @@
import { PropsWithChildren } from 'react';
import { IconButton } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import style from './PanelContent.module.scss';
interface PanelContentProps {
onClose: () => void;
}
export default function PanelContent(props: PropsWithChildren<PanelContentProps>) {
const { onClose, children } = props;
return (
<div className={style.contentWrapper}>
<div className={style.corner}>
<IconButton onClick={onClose} aria-label='close' icon={<IoClose />} variant='ontime-ghosted-white' />
</div>
<div className={style.content}>{children}</div>
</div>
);
}
@@ -0,0 +1,61 @@
.tabs,
ul {
list-style: none;
padding: 0;
margin: 0;
}
.tabs {
width: min(30vw, 300px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.primary,
.secondary {
padding: 0.25rem 1rem;
margin-right: 1rem;
&:focus {
background-color: $gray-1000;
outline: 0;
}
&:hover {
background-color: $gray-1000;
cursor: pointer;
}
}
.primary {
font-size: 1rem;
border-radius: 2px;
display: flex;
align-items: center;
gap: 0.5rem;
&.active {
color: $blue-400;
background-color: $gray-1100;
}
&.unsaved::before {
content: '';
width: 6px;
height: 6px;
border-radius: 3px;
background-color: $blue-400;
}
&.split {
margin-top: 1rem;
}
}
.secondary {
margin-left: 1rem;
color: $secondary-text-gray;
border-left: 1px solid $white-10;
font-size: $inner-section-text-size;
}
@@ -0,0 +1,54 @@
import { Fragment } from 'react';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import { settingPanels, SettingsOption, useSettingsStore } from '../settingsStore';
import style from './PanelList.module.scss';
export default function PanelList() {
const { showSettings, setShowSettings, hasUnsavedChanges } = useSettingsStore();
const handleSelect = (panel: SettingsOption) => {
setShowSettings(panel.id);
};
return (
<ul className={style.tabs}>
{settingPanels.map((panel) => {
const unsaved = hasUnsavedChanges(panel.id);
const classes = cx([
style.primary,
showSettings === panel.id ? style.active : null,
panel.split ? style.split : null,
unsaved ? style.unsaved : null,
]);
return (
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => handleSelect(panel)}
onKeyDown={(event) => {
isKeyEnter(event) && handleSelect(panel);
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
</li>
{panel.secondary?.map((secondary) => {
return (
<li key={secondary.id} onClick={() => handleSelect(panel)} className={style.secondary}>
{secondary.label}
</li>
);
})}
</Fragment>
);
})}
</ul>
);
}
@@ -0,0 +1,115 @@
$inner-padding: 1rem;
.header {
font-size: 2rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10;
font-weight: 600;
}
.subheader {
font-size: 1.5rem;
padding-bottom: 0.5rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 1.25rem;
padding-left: $inner-padding;
display: flex;
align-items: center;
justify-content: space-between;
}
.section {
margin-top: 2rem;
font-size: calc(1rem - 1px);
max-width: 800px;
}
.paragraph {
padding: 0.5rem 0;
}
.card {
padding: 2rem;
background-color: $white-1;
border: 1px solid $gray-1100;
border-radius: 3px;
}
.error {
font-size: $inner-section-text-size;
display: block;
color: $error-red;
}
.pad {
padding: 0 1rem;
max-height: 500px;
overflow-y: scroll;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: calc(1rem - 2px);
text-align: left;
tr {
padding: 1rem 0;
}
th {
border-bottom: 1px solid $white-10;
font-weight: 400;
color: $gray-400;
}
th,
td {
padding: 0.5rem;
}
tr:nth-child(even) {
background-color: $white-1;
}
}
.listGroup {
padding: 0.5rem 1rem 0 1rem;
> li:not(:last-child) {
border-bottom: 1px solid $white-10;
}
}
.listItem {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
padding: 0.5rem 0;
}
.fieldTitle {
color: $ui-white;
font-size: 1rem;
}
.fieldDescription {
font-size: calc(1rem - 2px);
color: $gray-400;
}
.fieldError {
font-size: calc(1rem - 2px);
color: $red-500;
}
.divider {
border-top: 1px solid $white-10;
margin: 1rem -2rem;
}
@@ -0,0 +1,80 @@
import { HTMLAttributes, ReactNode } from 'react';
import style from './Panel.module.scss';
export function Header({ children }: { children: ReactNode }) {
return <h2 className={style.header}>{children}</h2>;
}
export function SubHeader({ children }: { children: ReactNode }) {
return <h3 className={style.subheader}>{children}</h3>;
}
export function Title({ children }: { children: ReactNode }) {
return <h4 className={style.title}>{children}</h4>;
}
type AllowedTags = 'div' | 'form';
type SectionProps<C extends AllowedTags> = {
as?: C;
children: ReactNode;
} & JSX.IntrinsicElements[C];
export function Section<C extends AllowedTags = 'div'>({ as, children, ...props }: SectionProps<C>) {
const Element = as ?? 'div';
return (
<Element className={style.section} {...(props as HTMLAttributes<HTMLElement>)}>
{children}
</Element>
);
}
export function Paragraph({ children }: { children: ReactNode }) {
return <p className={style.paragraph}>{children}</p>;
}
export function Card({ children, ...props }: { children: ReactNode } & JSX.IntrinsicElements['div']) {
return (
<div className={style.card} {...props}>
{children}
</div>
);
}
export function Table({ children }: { children: ReactNode }) {
return (
<div className={style.pad}>
<table className={style.table}>{children}</table>
</div>
);
}
export function ListGroup({ children }: { children: ReactNode }) {
return <ul className={style.listGroup}>{children}</ul>;
}
export function ListItem({ children }: { children: ReactNode }) {
return <li className={style.listItem}>{children}</li>;
}
export function Field({ title, description, error }: { title: string; description: string; error?: string }) {
return (
<div className={style.fieldTitle}>
{title}
{error && <Error>{error}</Error>}
{!error && description && <Description>{description}</Description>}
</div>
);
}
export function Description({ children }: { children: ReactNode }) {
return <div className={style.fieldDescription}>{children}</div>;
}
export function Error({ children }: { children: ReactNode }) {
return <div className={style.fieldError}>{children}</div>;
}
export function Divider() {
return <hr className={style.divider} />;
}
@@ -0,0 +1,35 @@
import { version } from '../../../../../package.json';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { gitbookUrl, githubUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../PanelUtils';
import CheckUpdatesButton from './CheckUpdatesButton';
export default function AboutPanel() {
return (
<>
<Panel.Header>About Ontime</Panel.Header>
<Panel.Section>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={gitbookUrl}>Read the docs over at GitBook</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
</Panel.Card>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Paragraph>{`You are currently using Ontime ${version}`}</Panel.Paragraph>
<CheckUpdatesButton version={version} />
</Panel.Card>
</Panel.Section>
</>
);
}
@@ -1,14 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { getLatestVersion, HasUpdate } from '../../../common/api/ontimeApi'; import { getLatestVersion, HasUpdate } from '../../../../common/api/ontimeApi';
import ModalLink from '../ModalLink'; import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import styles from '../Modal.module.scss'; import style from '../Panel.module.scss';
interface UpdateCheckerProps {
version: string;
}
type CheckFail = { type CheckFail = {
error: string; error: string;
@@ -20,8 +16,13 @@ type CheckIsLatest = {
type CheckRemote = CheckFail | CheckIsLatest | HasUpdate; type CheckRemote = CheckFail | CheckIsLatest | HasUpdate;
export default function UpdateChecker(props: UpdateCheckerProps) { interface CheckUpdatesButtonProps {
version: string;
}
export default function CheckUpdatesButton(props: CheckUpdatesButtonProps) {
const { version } = props; const { version } = props;
const [updateMessage, setUpdateMessage] = useState<CheckRemote | null>(null); const [updateMessage, setUpdateMessage] = useState<CheckRemote | null>(null);
const [isFetching, setIsFetching] = useState(false); const [isFetching, setIsFetching] = useState(false);
@@ -50,12 +51,12 @@ export default function UpdateChecker(props: UpdateCheckerProps) {
const disableButton = Boolean(updateMessage && 'version' in updateMessage); const disableButton = Boolean(updateMessage && 'version' in updateMessage);
return ( return (
<div className={styles.updateSection}> <>
<Button onClick={versionCheck} variant='ontime-filled' isLoading={isFetching} isDisabled={disableButton}> <Button onClick={versionCheck} variant='ontime-filled' isLoading={isFetching} isDisabled={disableButton}>
Check for updates Check for updates
</Button> </Button>
<ResolveUpdateMessage updateMessage={updateMessage} /> <ResolveUpdateMessage updateMessage={updateMessage} />
</div> </>
); );
} }
@@ -63,10 +64,10 @@ function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
const { updateMessage } = props; const { updateMessage } = props;
if (updateMessage && 'error' in updateMessage) { if (updateMessage && 'error' in updateMessage) {
return <span className={styles.error}>{updateMessage.error}</span>; return <span className={style.error}>{updateMessage.error}</span>;
} }
if (updateMessage && 'url' in updateMessage) { if (updateMessage && 'url' in updateMessage) {
return <ModalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ModalLink>; return <ExternalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ExternalLink>;
} }
return null; return null;
} }
@@ -0,0 +1,181 @@
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { HttpSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
export default function HttpIntegrations() {
const { data } = useHttpSettings();
const { mutateAsync } = usePostHttpSettings();
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<HttpSettings>({
mode: 'onBlur',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'subscriptions',
control,
});
const onSubmit = async (values: HttpSettings) => {
try {
await mutateAsync(values);
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const preventEscape = (event: React.KeyboardEvent) => {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
}
};
const handleAddNewSubscription = () => {
prepend({
id: generateId(),
cycle: 'onLoad',
message: '',
enabled: false,
});
};
const handleDeleteSubscription = (index: number) => {
remove(index);
};
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
HTTP
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Reset
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='http-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save changes
</Button>
</div>
</Panel.SubHeader>
<Panel.Section as='form' id='http-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='HTTP Output' description='Provide feedback from Ontime through HTTP' />
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Divider />
<Panel.Title>
HTTP Integration
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
New
</Button>
</Panel.Title>
{fields.length > 0 && (
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.fullWidth}>Message</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((integration, index) => {
// @ts-expect-error -- not sure why it is not finding the type, it is ok
const maybeError = errors.subscriptions?.[index]?.message?.message;
return (
<tr key={integration.id}>
<td>
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
</td>
<td className={style.autoWidth}>
<Select
size='sm'
variant='ontime'
className={style.fitContents}
{...register(`subscriptions.${index}.cycle`)}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
</td>
<td className={style.fullWidth}>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='http://third-party/vt1/{{timer.current}}'
{...register(`subscriptions.${index}.message`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should start with http://',
},
})}
/>
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
</td>
<td>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDeleteSubscription(index)}
/>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
)}
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -0,0 +1,12 @@
.fullWidth {
width: 100%;
}
.fitContents {
width: max-content !important; /* override chakra */
}
.flex {
display: flex;
gap: 1rem;
}
@@ -0,0 +1,33 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import * as Panel from '../PanelUtils';
import HttpIntegrations from './HttpIntegrations';
import OscIntegrations from './OscIntegrations';
const integrationDocsUrl = 'https://ontime.gitbook.io/v2/control-and-feedback/integrations';
export default function IntegrationsPanel() {
return (
<>
<Panel.Header>Integration settings</Panel.Header>
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Integrations allow Ontime to receive commands or send its data to other systems in your workflow. <br />{' '}
<br />
Currently supported protocols are OSC (Open Sound Control), HTTP and Websockets. <br />
WebSockets are used for Ontime and cannot be configured independently. <br />
<ExternalLink href={integrationDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
<Panel.Section>
<OscIntegrations />
<HttpIntegrations />
</Panel.Section>
</>
);
}
@@ -0,0 +1,268 @@
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { OSCSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
export default function OscIntegrations() {
const { data } = useOscSettings();
const { mutateAsync } = useOscSettingsMutation();
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<OSCSettings>({
mode: 'onBlur',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'subscriptions',
control,
});
const onSubmit = async (values: OSCSettings) => {
if (values.portIn === values.portOut) {
setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' });
return;
}
const parsedValues = { ...values, portIn: Number(values.portIn), portOut: Number(values.portOut) };
try {
await mutateAsync(parsedValues);
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const preventEscape = (event: React.KeyboardEvent) => {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
}
};
const handleAddNewSubscription = () => {
prepend({
id: generateId(),
cycle: 'onLoad',
message: '',
enabled: false,
});
};
const handleDeleteSubscription = (index: number) => {
remove(index);
};
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Card>
<Panel.SubHeader>
Open Sound Control
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Reset
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='osc-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save changes
</Button>
</div>
</Panel.SubHeader>
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC input' description='Allow control of Ontime through OSC' />
<Switch variant='ontime' size='lg' {...register('enabledIn')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.portIn?.message}
/>
<Input
id='portIn'
placeholder='8888'
width='5rem'
maxLength={5}
size='sm'
textAlign='right'
variant='ontime-filled'
type='number'
autoComplete='off'
{...register('portIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC output' description='Provide feedback from Ontime with OSC' />
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='OSC target IP'
description='IP address Ontime will send OSC messages to'
error={errors.targetIP?.message}
/>
<Input
id='targetIP'
placeholder='127.0.0.1'
width='9rem'
size='sm'
textAlign='right'
variant='ontime-filled'
autoComplete='off'
{...register('targetIP', {
required: { value: true, message: 'Required field' },
pattern: {
value: isIPAddress,
message: 'Invalid IP address',
},
})}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='OSC target port'
description='Port number Ontime will send OSC messages to'
error={errors.portOut?.message}
/>
<Input
id='portOut'
placeholder='8888'
width='75px'
size='sm'
textAlign='right'
variant='ontime-filled'
autoComplete='off'
{...register('portOut', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Divider />
<Panel.Title>
OSC Integrations
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
Add
</Button>
</Panel.Title>
{fields.length > 0 && (
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.fullWidth}>Message</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((field, index) => {
// @ts-expect-error -- not sure why it is not finding the type, it is ok
const maybeError = errors.subscriptions?.[index]?.message?.message;
return (
<tr key={field.id}>
<td>
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
</td>
<td className={style.autoWidth}>
<Select
size='sm'
variant='ontime'
className={style.fitContents}
{...register(`subscriptions.${index}.cycle`)}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
</td>
<td className={style.fullWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='/from-ontime/{{timer.current}}'
{...register(`subscriptions.${index}.message`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithSlash,
message: 'OSC messages should start with a forward slash',
},
})}
/>
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
</td>
<td>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDeleteSubscription(index)}
/>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
)}
</Panel.Section>
</Panel.Card>
);
}
@@ -0,0 +1,8 @@
export const cycles = [
{ id: 1, label: 'On Load', value: 'onLoad' },
{ id: 2, label: 'On Start', value: 'onStart' },
{ id: 3, label: 'On Pause', value: 'onPause' },
{ id: 4, label: 'On Stop', value: 'onStop' },
{ id: 5, label: 'Every second', value: 'onUpdate' },
{ id: 6, label: 'On Finish', value: 'onFinish' },
];
@@ -1,20 +1,16 @@
@use '../../theme/v2Styles' as *;
@use '../../theme/mixins' as *;
$info-gray: $secondary-text-gray; $info-gray: $secondary-text-gray;
$info-hover: $section-white; $info-hover: $section-white;
.log { .log {
flex: 1; font-size: $inner-section-text-size;
height: 100%; overflow-y: auto;
overflow-y: scroll;
font-size: 0.8em;
user-select: text; user-select: text;
min-height: 10vh;
max-height: 40vh;
} }
.logEntry { .logEntry {
display: flex; display: flex;
margin-bottom: 2px;
&.INFO { &.INFO {
color: $info-gray; color: $info-gray;
@@ -33,7 +29,7 @@ $info-hover: $section-white;
} }
.time { .time {
width: 4.5em width: 4.5em;
} }
.origin { .origin {
@@ -48,7 +44,7 @@ $info-hover: $section-white;
.buttonBar { .buttonBar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 0.5rem;
justify-content: flex-start; justify-content: flex-start;
margin-bottom: 8px; padding-bottom: 0.5rem;
} }
@@ -2,11 +2,11 @@ import { useCallback, useState } from 'react';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { LogOrigin } from 'ontime-types'; import { LogOrigin } from 'ontime-types';
import { clearLogs, useLogData } from '../../common/stores/logger'; import { clearLogs, useLogData } from '../../../../common/stores/logger';
import style from './InfoLogger.module.scss'; import style from './Log.module.scss';
export default function InfoLogger() { export default function Log() {
const { logs: logData } = useLogData(); const { logs: logData } = useLogData();
const [showClient, setShowClient] = useState(true); const [showClient, setShowClient] = useState(true);
@@ -52,7 +52,7 @@ export default function InfoLogger() {
<div className={style.buttonBar}> <div className={style.buttonBar}>
<Button <Button
variant={showUser ? 'ontime-filled' : 'ontime-subtle'} variant={showUser ? 'ontime-filled' : 'ontime-subtle'}
size='xs' size='sm'
onClick={() => setShowUser((s) => !s)} onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)} onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -61,7 +61,7 @@ export default function InfoLogger() {
</Button> </Button>
<Button <Button
variant={showClient ? 'ontime-filled' : 'ontime-subtle'} variant={showClient ? 'ontime-filled' : 'ontime-subtle'}
size='xs' size='sm'
onClick={() => setShowClient((s) => !s)} onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)} onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -70,7 +70,7 @@ export default function InfoLogger() {
</Button> </Button>
<Button <Button
variant={showServer ? 'ontime-filled' : 'ontime-subtle'} variant={showServer ? 'ontime-filled' : 'ontime-subtle'}
size='xs' size='sm'
onClick={() => setShowServer((s) => !s)} onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)} onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -79,7 +79,7 @@ export default function InfoLogger() {
</Button> </Button>
<Button <Button
variant={showPlayback ? 'ontime-filled' : 'ontime-subtle'} variant={showPlayback ? 'ontime-filled' : 'ontime-subtle'}
size='xs' size='sm'
onClick={() => setShowPlayback((s) => !s)} onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)} onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -88,7 +88,7 @@ export default function InfoLogger() {
</Button> </Button>
<Button <Button
variant={showRx ? 'ontime-filled' : 'ontime-subtle'} variant={showRx ? 'ontime-filled' : 'ontime-subtle'}
size='xs' size='sm'
onClick={() => setShowRx((s) => !s)} onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)} onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -97,14 +97,14 @@ export default function InfoLogger() {
</Button> </Button>
<Button <Button
variant={showTx ? 'ontime-filled' : 'ontime-subtle'} variant={showTx ? 'ontime-filled' : 'ontime-subtle'}
size='xs' size='sm'
onClick={() => setShowTx((s) => !s)} onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)} onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Tx} {LogOrigin.Tx}
</Button> </Button>
<Button variant='ontime-outlined' size='xs' onClick={clearLogs}> <Button variant='ontime-outlined' size='sm' onClick={clearLogs}>
Clear Clear
</Button> </Button>
</div> </div>
@@ -0,0 +1,26 @@
import * as Panel from '../PanelUtils';
import Log from './Log';
import InfoNif from './NetworkInterfaces';
export default function LogPanel() {
return (
<>
<Panel.Header>Application log</Panel.Header>
<Panel.Section>
<Panel.SubHeader>Available networks</Panel.SubHeader>
<Panel.Card>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
<InfoNif />
</Panel.Card>
</Panel.Section>
<Panel.Section>
<Panel.SubHeader>Network log</Panel.SubHeader>
<Panel.Card>
<Log />
</Panel.Card>
</Panel.Section>
</>
);
}
@@ -0,0 +1,6 @@
.interfaces {
display: flex;
flex-wrap: wrap;
gap: $section-spacing;
row-gap: $element-inner-spacing;
}

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