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
+12 -22
View File
@@ -8,25 +8,12 @@
"jest": true
},
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"eslint-config-prettier"
],
"plugins": [
"@typescript-eslint",
"prettier"
],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", "eslint-config-prettier"],
"plugins": ["@typescript-eslint", "prettier"],
"overrides": [
{
"files": [
"e2e/**/**.spec.ts",
"e2e/**/**.test.ts"
],
"extends": [
"plugin:playwright/playwright-test"
]
"files": ["e2e/**/**.spec.ts", "e2e/**/**.test.ts"],
"extends": ["plugin:playwright/playwright-test"]
}
],
"rules": {
@@ -35,10 +22,13 @@
"no-console": [
"warn",
{
"allow": [
"warn",
"error"
]
"allow": ["warn", "error"]
}
],
"no-restricted-imports": [
"error",
{
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
}
],
"@typescript-eslint/no-non-null-assertion": "warn",
@@ -59,4 +49,4 @@
}
]
}
}
}
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
@@ -49,7 +49,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
@@ -82,7 +82,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
+8 -2
View File
@@ -17,7 +17,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
@@ -73,7 +73,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
@@ -98,3 +98,9 @@ jobs:
name: playwright-report
path: playwright-report/
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
playwright-report
/playwright/.cache/
automated-screenshots
# production
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.
Other dependencies will be installed as part of the setup
- __node__ (>=16.16)
- __pnpm__ (>=7)
- __node__ (~18.18.2)
- __pnpm__ (>=8)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
+23 -23
View File
@@ -1,36 +1,35 @@
{
"name": "ontime-ui",
"version": "2.28.11",
"version": "3.0.0-alpha",
"private": true,
"type": "module",
"dependencies": {
"@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.46.0",
"@sentry/tracing": "^7.46.0",
"@tanstack/react-query": "^5.8.4",
"@tanstack/react-query-devtools": "^5.8.4",
"@tanstack/react-table": "^8.9.2",
"@sentry/react": "^7.92.0",
"@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3",
"autosize": "^6.0.1",
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.2.3",
"deepmerge": "^4.3.0",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0",
"react-hook-form": "^7.43.5",
"react-qr-code": "^2.0.11",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1",
"zustand": "^4.3.6"
"zustand": "^4.4.7"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -45,7 +44,8 @@
"lint-staged": "eslint",
"test": "vitest",
"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": {
"production": [
@@ -60,7 +60,7 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "^0.4.0",
"@sentry/vite-plugin": "^2.14.0",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
@@ -71,7 +71,7 @@
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^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-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.6.0",
@@ -86,10 +86,10 @@
"prettier": "^3.0.3",
"sass": "^1.57.1",
"typescript": "^5.2.2",
"vite": "^4.3.1",
"vite-plugin-compression2": "^0.9.0",
"vite-plugin-svgr": "^2.4.0",
"vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.30.1"
"vite": "^5.1.0",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.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 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 STimer = withAlias(withData(TimerView));
@@ -31,27 +31,20 @@ const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
const Info = lazy(() => import('./features/info/InfoExport'));
export default function AppRouter() {
return (
<Suspense fallback={null}>
<Routes>
<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='/minimal' element={<SMinimalTimer />} />
<Route path='/minimalTimer' element={<SMinimalTimer />} />
<Route path='/simpleTimer' element={<SMinimalTimer />} />
<Route path='/clock' element={<SClock />} />
<Route path='/countdown' element={<SCountdown />} />
<Route path='/sm' element={<SBackstage />} />
<Route path='/backstage' element={<SBackstage />} />
<Route path='/public' element={<SPublic />} />
@@ -65,8 +58,6 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/cuelist' element={<Cuesheet />} />
<Route path='/table' element={<Cuesheet />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
@@ -93,14 +84,6 @@ export default function AppRouter() {
</EditorFeatureWrapper>
}
/>
<Route
path='/info'
element={
<EditorFeatureWrapper>
<Info />
</EditorFeatureWrapper>
}
/>
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</Routes>
+8 -6
View File
@@ -1,19 +1,21 @@
// REST stuff
export const PROJECT_DATA = ['project'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN = ['rundown'];
export const APP_INFO = ['appinfo'];
export const OSC_SETTINGS = ['oscSettings'];
export const HTTP_SETTINGS = ['httpSettings'];
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 SHEET_STATE = ['sheetState'];
export const USERFIELDS = ['userFields'];
export const VIEW_SETTINGS = ['viewSettings'];
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction;
const STATIC_PORT = 4001;
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
*/
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] });
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'] });
await ontimeQueryClient.invalidateQueries();
}
+16 -3
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { GetRundownCached, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { OntimeEvent, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { rundownURL } from './apiConstants';
@@ -7,7 +7,7 @@ import { rundownURL } from './apiConstants';
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchCachedRundown(): Promise<GetRundownCached> {
export async function fetchCachedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownURL}/cached`);
return res.data;
}
@@ -26,7 +26,7 @@ export async function fetchRundown(): Promise<OntimeRundown> {
* @description HTTP request to post new event
* @return {Promise}
*/
export async function requestPostEvent(data: OntimeRundownEntry) {
export async function requestPostEvent(data: Partial<OntimeRundownEntry>) {
return axios.post(rundownURL, data);
}
@@ -38,6 +38,19 @@ export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
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 = {
eventId: string;
from: number;
+103 -27
View File
@@ -4,10 +4,11 @@ import {
DatabaseModel,
GetInfo,
HttpSettings,
MessageResponse,
OntimeRundown,
OSCSettings,
OscSubscription,
ProjectData,
ProjectFileListResponse,
Settings,
UserFields,
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}
*/
export async function postAliases(data: Alias[]) {
export async function postAlias(data: Alias) {
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
* @return {Promise}
@@ -105,6 +122,14 @@ export async function getOSC(): Promise<OSCSettings> {
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
* @return {Promise}
@@ -118,38 +143,26 @@ export async function getHTTP(): Promise<HttpSettings> {
* @description HTTP request to mutate http settings
* @return {Promise}
*/
export async function postHTTP(data: HttpSettings) {
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
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
*/
export const downloadCSV = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
export const downloadCSV = (fileName?: string) => {
return fileDownload(ontimeURL, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
};
/**
* @description HTTP request to download db in JSON format
*/
export const downloadRundown = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' });
export const downloadRundown = (fileName?: string) => {
return fileDownload(
ontimeURL,
{ name: fileName ?? 'rundown', type: 'json' },
{ type: 'application/json;charset=utf-8;' },
);
};
// TODO: should this be extracted to shared code?
@@ -195,7 +208,6 @@ export async function patchData(patchDb: Partial<DatabaseModel>) {
type PostPreviewExcelResponse = {
rundown: OntimeRundown;
project: ProjectData;
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 });
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,
Tooltip,
} 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';
interface QuitIconBtnProps {
clickHandler: () => void;
size?: Size;
disabled?: boolean;
}
@@ -42,7 +40,7 @@ const quitBtnStyle = {
};
export default function QuitIconBtn(props: QuitIconBtnProps) {
const { clickHandler, size = 'lg', disabled, ...rest } = props;
const { clickHandler, disabled, ...rest } = props;
const [isOpen, setIsOpen] = useState(false);
const { emitInfo } = useEmitLog();
const onClose = () => setIsOpen(false);
@@ -67,8 +65,8 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<Tooltip label='Quit Application'>
<IconButton
aria-label='Quit Application'
size={size}
icon={<FiPower />}
size='lg'
icon={<IoPowerOutline />}
onClick={() => setIsOpen(true)}
isDisabled={disabled}
{...quitBtnStyle}
@@ -81,7 +79,7 @@ export default function QuitIconBtn(props: QuitIconBtnProps) {
<AlertDialogHeader fontSize='lg' fontWeight='bold'>
Ontime Shutdown
</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>
<Button ref={cancelRef} onClick={onClose} variant='ghost'>
Cancel
@@ -2,7 +2,7 @@ import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: (event?: MouseEvent) => void | Promise<void>;
clickHandler: (event: MouseEvent) => void | Promise<void>;
tooltip: string;
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 {
position: absolute;
cursor: pointer;
}
.contextMenuBackdrop {
@@ -1,11 +1,13 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { Fragment, ReactElement } from 'react';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { ReactElement } from 'react';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
@@ -13,14 +15,23 @@ type ContextMenuCoords = {
y: number;
};
export type Option = {
export type OptionWithoutGroup = {
label: string;
isDisabled?: boolean;
icon: IconType;
onClick: () => void;
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 = {
coords: ContextMenuCoords;
options: Option[];
@@ -69,14 +80,17 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
}}
/>
<MenuList>
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
<Fragment key={label}>
{withDivider && <MenuDivider />}
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</Fragment>
))}
{options.map((option) =>
isOptionWithGroup(option) ? (
<MenuGroup key={option.label} title={option.label}>
{option.group.map((groupOption) => (
<ContextMenuOption key={groupOption.label} {...groupOption} />
))}
</MenuGroup>
) : (
<ContextMenuOption key={option.label} {...option} />
),
)}
</MenuList>
</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 {
svg {
font-size: 1.5rem;
@@ -17,7 +17,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
@@ -27,7 +27,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
@@ -3,7 +3,7 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
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 style from './ErrorBoundary.module.scss';
@@ -23,13 +23,13 @@ class ErrorBoundary extends React.Component {
componentDidCatch(error, info) {
this.setState({
error: error,
error,
errorInfo: info,
});
Sentry.withScope((scope) => {
scope.setExtras('error', error);
scope.setExtras('store', runtime.getState());
scope.setExtras('store', runtimeStore.getState());
scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
@@ -1,5 +1,3 @@
@use '../../../theme/v2Styles' as *;
.errorContainer {
width: 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';
export const AutoTextArea = (props: TextareaProps) => {
const { value } = props;
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
@@ -12,7 +14,7 @@ export const AutoTextArea = (props: TextareaProps) => {
return () => {
autosize.destroy(node);
};
}, []);
}, [value]);
return (
<Textarea
@@ -1,24 +1,24 @@
.list {
display: flex;
gap: 4px;
flex-wrap: wrap;
gap: 0.5rem
}
.swatch {
cursor: pointer;
aspect-ratio: 1;
width: 2rem;
height: 2rem;
border-radius: 16px;
border: 4px solid #262626;
aspect-ratio: 1;
border-radius: 99px;
border: 2px solid $gray-1200;
&.selected {
border: 2px solid #578AF4;
border: 2px solid $blue-500;
}
}
.center {
display: grid;
place-content: center;
color: #578AF4;
}
color: $ui-white;
}
@@ -1,15 +1,13 @@
import { useCallback } from 'react';
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
import Swatch from './Swatch';
import style from './SwatchSelect.module.scss';
interface ColourInputProps {
value: string;
name: EditorUpdateFields;
handleChange: (newValue: EditorUpdateFields, name: string) => void;
name: 'colour';
handleChange: (newValue: 'colour', name: string) => void;
}
const colours = [
@@ -1,5 +1,3 @@
@use '../../../../theme/v2Styles' as *;
$input-font-size: 15px;
.delayInput {
@@ -1,6 +1,3 @@
@use "../../../../theme/_ontimeColours" as *;
@use "../../../../theme/_v2Styles" as *;
.swatch {
width: 24px;
height: 24px;
@@ -1,7 +1,7 @@
import { useCallback, useRef } from '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 useReactiveTextInput from './useReactiveTextInput';
@@ -1,37 +1,4 @@
@use "../../../../theme/v2Styles" as *;
$input-font-size: 15px;
$input-delayed-border-color: #E69056;
.timeInput {
width: fit-content !important;
.inputLeft {
max-width: fit-content;
}
.inputLeft,
.inputButton {
aspect-ratio: 1;
}
.inputField {
font-size: $input-font-size;
letter-spacing: 1px;
width: 7.5em;
padding: 0 0 0 2.6em;
}
.warn {
&::after {
content: "*";
color: $warning-orange;
}
}
&.delayed {
.inputField {
border: 1px solid $input-delayed-border-color;
}
}
width: 6.5em;
}
@@ -1,42 +1,23 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Input, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import { useEmitLog } from '../../../stores/logger';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import { cx } from '../../../utils/styleUtils';
import { TimeEntryField } from '../../../utils/timesManager';
import style from './TimeInput.module.scss';
interface TimeInputProps {
id?: TimeEntryField;
name: TimeEntryField;
submitHandler: (field: TimeEntryField, value: number) => void;
interface TimeInputProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
delay?: number;
placeholder: string;
previousEnd?: number;
tooltip?: string;
disabled?: boolean;
className?: string;
}
function ButtonInitial(name: TimeEntryField) {
if (name === 'timeStart') return 'S';
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;
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -72,32 +53,20 @@ export default function TimeInput(props: TimeInputProps) {
return false;
}
let newValMillis = 0;
// check for known aliases
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);
// we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) {
submitHandler(name, newValue);
}
// check if time is different from before
if (newValMillis === time) return false;
// update entry
submitHandler(name, newValMillis);
const valueInMillis = forgivingStringToMillis(newValue);
if (valueInMillis === time) {
return false;
}
submitHandler(name, newValue);
return true;
},
[name, previousEnd, submitHandler, time],
[name, submitHandler, time],
);
/**
@@ -107,15 +76,11 @@ export default function TimeInput(props: TimeInputProps) {
const validateAndSubmit = useCallback(
(newValue: string) => {
const success = handleSubmit(newValue);
if (success) {
const ms = forgivingStringToMillis(newValue);
const delayed = name === 'timeEnd' ? Math.max(0, ms + delay) : Math.max(0, ms + delay);
setValue(millisToString(delayed));
} else {
if (!success) {
resetValue();
}
},
[delay, handleSubmit, name, resetValue],
[handleSubmit, resetValue],
);
/**
@@ -155,51 +120,26 @@ export default function TimeInput(props: TimeInputProps) {
resetValue();
}, [resetValue, time]);
const isDelayed = delay !== 0;
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]);
const timeInputClasses = cx([style.timeInput, className]);
return (
<InputGroup size='sm' className={inputClasses}>
<InputLeftElement className={style.inputLeft}>
<Tooltip label={TooltipLabel} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<Button
size='sm'
variant='ontime-subtle-white'
className={buttonClasses}
tabIndex={-1}
border={isDelayed ? '1px solid #E69056' : '1px solid transparent'}
borderRight='1px solid transparent'
borderRadius='2px 0 0 2px'
>
{ButtonText}
</Button>
</Tooltip>
</InputLeftElement>
<Input
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>
<Input
disabled={disabled}
size='sm'
ref={inputRef}
data-testid={`time-input-${name}`}
className={timeInputClasses}
fontSize='1rem'
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'
/>
);
}
@@ -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 './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: number | null;
now: MaybeNumber;
complete: number;
normalColor: string;
warning: number;
warning?: MaybeNumber;
warningColor: string;
danger: number;
danger?: MaybeNumber;
dangerColor: string;
hidden?: boolean;
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 dangerWidth = clamp((danger / complete) * 100, 0, 100);
const warningWidth = clamp((warning / complete) * 100, 0, 100);
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
return (
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
@@ -1,6 +1,4 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/mixins' as *;
@use '../../../theme/ontimeColours' as *;
$menu-bg: $gray-1200;
$menu-hover-bg: $gray-1350;
@@ -71,7 +69,6 @@ $button-size: 48px;
@include action-link;
justify-content: space-between;
padding: 0.5rem 1rem;
cursor: pointer;
&:hover {
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 { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
@@ -13,6 +13,7 @@ import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
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 handleMirror = () => toggleMirror();
@@ -30,7 +30,7 @@ export default function RenameClientModal({ isOpen, onClose }: RenameClientModal
const handleRename = async () => {
if (newName) {
await setClientName(newName);
setClientName(newName);
persistName(newName);
onClose();
}
@@ -1,5 +1,3 @@
@use '../../../theme/v2Styles' as *;
.container {
display: grid;
place-content: center;
@@ -4,7 +4,8 @@ import { formatTime } from '../../utils/time';
import './Schedule.scss';
const formatOptions = {
format: 'hh:mm a',
format12: 'hh:mm a',
format24: 'HH:mm',
};
interface ScheduleItemProps {
@@ -1,5 +1,3 @@
@use '../../../theme/ontimeColours' as *;
.emptyContainer {
width: 100%;
text-align: center;
@@ -1,6 +1,6 @@
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';
@@ -13,7 +13,7 @@ export default function Empty(props: EmptyProps) {
const { text, ...rest } = props;
return (
<div className={style.emptyContainer} {...rest}>
<Emptyimage className={style.empty} />
<EmptyImage className={style.empty} />
<span className={style.text}>{text}</span>
</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 {
background-color: $gray-1250;
}
@@ -12,7 +12,7 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import ParamInput from './ParamInput';
import { ParamField } from './types';
@@ -23,14 +23,14 @@ type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>;
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]) => {
if (typeof value === 'string' && value.length) {
if (defaultValues.includes(value)) {
if (defaultValues[id] === value) {
return newSearchParams;
}
newSearchParams.set(id, value);
return newSearchParams;
@@ -1,18 +1,27 @@
import { UserFields } from 'ontime-types';
import { TimeFormat } from 'ontime-types/src/definitions/core/TimeFormat.type';
import { ParamField } from './types';
export const getTimeOption = (timeFormat: TimeFormat): ParamField => ({
id: 'format',
title: '12 / 24 hour timer',
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
type: 'option',
values: { '12': '12 hour AM/PM', '24': '24 hour' },
defaultValue: timeFormat,
});
const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`;
return {
id: 'timeformat',
title: 'Time format string, taken from the Application Settings',
description: 'Format for auxiliar time fields (not the running), eg. HH:mm:ss or hh:mm:ss a, see docs for help',
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),
{
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),
hideTimerSeconds,
{
id: 'hideClock',
title: 'Hide Time Now',
@@ -124,6 +134,7 @@ export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [
];
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
hideTimerSeconds,
{
id: 'key',
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',
title: 'Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
id: 'trigger',
title: 'Animation Trigger',
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',
placeholder: '1 (default)',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
@@ -231,39 +326,31 @@ export const LOWER_THIRDS_OPTIONS: ParamField[] = [
placeholder: '3 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffffa (default)',
},
{
id: 'bg',
title: 'Text Background',
description: 'Text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Screen background colour in hexadecimal',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
placeholder: 'ffffffff (default)',
},
{
id: 'fadeout',
title: 'Fadeout',
description: 'Time (in seconds) the lower third displays before fading out',
type: 'number',
placeholder: '3 (default)',
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
export const getBackstageOptions = (timeFormat: TimeFormat): ParamField[] => [
export const getBackstageOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
{
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),
{
id: 'hidePast',
@@ -312,27 +399,14 @@ export const getPublicOptions = (timeFormat: TimeFormat): ParamField[] => [
placeholder: '7 (default)',
},
];
export const getStudioClockOptions = (timeFormat: TimeFormat): ParamField[] => [
export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'seconds',
title: 'Show Seconds',
description: 'Shows seconds in clock',
type: 'boolean',
defaultValue: false,
},
hideTimerSeconds,
];
export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeFormat): ParamField[] => {
export const getOperatorOptions = (userFields: UserFields, timeFormat: string): ParamField[] => {
return [
getTimeOption(timeFormat),
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidepast',
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 { HttpSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
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
return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch };
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
}
export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
});
return { isPending, mutateAsync };
@@ -3,17 +3,14 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getOSC, postOSC, postOscSubscriptions } from '../api/ontimeApi';
import { getOSC, postOSC } from '../api/ontimeApi';
import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: async () => {
const oscData = await getOSC();
return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) };
},
queryFn: getOSC,
placeholderData: oscPlaceholderSettings,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
@@ -28,16 +25,9 @@ export function useOscSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postOSC,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isPending, mutateAsync };
}
export function usePostOscSubscriptions() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postOscSubscriptions,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isPending, mutateAsync };
@@ -16,5 +16,5 @@ export default function useProjectData() {
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 { GetRundownCached } from 'ontime-types';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN } from '../api/apiConstants';
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() {
const { data, status, isError, refetch, isFetching } = useQuery<GetRundownCached>({
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
queryFn: fetchCachedRundown,
placeholderData: cachedRundownPlaceholder,
@@ -17,13 +18,24 @@ export default function useRundown() {
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
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 { useMutation, useQueryClient } from '@tanstack/react-query';
import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import {
ReorderEntry,
requestApplyDelay,
requestBatchPutEvents,
requestDelete,
requestDeleteAll,
requestEventSwap,
@@ -17,6 +18,7 @@ import {
SwapEntry,
} from '../api/eventsApi';
import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
/**
* @description Set of utilities for events
@@ -32,8 +34,6 @@ export const useEventAction = () => {
* @private
*/
const _addEventMutation = useMutation({
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
mutationFn: requestPostEvent,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
@@ -67,21 +67,13 @@ export const useEventAction = () => {
after: options?.after,
};
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
if (newEvent?.cue === undefined) {
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;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this has a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const { rundown } = rundownData;
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
if (previousEvent !== undefined && previousEvent.type === 'event') {
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd;
}
@@ -98,7 +90,6 @@ export const useEventAction = () => {
}
try {
// @ts-expect-error -- we know that the object is well formed now
await _addEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Failed adding event', error);
@@ -119,19 +110,15 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const eventId = newEvent.id;
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
if (previousData) {
if (previousData && eventId) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const index = optimisticRundown.findIndex((event) => event.id === newEvent.id);
if (index > -1) {
// @ts-expect-error -- we expect the event types to match
optimisticRundown[index] = { ...optimisticRundown[index], ...newEvent };
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
const newRundown = { ...previousData.rundown };
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous and new events
@@ -163,6 +150,154 @@ export const useEventAction = () => {
[_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
* @private
@@ -175,20 +310,19 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const index = optimisticRundown.findIndex((event) => event.id === eventId);
if (index > -1) {
optimisticRundown.splice(index, 1);
const newOrder = previousData.order.filter((id) => id !== eventId);
const newRundown = { ...previousData.rundown };
delete newRundown[eventId];
queryClient.setQueryData(RUNDOWN, {
rundown: optimisticRundown,
revision: -1,
});
}
queryClient.setQueryData(RUNDOWN, {
order: newOrder,
rundown: newRundown,
revision: -1,
});
}
// Return a context with the previous and new events
@@ -233,10 +367,10 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
// 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 { previousData };
@@ -304,15 +438,13 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const [reorderedItem] = optimisticRundown.splice(data.from, 1);
optimisticRundown.splice(data.to, 0, reorderedItem);
const newOrder = reorderArray(previousData.order, data.from, data.to);
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
@@ -338,9 +470,9 @@ export const useEventAction = () => {
async (eventId: string, from: number, to: number) => {
try {
const reorderObject: ReorderEntry = {
eventId: eventId,
from: from,
to: to,
eventId,
from,
to,
};
await _reorderEventMutation.mutateAsync(reorderObject);
} catch (error) {
@@ -362,15 +494,22 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const fromEventIndex = previousData.rundown.findIndex((event) => event.id === from);
const toEventIndex = previousData.rundown.findIndex((event) => event.id === to);
const newRundown = { ...previousData.rundown };
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
@@ -394,8 +533,6 @@ export const useEventAction = () => {
*/
const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => {
// TODO: before calling `/swapEvents`,
// we should determine the events are of type `OntimeEvent`
try {
await _swapEvents.mutateAsync({ from, to });
} catch (error) {
@@ -407,11 +544,14 @@ export const useEventAction = () => {
return {
addEvent,
updateEvent,
applyDelay,
batchUpdateEvents,
deleteEvent,
deleteAllEvents,
applyDelay,
linkTimer,
reorderEvent,
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';
export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
nextEventId: state.loaded.nextEventId,
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
nextEventId: state.eventNext?.id ?? null,
});
return useRuntimeStore(featureSelector, deepCompare);
return useRuntimeStore(featureSelector);
};
export const useOperator = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
});
return useRuntimeStore(featureSelector, deepCompare);
return useRuntimeStore(featureSelector);
};
export const useMessageControl = () => {
const featureSelector = (state: RuntimeStore) => ({
timerMessage: state.timerMessage,
publicMessage: state.publicMessage,
lowerMessage: state.lowerMessage,
externalMessage: state.externalMessage,
timer: state.message.timer,
public: state.message.public,
lower: state.message.lower,
external: state.message.external,
onAir: state.onAir,
});
return useRuntimeStore(featureSelector, deepCompare);
return useRuntimeStore(featureSelector);
};
export const setMessage = {
presenterText: (payload: string) => socketSendJson('set-timer-message-text', payload),
presenterVisible: (payload: boolean) => socketSendJson('set-timer-message-visible', payload),
publicText: (payload: string) => socketSendJson('set-public-message-text', payload),
publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload),
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload),
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload),
externalText: (payload: string) => socketSendJson('set-external-message-text', payload),
externalVisible: (payload: boolean) => socketSendJson('set-external-message-visible', payload),
onAir: (payload: boolean) => socketSendJson('set-onAir', payload),
timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload),
timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload),
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }),
publicText: (payload: string) => socketSendJson('message', { public: { text: payload } }),
publicVisible: (payload: boolean) => socketSendJson('message', { public: { visible: payload } }),
lowerText: (payload: string) => socketSendJson('message', { lower: { text: payload } }),
lowerVisible: (payload: boolean) => socketSendJson('message', { lower: { visible: payload } }),
externalText: (payload: string) => socketSendJson('message', { external: { visible: payload } }),
externalVisible: (payload: boolean) => socketSendJson('message', { external: { visible: payload } }),
onAir: (payload: boolean) => socketSendJson('onAir', payload),
timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }),
};
export const usePlaybackControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
});
return useRuntimeStore(featureSelector, deepCompare);
return useRuntimeStore(featureSelector);
};
export const setPlayback = {
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
roll: () => socketSendJson('roll'),
startNext: () => socketSendJson('start-next'),
startNext: () => socketSendJson('start', { next: true }),
previous: () => {
socketSendJson('previous');
socketSendJson('load', { previous: true });
},
next: () => {
socketSendJson('next');
socketSendJson('load', { next: true });
},
stop: () => {
socketSendJson('stop');
@@ -84,29 +84,52 @@ export const useInfoPanel = () => {
const featureSelector = (state: RuntimeStore) => ({
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
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 = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
titleNow: state.eventNow?.title || '',
});
return useRuntimeStore(featureSelector, deepCompare);
return useRuntimeStore(featureSelector);
};
export const setEventPlayback = {
loadEvent: (eventId: string) => socketSendJson('loadid', eventId),
startEvent: (eventId: string) => socketSendJson('startid', eventId),
loadEvent: (id: string) => socketSendJson('load', { id }),
startEvent: (id: string) => socketSendJson('start', { id }),
start: () => socketSendJson('start'),
pause: () => socketSendJson('pause'),
};
@@ -116,7 +139,37 @@ export const useTimer = () => {
...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 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 = {
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onUpdate: [],
onPause: [],
onStop: [],
onFinish: [],
},
subscriptions: [],
};
+2 -16
View File
@@ -1,20 +1,6 @@
import { GetInfo, OSCSettings } from 'ontime-types';
import { GetInfo } from 'ontime-types';
export const oscPlaceholderSettings: OSCSettings = {
portIn: 0,
portOut: 0,
targetIP: '',
enabledIn: false,
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
};
import { oscPlaceholderSettings } from './OscSettings';
export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [],
+5 -18
View File
@@ -1,23 +1,10 @@
import { OSCSettings } from 'ontime-types';
// in the placeholder, we pass strings to satisfy input type
export interface PlaceholderSettings extends Omit<OSCSettings, 'portIn' | 'portOut'> {
portIn: string;
portOut: string;
}
export const oscPlaceholderSettings: PlaceholderSettings = {
portIn: '',
portOut: '',
targetIP: '',
export const oscPlaceholderSettings: OSCSettings = {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
subscriptions: [],
};
@@ -1,18 +1,19 @@
import { Playback, TimerType } from 'ontime-types';
import { MaybeNumber, Playback, TimerType } from 'ontime-types';
export type TimeManagerType = {
clock: number;
current: null | number;
elapsed: null | number;
duration: null | number;
timerBehaviour?: string;
timerType: TimerType;
expectedFinish: null | number;
// first set extends TimerState
export type ViewExtendedTimer = {
addedTime: number;
startedAt: null | number;
finishedAt: null | number;
secondaryTimer: null | number;
finished: boolean;
current: MaybeNumber;
duration: MaybeNumber;
elapsed: MaybeNumber;
expectedFinish: MaybeNumber;
finishedAt: MaybeNumber;
playback: Playback;
secondaryTimer: MaybeNumber;
startedAt: MaybeNumber;
clock: number;
timeDanger: MaybeNumber;
timeWarning: MaybeNumber;
timerType: TimerType;
};
@@ -14,4 +14,5 @@ export type OverridableOptions = {
hideEndMessage?: boolean;
language?: string;
showProgressBar?: boolean;
hideTimerSeconds?: boolean;
};
@@ -4,8 +4,6 @@ export const viewsSettingsPlaceholder: ViewSettings = {
overrideStyles: false,
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '',
};
+13 -47
View File
@@ -8,63 +8,29 @@ export enum AppMode {
const appModeKey = 'ontime-app-mode';
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) {
localStorage.setItem(appModeKey, mode);
function persistModeToSession(mode: AppMode) {
sessionStorage.setItem(appModeKey, mode);
}
type AppModeStore = {
mode: AppMode;
cursor: string | null;
editId: string | null;
setMode: (mode: AppMode) => void;
setCursor: (id: string | null, isEditable?: boolean) => void;
setEditId: (id: string | null) => void;
cursor: string | null;
setCursor: (cursor: string | null) => void;
};
export const useAppMode = create<AppModeStore>()((set) => ({
mode: getModeFromSession(),
cursor: null,
editId: null,
setMode: (mode: AppMode) =>
set((state) => {
persistModeToSession(mode);
return mode === AppMode.Edit
? {
editId: state.cursor,
mode: mode,
}
: {
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,
};
}),
setMode: (mode: AppMode) => {
persistModeToSession(mode);
return set(() => {
return { mode };
});
},
setCursor: (cursor: string | null) => set(() => ({ cursor })),
}));
@@ -6,7 +6,6 @@ type EditorSettings = {
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
defaultPublic: boolean;
showNif: boolean;
};
type EditorSettingsStore = {
@@ -15,14 +14,12 @@ type EditorSettingsStore = {
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setShowNif: (showNif: boolean) => void;
};
enum EditorSettingsKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public',
ShowNif = 'ontime-show-nif',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
@@ -30,7 +27,6 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
showNif: booleanFromLocalStorage(EditorSettingsKeys.ShowNif, true),
},
setLocalEventSettings: (value) =>
@@ -58,10 +54,4 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(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 { Playback, RuntimeStore } from 'ontime-types';
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
export const runtimeStorePlaceholder: RuntimeStore = {
clock: 0,
timer: {
clock: 0,
addedTime: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null,
addedTime: 0,
startedAt: null,
finishedAt: null,
playback: Playback.Stop,
secondaryTimer: null,
selectedEventId: 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,
startedAt: null,
},
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,
selectedEventIndex: null,
selectedEventId: null,
selectedPublicEventId: null,
nextEventId: null,
nextPublicEventId: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
publicEventNext: null,
timer1: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
},
};
export const runtime = createStore<RuntimeStore>(() => ({
...runtimeStorePlaceholder,
}));
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
export const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
export const runtimeStore = createWithEqualityFn<RuntimeStore>(
() => ({
...runtimeStorePlaceholder,
}),
deepCompare,
);
export const useRuntimeStore = <T>(
selector: (state: RuntimeStore) => T,
equalityFn?: (a: unknown, b: unknown) => boolean,
) => useStore(runtime, selector, equalityFn);
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
@@ -1,95 +1,4 @@
import {
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);
});
});
import { forgivingStringToMillis, millisToDelayString } from '../dateConfig';
describe('test forgivingStringToMillis()', () => {
describe('function handles time with no separators', () => {
@@ -358,10 +267,10 @@ describe('millisToDelayString()', () => {
});
describe('converts values 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', () => {
expect(millisToDelayString(-10000)).toBe('-10 sec');
expect(millisToDelayString(-10000, true)).toBe('-10 sec');
});
const underAMinute = [1, 500, 1000, 6000, 55000, 59999];
@@ -375,32 +284,32 @@ describe('millisToDelayString()', () => {
describe('converts values 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', () => {
expect(millisToDelayString(-720000)).toBe('-12 min');
expect(millisToDelayString(-720000, true)).toBe('-12 min');
});
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', () => {
expect(millisToDelayString(-630000)).toBe('-00:10:30');
expect(millisToDelayString(-630000, true)).toBe('-00:10:30');
});
const underAnHour = [60000, 360000, 720000];
underAnHour.forEach((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', () => {
it('positive added time', () => {
expect(millisToDelayString(45015000)).toBe('+12:30:15');
expect(millisToDelayString(45015000, true)).toBe('+12:30:15');
});
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',
user8: 'user8',
user9: 'user9',
timeWarning: 120000,
timeDanger: 60000,
} as OntimeEvent;
const cloned = cloneEvent(original);
@@ -51,5 +53,7 @@ describe('cloneEvent()', () => {
expect(cloned.colour).toBe(original.colour);
expect(cloned.type).toBe(SupportedEvent.Event);
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', () => {
test('isOnlyNumbers', () => {
@@ -36,4 +36,16 @@ describe('simple tests for regex', () => {
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()', () => {
it('parses 24h strings', () => {
const ms = 13 * 60 * 60 * 1000;
const options = {
showSeconds: true,
format: 'irrelevant',
};
const time = formatTime(ms, options, () => '24');
const time = formatTime(ms, {format12: "hh:mm:ss", format24: "HH:mm:ss" }, (_format12, format24) => format24);
expect(time).toStrictEqual('13:00:00');
});
it('parses same string in 12h strings', () => {
const ms = 13 * 60 * 60 * 1000;
const options = {
showSeconds: true,
format: 'hh:mm:ss a',
};
const time = formatTime(ms, options, () => '12');
const time = formatTime(ms, {format12: "hh:mm:ss a", format24: "HH:mm:ss" }, (format12, _format24) => format12);
expect(time).toStrictEqual('01:00:00 PM');
});
@@ -27,13 +32,9 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('...');
});
it('shows 12h format without times', () => {
const ms = 13 * 60 * 60 * 1000;
const options = {
showSeconds: false,
format: 'hh:mm a',
};
const time = formatTime(ms, options, () => '12');
expect(time).toStrictEqual('01:00 PM');
it('handles negative times', () => {
const ms = 1 * 60 * 60 * 1000;
const time = formatTime(-ms, {format12: "hh:mm a", format24: "HH:mm" }, (_format12, format24) => format24);
expect(time).toStrictEqual('-01:00');
});
});
+23 -54
View File
@@ -1,37 +1,4 @@
import { formatFromMillis } 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);
};
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
/**
* @description safe parse string to int
@@ -88,7 +55,7 @@ function checkMatchers(value: string) {
const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 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 };
}
@@ -105,13 +72,13 @@ function inferSeparators(value: string, isAM: boolean, isPM: boolean) {
let addAM = 0;
if (length === 1) {
if (isPM || isAM) {
inferredMillis = parse(value) * mth;
inferredMillis = parse(value) * MILLIS_PER_HOUR;
if (isAM) {
// this ensures we dont add 12 hours in the end
addAM = inferredMillis;
}
} else {
inferredMillis = parse(value) * mtm;
inferredMillis = parse(value) * MILLIS_PER_MINUTE;
}
} else if (length === 2) {
if (isPM || isAM) {
@@ -121,22 +88,22 @@ function inferSeparators(value: string, isAM: boolean, isPM: boolean) {
addAM = 12;
}
} else {
inferredMillis = parse(value) * mtm;
inferredMillis = parse(value) * MILLIS_PER_MINUTE;
}
} 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) {
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) {
const hours = parse(value.substring(0, 2));
const minutes = parse(value.substring(2, 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) {
const hours = parse(value.substring(0, 2));
const minutes = parse(value.substring(2, 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 };
}
@@ -167,9 +134,9 @@ export const forgivingStringToMillis = (value: string): number => {
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * mth;
millis += parse(second) * mtm;
millis += parse(third) * mts;
millis = parse(first) * MILLIS_PER_HOUR;
millis += parse(second) * MILLIS_PER_MINUTE;
millis += parse(third) * MILLIS_PER_SECOND;
} else if (first != null && second == null && third == null) {
// we only have one section, infer separators
const { inferredMillis, addAM } = inferSeparators(first, isAM, isPM);
@@ -177,30 +144,32 @@ export const forgivingStringToMillis = (value: string): number => {
hoursMatchValue = addAM;
}
if (first != null && second != null && third == null) {
millis = parse(first) * mth;
millis += parse(second) * mtm;
millis = parse(first) * MILLIS_PER_HOUR;
millis += parse(second) * MILLIS_PER_MINUTE;
}
// Add 12 hours if it is PM
if (isPM && hoursMatchValue < 12) {
millis += 12 * mth;
millis += 12 * MILLIS_PER_HOUR;
}
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) {
return null;
}
const isNegative = millis < 0;
const absMillis = Math.abs(millis);
const delayed = small ? '+' : 'delayed by ';
const ahead = small ? '-' : 'ahead by ';
if (absMillis < mtm) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 's')} sec`;
} else if (absMillis < mth && absMillis % mtm === 0) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'm')} min`;
if (absMillis < MILLIS_PER_MINUTE) {
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 's')} sec`;
} else if (absMillis < MILLIS_PER_HOUR && absMillis % MILLIS_PER_MINUTE === 0) {
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'm')} min`;
} 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<
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 => {
return {
type: SupportedEvent.Event,
title: event.title,
cue: event.cue,
subtitle: event.subtitle,
presenter: event.presenter,
note: event.note,
@@ -22,11 +21,15 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
linkStart: event.linkStart,
endAction: event.endAction,
isPublic: event.isPublic,
skip: event.skip,
colour: event.colour,
after: after,
after,
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 isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
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 { socketClientName } from '../stores/connectionName';
import { addLog } from '../stores/logger';
import { runtime } from '../stores/runtime';
import { runtimeStore } from '../stores/runtime';
export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -63,52 +63,40 @@ export const connectSocket = (preferredClientName?: string) => {
break;
}
case 'ontime': {
runtime.setState(payload as RuntimeStore);
runtimeStore.setState(payload as RuntimeStore);
if (!isProduction) {
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
}
break;
}
case 'ontime-playback': {
const state = runtime.getState();
state.playback = payload;
runtime.setState(state);
const state = runtimeStore.getState();
state.timer.playback = payload;
runtimeStore.setState(state);
break;
}
case 'ontime-timer': {
const state = runtime.getState();
const state = runtimeStore.getState();
state.timer = payload;
runtime.setState(state);
runtimeStore.setState(state);
break;
}
case 'ontime-loaded': {
const state = runtime.getState();
state.loaded = payload;
runtime.setState(state);
case 'ontime-runtime': {
const state = runtimeStore.getState();
state.runtime = payload;
runtimeStore.setState(state);
break;
}
case 'ontime-timerMessage': {
const state = runtime.getState();
state.timerMessage = payload;
runtime.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);
case 'ontime-message': {
const state = runtimeStore.getState();
state.message = payload;
runtimeStore.setState(state);
break;
}
case 'ontime-onAir': {
const state = runtime.getState();
const state = runtimeStore.getState();
state.onAir = payload;
runtime.setState(state);
runtimeStore.setState(state);
break;
}
}
+60 -20
View File
@@ -1,6 +1,7 @@
import { Settings } from 'ontime-types';
import { formatFromMillis, millisToString } from 'ontime-utils';
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { formatFromMillis } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
@@ -22,36 +23,75 @@ export const nowInMillis = () => {
/**
* @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 urlOptions = params.get('format');
const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS);
return params.get('timeformat');
}
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 = {
showSeconds?: boolean;
format?: string;
format12: string;
format24: string;
};
/**
* @description utility function to format a date in 12 or 24 hour format
* @param {number | null} milliseconds
* @description viewer specific utility function to format a date in 12 or 24 hour format
* @param {MaybeNumber} milliseconds
* @param {object} [options]
* @param {boolean} [options.showSeconds]
* @param {string} [options.format]
* @param {function} resolver
* @param {string} [options.format.format12] format string if 12 hour time
* @param {string} [options.format.format24] format string if 24 hour time
* @param {Function} resolver DI for testing
* @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) {
return '...';
}
const timeFormat = resolver();
const fallback = options?.showSeconds ? 'hh:mm:ss a' : 'hh:mm a';
const { showSeconds = false, format: formatString = fallback } = options || {};
return timeFormat === '12' ? formatFromMillis(milliseconds, formatString) : millisToString(milliseconds, showSeconds);
const timeFormat = resolver(options?.format12 ?? FORMAT_12, options?.format24 ?? FORMAT_24);
const display = formatFromMillis(Math.abs(milliseconds), timeFormat);
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 apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no';
export const gitbookUrl = 'https://ontime.gitbook.io';
@@ -1,8 +1,6 @@
@use '../theme/v2Styles' as *;
.wrapper {
background: $bg-container-l1;
width: 100%;
height: 100%;
padding: max(16px, 2vh);
}
}
@@ -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 { Button } from '@chakra-ui/react';
import { getLatestVersion, HasUpdate } from '../../../common/api/ontimeApi';
import ModalLink from '../ModalLink';
import { getLatestVersion, HasUpdate } from '../../../../common/api/ontimeApi';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import styles from '../Modal.module.scss';
interface UpdateCheckerProps {
version: string;
}
import style from '../Panel.module.scss';
type CheckFail = {
error: string;
@@ -20,8 +16,13 @@ type CheckIsLatest = {
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 [updateMessage, setUpdateMessage] = useState<CheckRemote | null>(null);
const [isFetching, setIsFetching] = useState(false);
@@ -50,12 +51,12 @@ export default function UpdateChecker(props: UpdateCheckerProps) {
const disableButton = Boolean(updateMessage && 'version' in updateMessage);
return (
<div className={styles.updateSection}>
<>
<Button onClick={versionCheck} variant='ontime-filled' isLoading={isFetching} isDisabled={disableButton}>
Check for updates
</Button>
<ResolveUpdateMessage updateMessage={updateMessage} />
</div>
</>
);
}
@@ -63,10 +64,10 @@ function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
const { updateMessage } = props;
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) {
return <ModalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ModalLink>;
return <ExternalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ExternalLink>;
}
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-hover: $section-white;
.log {
flex: 1;
height: 100%;
overflow-y: scroll;
font-size: 0.8em;
font-size: $inner-section-text-size;
overflow-y: auto;
user-select: text;
min-height: 10vh;
max-height: 40vh;
}
.logEntry {
display: flex;
margin-bottom: 2px;
&.INFO {
color: $info-gray;
@@ -33,7 +29,7 @@ $info-hover: $section-white;
}
.time {
width: 4.5em
width: 4.5em;
}
.origin {
@@ -48,7 +44,7 @@ $info-hover: $section-white;
.buttonBar {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 0.5rem;
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 { 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 [showClient, setShowClient] = useState(true);
@@ -52,7 +52,7 @@ export default function InfoLogger() {
<div className={style.buttonBar}>
<Button
variant={showUser ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
size='sm'
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()}
@@ -61,7 +61,7 @@ export default function InfoLogger() {
</Button>
<Button
variant={showClient ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
size='sm'
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()}
@@ -70,7 +70,7 @@ export default function InfoLogger() {
</Button>
<Button
variant={showServer ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
size='sm'
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()}
@@ -79,7 +79,7 @@ export default function InfoLogger() {
</Button>
<Button
variant={showPlayback ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
size='sm'
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()}
@@ -88,7 +88,7 @@ export default function InfoLogger() {
</Button>
<Button
variant={showRx ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
size='sm'
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()}
@@ -97,14 +97,14 @@ export default function InfoLogger() {
</Button>
<Button
variant={showTx ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
size='sm'
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Tx}
</Button>
<Button variant='ontime-outlined' size='xs' onClick={clearLogs}>
<Button variant='ontime-outlined' size='sm' onClick={clearLogs}>
Clear
</Button>
</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