Compare commits

...

96 Commits

Author SHA1 Message Date
Carlos Valente 7d9c2a9cfd refactor: create mock data utilities 2025-01-12 09:05:23 +01:00
Alex Christoffer Rasmussen e9a7cd0400 More rundown metadata (#1429)
* add dayOffset to OntimeEvent

* refactor: isNewLatest

* calculate totalDays

* refactor: getTimeFromPrevious

* add gap as dataset in OntimeEvent

* use in ui

* format overlap is just a simple text formatting, big test is not needed

* add new fields where needed

* update apply delay

* show nex day eaven if there is no gap

* create test

* use buildin day offset in timeline

* remove todo

* consistent naming

* make a calculateDayOffset util for rundownCache

* refactor: checkIsNextDay to use dayOffset

* spelling

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

* remove unneeded test

* remove todo

* update test db

* use data-testid

* spelling

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

* update comments

* remove null option

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-09 13:35:11 +01:00
Carlos Valente 9ba40c35a1 refactor: avoid matching file versions 2025-01-06 22:58:06 +01:00
Carlos Valente f94db06812 refactor: wider sheet id length 2025-01-06 22:58:06 +01:00
Carlos Valente 35424cfff2 bump version to 3.10.3 2025-01-06 20:33:39 +01:00
Alex Christoffer Rasmussen 13eadeb0af fix download (#1434)
* fix download

* update test db

* create download test
2025-01-06 20:26:06 +01:00
Carlos Valente 9cfed5a150 fix: issue with persistent script 2025-01-06 20:11:15 +01:00
Alex Christoffer Rasmussen a22c2d380d bump: v3.10.2 (#1431) 2025-01-06 14:08:36 +01:00
Alex Christoffer Rasmussen cd913fc144 small strict improvements (#1428)
* small `strict` improvements

* fix type

* use isObject
2025-01-06 14:07:36 +01:00
Alex Christoffer Rasmussen 743d7fa782 fix: validateLinkStart (#1430)
* fix validateLinkStart

* update comment

* use fallback

* cast to MaybeString

* update comment

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-06 13:12:03 +01:00
Carlos Valente 891c43ba77 refactor: countToEnd does not cause rundown recalculation 2025-01-05 15:18:28 +01:00
Carlos Valente fba20b889e refactor: only allow testing saved URLs 2025-01-05 15:17:53 +01:00
Carlos Valente bc4d913ba7 refactor: always show client path 2025-01-05 15:17:53 +01:00
Alex Christoffer Rasmussen 533f9798e8 convert to and from database type (#1410)
* create db event type

* dont use it yet

* convert to and from database type

* type needed in one place in client

* use flatMap to filter out unknow stuff

* leave revision in save file

* move to utils

* make test

* destructur entry

* simple test

* throw in rundownToDatabaseRundown

* clerify comment

* Refactor: avoid shadowing the rundown variable in rundownCache (#1409)


---------

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

* rename to DAO

* add simple edit test

* only the generate function clears the state state

* strict guard for custom fields

* fix small strict issues

* ensure deep copy

* safely set isStale

* todo is done

* small type error

* these functions can work on DAO entries

* only reat rundown as DAO on initial load and the cast immediately and esure all fields are present

* eslint comment

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

* fix spelling

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

* fix spelling

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

* comment not needed

* setIsStale

* remove DAO

* clean up commits

* remove unneeded async/await

* revert test

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-05 14:35:58 +01:00
Carlos Valente 1b62ed5f2d bump version to 3.10.1 2025-01-04 23:12:57 +01:00
Carlos Valente bd0c951da8 declare env variables 2025-01-04 23:12:57 +01:00
Carlos Valente 2d5b86808f refactor: cleanup pin styles 2025-01-04 16:39:47 +01:00
Carlos Valente c69bce3e89 refactor: cleanup picker implementations 2025-01-03 13:02:26 +01:00
Carlos Valente d62e314d19 bump version to 3.10.0 2025-01-03 12:56:37 +01:00
Shobhit-Nagpal dbd56522f6 refactor: swatch picker and inline colour picker 2025-01-02 21:30:41 +01:00
Shobhit-Nagpal 1ba48c6a6f refactor: inline colour picker 2025-01-02 21:30:41 +01:00
Carlos Valente f4f24da47b refactor: stop port change in dev 2025-01-02 14:18:19 +01:00
Carlos Valente c3609b2aed chore: extract middleware config 2025-01-02 14:18:19 +01:00
Carlos Valente 260d326989 chore: add noindex to app entry 2024-12-31 23:20:28 +01:00
Carlos Valente a21e32a097 refactor: expose indent style 2024-12-31 15:01:34 +01:00
Carlos Valente 617e4b579b refactor: introduce empty placeholder 2024-12-31 15:01:34 +01:00
Carlos Valente 44f65b2fcc refactor: extract utility 2024-12-31 13:04:00 +01:00
Carlos Valente 760de608e3 refactor: simplify logic 2024-12-31 13:01:22 +01:00
Carlos Valente 0dfef732bf refactor: prevent race conditions on init 2024-12-29 22:53:41 +01:00
Carlos Valente f65ec0da3c chore: upgrade dependencies 2024-12-29 20:02:56 +01:00
Shobhit Nagpal 6bbf8ce795 refactor: event editor color picker (#1400)
* refactor: event editor color picker

* refactor: throttle and debounce function signature
2024-12-27 10:57:48 +01:00
Carlos Valente 7f39d035fd refactor: show delayed times 2024-12-22 21:43:37 +01:00
Carlos Valente bdd2a62cee feat: allow editing times in cuesheet 2024-12-22 21:43:37 +01:00
Carlos Valente b38a7e5c92 refactor: allow submitting same value 2024-12-22 21:43:37 +01:00
Carlos Valente b173b5a8e8 chore: update manifest 2024-12-22 20:06:35 +01:00
Alex Christoffer Rasmussen 29243e3f6c detect EADDRINUSE on server start (#1348)
* detect `EADDRINUSE` on server start

* @ts-expect-error

* send portError to electron

* use escalateErrorFn

* dont move in docker

* allow errors to be sendt to electron UI

* Log after the new port is found

* Update comment

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

* update comment

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

* update comment

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

* rename to escalate

* extract `serverTryDesiredPort`

* type check server.address()

* request shutdown on `unrecoverable` `escalateError`

* combine network utils

* reject promise

* not unrecoverable by default

* unneeded assignment

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-12-22 16:23:47 +01:00
Carlos Valente 1af394cd49 chore: upgrade turbo 2024-12-21 22:46:07 +01:00
Carlos Valente 1c85265623 fix: section can only be selected if parent is 2024-12-21 13:56:23 +01:00
Carlos Valente 6199d0ad10 refactor: improve section style and composition 2024-12-20 21:24:34 +01:00
Carlos Valente 2f2c26ed00 chore: rename time-to-end to count-to-end 2024-12-20 21:24:34 +01:00
Carlos Valente c9ef2465f8 refactor: visibility options 2024-12-20 21:15:26 +01:00
Carlos Valente 430b72c1d0 fix: missing return when data is handled 2024-12-20 21:15:26 +01:00
Carlos Valente 88b114020b feat: edit duration in cuesheet 2024-12-20 21:15:26 +01:00
Carlos Valente c445c42f8a refactor: extract edit element in table 2024-12-20 21:15:26 +01:00
Carlos Valente 2fc4e6da16 refactor: extract utility type 2024-12-20 21:15:26 +01:00
Carlos Valente 4cbe25a845 refactor: simplify update functions 2024-12-20 21:15:26 +01:00
Carlos Valente ab6765b332 refactor: tweak table element styles 2024-12-20 21:15:26 +01:00
Carlos Valente fb38d82855 refactor: show cuesheet as long as we have data 2024-12-20 21:15:26 +01:00
Carlos Valente 1061ed2a0e refactor: improve fallback timer consistency 2024-12-20 15:27:57 +01:00
Carlos Valente 3d37f42fe0 refactor: tweak styling 2024-12-20 09:03:02 +01:00
Carlos Valente 1bdea8e436 refactor: edit cue field 2024-12-20 09:03:02 +01:00
Carlos Valente 2c25910863 refactor: add table actions 2024-12-20 09:03:02 +01:00
Carlos Valente 8f30c0a7df refactor: restructure element composition 2024-12-20 09:03:02 +01:00
Carlos Valente 67fa747aae refactor: extract cuesheet dnd 2024-12-20 09:03:02 +01:00
Carlos Valente 8116b169b2 refactor: generalise event editor 2024-12-20 09:03:02 +01:00
Carlos Valente ec823e0095 refactor: improve cuesheet composition
refactor: simplify placing events in rundown

m
2024-12-20 09:03:02 +01:00
Carlos Valente c7faab00a3 feat: create offline indicator 2024-12-19 22:15:48 +01:00
Alex Christoffer Rasmussen cfcf6a5684 extract isPlaybackActive to a global util function (#1393) 2024-12-18 12:44:48 +01:00
Carlos Valente df1cf7a96b feat: parse timeToEnd from excel 2024-12-16 21:59:38 +01:00
Carlos Valente 0f3feca7ab refactor: extract time-to-end 2024-12-16 21:59:38 +01:00
Carlos Valente 41fe213ebb refactor: settings in params 2024-12-16 15:13:49 +01:00
Carlos Valente e91d5f5cd9 refactor: always show settings 2024-12-16 15:13:49 +01:00
Carlos Valente bedab221d9 chore: dedupe lock file 2024-12-16 15:13:49 +01:00
Carlos Valente 97208c052a chore: upgrade dependencies 2024-12-16 15:13:49 +01:00
Alex Christoffer Rasmussen b048d0f88d Edit in cuesheet (#1372)
* pass on all event edits

* MakePublic

* trim value in cell

* edit notes

* title

* add key check

* don't log error

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

* checkbox

* refactor

* remove log

* fix test

* use row number to test value

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-12-16 14:27:25 +01:00
asharonbaltazar d8f7d4bba6 feat(ui): add warning in view params when styles are overriden (#1384)
* feat: add warning in view params when styles are overriden

* refactor: style tweaks

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-12-15 10:40:27 -05:00
Carlos Valente ed65a4db67 refactor: smaller settings 2024-12-15 11:04:15 +01:00
Carlos Valente 4a70bcfe64 chore: remove unused 2024-12-15 11:04:15 +01:00
Carlos Valente 89e68c3b0e fix: correct aria label 2024-12-15 11:04:15 +01:00
Carlos Valente 976dc9cbde refactor: tweak header style 2024-12-15 11:04:15 +01:00
Carlos Valente 3701ba5174 refactor: handle missing data 2024-12-15 11:04:15 +01:00
Carlos Valente acc6ba63ba refactor: keep drawer open on submit 2024-12-15 11:04:15 +01:00
Carlos Valente 6c5afcb8d9 refactor: improve empty state for pages 2024-12-15 09:55:52 +01:00
Carlos Valente d46dfcf82e refactor: improve redirect 2024-12-14 08:25:53 +01:00
Carlos Valente 4c6acc4013 refactor: improve active styles for navigation buttons 2024-12-13 11:24:03 +01:00
Carlos Valente 26a24449de refactor: prevent modal close on click outside 2024-12-13 10:40:16 +01:00
Carlos Valente a1fb642441 bump version to 3.9.5 2024-12-13 10:40:16 +01:00
Carlos Valente b64b154330 refactor: disable shutdown 2024-12-13 10:39:58 +01:00
Carlos Valente 5032cbf65a refactor: prevent instantiating unavailable services 2024-12-13 10:39:58 +01:00
Carlos Valente 466360f9d1 chore: note service limitations 2024-12-13 10:39:58 +01:00
Carlos Valente ef2ea9a1da fix: recover edit menu and shortcuts 2024-12-13 10:39:45 +01:00
Carlos Valente fb83f48752 refactor: maintain an isOnline flag in client 2024-12-13 10:39:30 +01:00
Carlos Valente c1d53b0e55 refactor: improve empty state for project info 2024-12-13 10:12:06 +01:00
Alex Christoffer Rasmussen 7915cc822d Dockersafe rename (#1370)
* add dockerSafeRename function

* replace all rename functions

* add explanation to function
2024-12-10 23:27:51 +01:00
Carlos Valente 7dbf64d100 chore: restructure directory 2024-12-10 15:53:53 +01:00
Carlos Valente 6ffbf2af9d chore: move files to new structure 2024-12-10 15:53:53 +01:00
Carlos Valente fa7ec623f1 bump version to 3.9.4 2024-12-08 08:52:18 +01:00
Carlos Valente bfb9b51073 fix: secure protocol resolution 2024-12-08 08:52:18 +01:00
Carlos Valente 8bc3f5a56c Fix links (#1366)
* chore: remove unused code

* bump version to 3.9.3

* chore: use constant links

* refactor: build links

* chore: ignore ontime-data
2024-12-06 16:48:35 +01:00
Carlos Valente acf1afb5e9 fix: prevent overflow 2024-12-06 14:54:41 +01:00
Carlos Valente d8e2a8d092 bump version to 3.9.2 2024-12-04 21:41:49 +01:00
Carlos Valente 8e587128a1 refactor: ensure element height 2024-12-04 21:41:49 +01:00
Alex Christoffer Rasmussen 607bc40673 create addTime function in simple timer (#1363)
* create addTime function in simple timer
2024-12-04 20:25:49 +01:00
Carlos Valente e6aa7404f7 chore: correct documentation 2024-12-04 19:49:49 +01:00
Carlos Valente d622a7738f refactor: remove deprecated field 2024-12-04 19:49:49 +01:00
Carlos Valente b9ba416366 refactor: add prefix to relative assets
refactor: add prefix to server entrypoints
refactor: add prefix to express router
refactor: add prefix to router basename
2024-12-04 19:49:49 +01:00
227 changed files with 5578 additions and 4258 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.TEAMID }} APPLE_TEAM_ID: ${{ secrets.TEAMID }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }} CSC_LINK: ${{ secrets.CSC_LINK }}
run: pnpm dist-mac run: pnpm dist-mac --env-mode=loose
timeout-minutes: 60 timeout-minutes: 60
- name: Release - name: Release
+2
View File
@@ -28,6 +28,7 @@ test-results
playwright-report playwright-report
/playwright/.cache/ /playwright/.cache/
automated-screenshots automated-screenshots
e2e/tests/fixtures/tmp/*
# production # production
build/ build/
@@ -39,6 +40,7 @@ dist/
# docker utils # docker utils
ontime-db ontime-db
ontime-external/ ontime-external/
ontime-data/
# versioning file # versioning file
**/ONTIME_VERSION.js **/ONTIME_VERSION.js
+1 -1
View File
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands From the project root, run the following commands
- __Build docker image from__ by running `docker build -t getontime/ontime` - __Build docker image from__ by running `docker build -t getontime/ontime .`
- __Run docker image from compose__ by running `docker-compose up -d` - __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands Other useful commands
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.9.1", "version": "3.10.3",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+26 -29
View File
@@ -1,31 +1,28 @@
<!doctype html> <!doctype html>
<html lang='en'> <html lang="en">
<head> <head>
<meta charset='utf-8' /> <meta charset="utf-8" />
<base href="/"> <base href="/" />
<link rel='icon' href='/favicon.ico' /> <link rel="icon" href="/favicon.ico" />
<meta name='viewport' content='width=device-width, initial-scale=1' /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name='theme-color' content='#101010' /> <meta name="theme-color" content="#101010" />
<meta name='ontime' content='ontime - time keeping for live events' /> <meta name="ontime" content="ontime - time keeping for live events" />
<link rel='apple-touch-icon' href='/ontime-logo.png' /> <link rel="apple-touch-icon" href="/ontime-logo.png" />
<link <link rel="icon" type="image/png" href="/ontime-logo.png" />
rel='icon' <link rel="manifest" href="/site.webmanifest" />
type='image/png' <link rel="manifest" href="/manifest.json" />
href='ontime-logo.png' <meta name="robots" content="noindex" />
/> <title>ontime</title>
<link rel='manifest' href='/site.webmanifest' /> <style>
<link rel='manifest' href='/manifest.json' /> body,
<title>ontime</title> html {
<style> background-color: rgba(0, 0, 0, 0) !important;
body, }
html { </style>
background-color: rgba(0, 0, 0, 0) !important; </head>
} <body>
</style> <noscript>You need to enable JavaScript to run this app.</noscript>
</head> <div id="root"></div>
<body> <script type="module" src="/src/index.tsx"></script>
<noscript>You need to enable JavaScript to run this app.</noscript> </body>
<div id='root'></div>
<script type='module' src='/src/index.tsx'></script>
</body>
</html> </html>
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.9.1", "version": "3.10.3",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -13,10 +13,10 @@
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.13.3", "@mantine/hooks": "^7.13.3",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.19.0", "@sentry/react": "^8.43.0",
"@tanstack/react-query": "^5.17.9", "@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.17.9", "@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.11.3", "@tanstack/react-table": "^8.20.5",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.2.0", "axios": "^1.2.0",
"color": "^4.2.3", "color": "^4.2.3",
@@ -39,7 +39,7 @@
"build": "vite build", "build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build", "build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build", "build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "cross-env VITE_IS_CLOUD=true vite build", "build:docker": "cross-env VITE_IS_DOCKER=true vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build", "build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"test": "vitest", "test": "vitest",
+10 -1
View File
@@ -1 +1,10 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} {
"name": "",
"short_name": "",
"icons": [
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
],
"theme_color": "#2B5ABC",
"background_color": "#101010",
"display": "standalone"
}
+2 -1
View File
@@ -11,6 +11,7 @@ import { connectSocket } from './common/utils/socket';
import theme from './theme/theme'; import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider'; import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter'; import AppRouter from './AppRouter';
import { baseURI } from './externals';
connectSocket(); connectSocket();
@@ -19,7 +20,7 @@ function App() {
<ChakraProvider disableGlobalStyle resetCSS theme={theme}> <ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}> <QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider> <AppContextProvider>
<BrowserRouter> <BrowserRouter basename={baseURI}>
<div className='App'> <div className='App'>
<ErrorBoundary> <ErrorBoundary>
<TranslationProvider> <TranslationProvider>
+1 -1
View File
@@ -19,7 +19,7 @@ import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config'; import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor')); const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet')); const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport')); const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer')); const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
+2 -2
View File
@@ -1,7 +1,7 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils'; import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -12,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB * HTTP request to the current DB
*/ */
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> { export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { filename }); return axios.post(`${dbPath}/download`, { filename });
} }
/** /**
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types'; import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -16,7 +16,7 @@ export async function fetchNormalisedRundown(): Promise<RundownCached> {
/** /**
* HTTP request to post new event * HTTP request to post new event
*/ */
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> { export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data); return axios.post(rundownPath, data);
} }
@@ -9,32 +9,23 @@ import style from './DelayIndicator.module.scss';
interface DelayIndicatorProps { interface DelayIndicatorProps {
delayValue?: number; delayValue?: number;
tooltipPrefix?: string;
} }
export default function DelayIndicator(props: DelayIndicatorProps) { export default function DelayIndicator(props: DelayIndicatorProps) {
const { delayValue } = props; const { delayValue, tooltipPrefix } = props;
if (typeof delayValue === 'number') { if (typeof delayValue !== 'number' || delayValue === 0) {
if (delayValue < 0) { return null;
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
} }
return null; const delayString = tooltipPrefix
? `${tooltipPrefix} ${millisToDelayString(delayValue)}`
: millisToDelayString(delayValue);
return (
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
</Tooltip>
);
} }
@@ -16,11 +16,11 @@ export default function Swatch(props: SwatchProps) {
const handleClick = () => { const handleClick = () => {
onClick?.(color); onClick?.(color);
}; };
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]); const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
if (!color) { if (!color) {
return ( return (
<div className={`${classes} ${style.center}`} onClick={handleClick}> <div className={classes} onClick={handleClick}>
<IoBan /> <IoBan />
</div> </div>
); );
@@ -0,0 +1,60 @@
import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop';
import { ViewSettings } from 'ontime-types';
import PopoverPicker from '../../../../common/components/input/popover-picker/PopoverPicker';
import { debounce } from '../../../../common/utils/debounce';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import style from './SwatchSelect.module.scss';
interface SwatchPickerProps {
color: string;
isSelected?: boolean;
onChange: (name: string) => void;
alwaysDisplayColor?: boolean;
}
export default function SwatchPicker(props: SwatchPickerProps) {
const { color, onChange, isSelected, alwaysDisplayColor } = props;
const debouncedOnChange = useCallback(
debounce((newValue: string) => {
onChange(newValue);
}, 500),
[onChange],
);
const displayColor = alwaysDisplayColor || isSelected ? color : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={displayColor} onChange={debouncedOnChange}>
<div
className={cx([style.swatch, isSelected && style.selected, style.selectable])}
style={{ backgroundColor: displayColor }}
>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
export function SwatchPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
const displayColor = typeof value === 'string' ? value : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={value as string} onChange={onChange}>
<div className={cx([style.swatch, style.selectable])} style={{ backgroundColor: displayColor }}>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
@@ -1,15 +1,18 @@
.list { .list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem gap: 0.5rem;
} }
.swatch { .swatch {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
aspect-ratio: 1;
border-radius: 99px; border-radius: 99px;
border: 2px solid $gray-1200; border: 2px solid $gray-1200;
color: $ui-white;
display: grid;
place-content: center;
&.selected { &.selected {
border: 2px solid $blue-500; border: 2px solid $blue-500;
@@ -17,11 +20,9 @@
&.selectable { &.selectable {
cursor: pointer; cursor: pointer;
&:hover {
border: 2px solid $blue-300;
}
} }
} }
.center {
display: grid;
place-content: center;
color: $ui-white;
}
@@ -1,6 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import Swatch from './Swatch'; import Swatch from './Swatch';
import SwatchPicker from './SwatchPicker';
import style from './SwatchSelect.module.scss'; import style from './SwatchSelect.module.scss';
@@ -43,6 +44,7 @@ export default function SwatchSelect(props: ColourInputProps) {
{colours.map((colour) => ( {colours.map((colour) => (
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} /> <Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
))} ))}
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
</div> </div>
); );
} }
@@ -1,16 +1,20 @@
.swatch { .input {
width: 24px; color: $gray-200;
height: 24px; border: 1px solid transparent;
border-radius: 12px; border-radius: 0 0 8px 8px;
border: 1px solid white; background-color: $gray-1200;
box-shadow: 0 0 0 1px $gray-300; padding: 0 0.5rem;
cursor: pointer; font-size: 1rem;
height: 2rem;
outline: none;
transition-property: box-shadow; &:hover {
transition-duration: $transition-time-action; background-color: $gray-1100;
}
&:focus {
background-color: $gray-1000;
color: $gray-50;
border: 1px solid $blue-500;
}
} }
.swatch:hover {
box-shadow: 0 0 0 1px $blue-300;
}
@@ -1,34 +1,22 @@
import { HexAlphaColorPicker } from 'react-colorful'; import { PropsWithChildren } from 'react';
import { useController, UseControllerProps } from 'react-hook-form'; import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react'; import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import style from './PopoverPicker.module.scss'; import style from './PopoverPicker.module.scss';
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
return <PopoverPicker color={value as string} onChange={onChange} />;
}
interface PopoverPickerProps { interface PopoverPickerProps {
color: string; color: string;
onChange: (color: string) => void; onChange: (color: string) => void;
} }
export default function PopoverPicker(props: PopoverPickerProps) { export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
const { color, onChange } = props; const { color, onChange, children } = props;
return ( return (
<Popover> <Popover>
<PopoverTrigger> <PopoverTrigger>{children}</PopoverTrigger>
<div className={style.swatch} style={{ backgroundColor: color }} /> <PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
</PopoverTrigger>
<PopoverContent style={{ width: 'auto' }}>
<HexAlphaColorPicker color={color} onChange={onChange} /> <HexAlphaColorPicker color={color} onChange={onChange} />
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
@@ -15,6 +15,8 @@ export default function useReactiveTextInput(
options?: { options?: {
submitOnEnter?: boolean; submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean; submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean;
}, },
): UseReactiveTextInputReturn { ): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText); const [text, setText] = useState<string>(initialText);
@@ -47,7 +49,9 @@ export default function useReactiveTextInput(
const handleSubmit = useCallback( const handleSubmit = useCallback(
(valueToSubmit: string) => { (valueToSubmit: string) => {
// No need to update if it hasn't changed // No need to update if it hasn't changed
if (valueToSubmit !== initialText) { if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
options?.onCancelUpdate?.();
} else {
const cleanVal = valueToSubmit.trim(); const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal); submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) { if (cleanVal !== valueToSubmit) {
@@ -56,7 +60,7 @@ export default function useReactiveTextInput(
} }
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, },
[initialText, ref, submitCallback], [initialText, options, ref, submitCallback],
); );
/** /**
@@ -66,8 +70,9 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
// No need to update if it hasn't changed // No need to update if it hasn't changed
setText(initialText); setText(initialText);
options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, [initialText, ref]); }, [initialText, options, ref]);
const keyHandler = useMemo(() => { const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]]; const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
@@ -55,6 +55,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
// we dont know the values in the rundown, escalate to handler // we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) { if (newValue.startsWith('p') || newValue.startsWith('+')) {
submitHandler(name, newValue); submitHandler(name, newValue);
return true;
} }
const valueInMillis = parseUserTime(newValue); const valueInMillis = parseUserTime(newValue);
@@ -2,6 +2,8 @@ $input-delayed-border-color: $ontime-delay-text;
.timeInput { .timeInput {
border: 1px solid transparent; border: 1px solid transparent;
color: $label-gray;
&.delayed { &.delayed {
border: 1px solid $input-delayed-border-color; border: 1px solid $input-delayed-border-color;
} }
@@ -17,7 +17,7 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline'; import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost, serverPort } from '../../../externals'; import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside'; import useClickOutside from '../../hooks/useClickOutside';
import { useElectronEvent } from '../../hooks/useElectronEvent'; import { useElectronEvent } from '../../hooks/useElectronEvent';
@@ -25,7 +25,7 @@ import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent'; import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, openLink } from '../../utils/linkUtils'; import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal'; import { RenameClientModal } from '../client-modal/RenameClientModal';
import CopyTag from '../copy-tag/CopyTag'; import CopyTag from '../copy-tag/CopyTag';
@@ -45,7 +45,7 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure(); const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const location = useLocation(); const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
@@ -65,7 +65,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<DrawerBody padding={0}> <DrawerBody padding={0}>
<div className={style.buttonsContainer}> <div className={style.buttonsContainer}>
<div <div
className={style.link} className={cx([style.link, fullscreen && style.current])}
tabIndex={0} tabIndex={0}
role='button' role='button'
onClick={toggle} onClick={toggle}
@@ -77,7 +77,7 @@ function NavigationMenu(props: NavigationMenuProps) {
{fullscreen ? <IoContract /> : <IoExpand />} {fullscreen ? <IoContract /> : <IoExpand />}
</div> </div>
<div <div
className={style.link} className={cx([style.link, mirror && style.current])}
tabIndex={0} tabIndex={0}
role='button' role='button'
onClick={() => toggleMirror()} onClick={() => toggleMirror()}
@@ -154,7 +154,8 @@ function OtherAddresses(props: OtherAddressesProps) {
return null; return null;
} }
const address = `http://${nif.address}:${serverPort}${currentLocation}`; const address = linkToOtherHost(nif.address, currentLocation);
return ( return (
<CopyTag <CopyTag
key={nif.name} key={nif.name}
@@ -1,9 +1,13 @@
.container { .container {
width: 100%;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 25vh; padding-top: 25vh;
background: $ui-black;
color: $ontime-color; color: $ontime-color;
font-weight: 200; font-weight: 200;
font-size: 3rem; font-size: 3rem;
@@ -42,4 +46,4 @@
to { to {
background: rgba($red-500, 0); background: rgba($red-500, 0);
} }
} }
@@ -0,0 +1,20 @@
@use '../../../theme/viewerDefs' as *;
/* share the same style as a page layout */
.page {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
}
@@ -0,0 +1,20 @@
import { CSSProperties } from 'react';
import Empty from './Empty';
import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
style?: CSSProperties;
}
export default function EmptyPage(props: EmptyPageProps) {
const { text, ...rest } = props;
return (
<div className={style.page}>
<Empty text={text} {...rest} />
</div>
);
}
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import PopoverPicker from '../input/popover-picker/PopoverPicker'; import SwatchPicker from '../input/colour-input/SwatchPicker';
import style from './InlineColourPicker.module.scss'; import style from './InlineColourPicker.module.scss';
@@ -20,13 +20,10 @@ export default function InlineColourPicker(props: InlineColourPickerProps) {
const { name, value } = props; const { name, value } = props;
const [colour, setColour] = useState(() => ensureHex(value)); const [colour, setColour] = useState(() => ensureHex(value));
const debouncedChange = (value: string) => {
setColour(value);
};
return ( return (
<div className={style.inline}> <div className={style.inline}>
<PopoverPicker color={colour} onChange={debouncedChange} /> <SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
<span>{colour}</span>
<input type='hidden' name={name} value={colour} /> <input type='hidden' name={name} value={colour} />
</div> </div>
); );
@@ -1,6 +1,6 @@
.drawerFooter { .drawerFooter {
display: flex; display: flex;
justify-content: end; justify-content: end;
gap: $section-spacing; gap: $section-spacing;
button { button {
@@ -8,6 +8,23 @@
} }
} }
.infoLabel {
display: flex;
align-items: center;
gap: $element-spacing;
padding: 1rem;
margin-bottom: 1rem;
background-color: $gray-1100;
border-radius: 2px;
font-size: $inner-section-text-size;
svg {
font-size: 1.5rem;
color: $info-blue;
}
}
.label { .label {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray; color: $label-gray;
@@ -11,6 +11,9 @@ import {
DrawerOverlay, DrawerOverlay,
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { isSection, ViewOption } from './types'; import { isSection, ViewOption } from './types';
@@ -87,6 +90,8 @@ interface EditFormDrawerProps {
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable // TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) { export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen, onClose, onOpen } = useDisclosure();
useEffect(() => { useEffect(() => {
@@ -115,8 +120,6 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
onClose();
}; };
return ( return (
@@ -129,6 +132,12 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
</DrawerHeader> </DrawerHeader>
<DrawerBody> <DrawerBody>
{viewSettings.overrideStyles && (
<div className={style.infoLabel}>
<IoAlertCircle />
This view style is being modified by a custom CSS file. <br />
</div>
)}
<form id='edit-params-form' onSubmit={onParamsFormSubmit}> <form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{viewOptions.map((option) => { {viewOptions.map((option) => {
if (isSection(option)) { if (isSection(option)) {
+11 -4
View File
@@ -4,16 +4,23 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { useClientStore } from '../stores/clientStore'; import { useClientStore } from '../stores/clientStore';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
import { useIsOnline } from './useSocket';
export const useClientPath = () => { export const useClientPath = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname, search } = useLocation(); const { pathname, search } = useLocation();
const redirect = useClientStore((store) => store.redirect); const { redirect, setRedirect } = useClientStore((store) => ({
const setRedirect = useClientStore((store) => store.setRedirect); redirect: store.redirect,
setRedirect: store.setRedirect,
}));
const isOnline = useIsOnline();
// notify of client path changes // notify of client path changes
useEffect(() => { useEffect(() => {
if (!isOnline) return;
socketSendJson('set-client-path', pathname + search); socketSendJson('set-client-path', pathname + search);
}, [pathname, search]); }, [pathname, search, isOnline]);
// navigate to new path when received from server // navigate to new path when received from server
useEffect(() => { useEffect(() => {
@@ -26,7 +33,7 @@ export const useClientPath = () => {
// navigate if there is a path change // navigate if there is a path change
if (redirect !== pathname + search) { if (redirect !== pathname + search) {
navigate(redirect); navigate(redirect, { replace: true });
} }
}, [navigate, pathname, redirect, search, setRedirect]); }, [navigate, pathname, redirect, search, setRedirect]);
}; };
+95 -48
View File
@@ -1,6 +1,16 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types'; import {
isOntimeEvent,
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundownEntry,
RundownCached,
TimeField,
TimeStrategy,
TransientEventPayload,
} from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils'; import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
@@ -19,6 +29,16 @@ import {
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
// options to any new block (event / delay / block)
after: string;
before: string;
// options to blocks of type OntimeEvent
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
}>;
/** /**
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on * @description Set of utilities for events //TODO: should this be called useEntryAction and so on
*/ */
@@ -47,31 +67,19 @@ export const useEventAction = () => {
networkMode: 'always', networkMode: 'always',
}); });
// options to any new block (event / delay / block)
type BaseOptions = {
after?: string;
};
// options to blocks of type OntimeEvent
type EventOptions = BaseOptions &
Partial<{
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
}>;
/** /**
* Adds an event to rundown * Adds an event to rundown
*/ */
const addEvent = useCallback( const addEvent = useCallback(
async (event: Partial<OntimeRundownEntry>, options?: EventOptions) => { async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => {
const newEvent: Partial<OntimeRundownEntry> = { ...event }; const newEvent: TransientEventPayload = { ...event };
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) { if (isOntimeEvent(newEvent)) {
// merge creation time options with event settings // merge creation time options with event settings
const applicationOptions = { const applicationOptions = {
after: options?.after, after: options?.after,
before: options?.before,
defaultPublic: options?.defaultPublic ?? defaultPublic, defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId, lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious, linkPrevious: options?.linkPrevious ?? linkPrevious,
@@ -121,11 +129,16 @@ export const useEventAction = () => {
// handle adding options that concern all event type // handle adding options that concern all event type
if (options?.after) { if (options?.after) {
// @ts-expect-error -- not sure how to type this, <after> is a transient property
newEvent.after = options.after; newEvent.after = options.after;
} }
if (options?.before) {
// @ts-expect-error -- not sure how to type this, <before> is a transient property
newEvent.before = options.before;
}
try { try {
await _addEventMutation.mutateAsync(newEvent); await _addEventMutation.mutateAsync(newEvent as TransientEventPayload);
} catch (error) { } catch (error) {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
@@ -203,13 +216,75 @@ export const useEventAction = () => {
[updateEvent], [updateEvent],
); );
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
/** /**
* Updates time of existing event * Updates time of existing event
* @param eventId {string} - id of the event
* @param field {TimeField} - field to update
* @param value {string} - new value string to be parsed
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
*/ */
const updateTimer = useCallback( const updateTimer = useCallback(
async (eventId: string, field: TimeField, value: string) => { async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => {
const getPreviousEnd = (): number => { // an empty value with no lock has no domain validity
if (!lockOnUpdate && value === '') {
return;
}
const newEvent: Partial<OntimeEvent> = {
id: eventId,
};
// check if we should lock the field
if (lockOnUpdate) {
if (field === 'timeEnd') {
// an empty value indicates that we should unlock the field
newEvent.timeStrategy = value === '' ? TimeStrategy.LockDuration : TimeStrategy.LockEnd;
newEvent.timeEnd = value === '' ? undefined : calculateNewValue();
} else if (field === 'duration') {
// an empty value indicates that we should unlock the field
newEvent.timeStrategy = value === '' ? TimeStrategy.LockEnd : TimeStrategy.LockDuration;
newEvent.duration = value === '' ? undefined : calculateNewValue();
} else if (field === 'timeStart') {
// an empty values means we should link to the previous
newEvent.linkStart = value === '' ? 'true' : null;
newEvent.timeStart = value === '' ? undefined : calculateNewValue();
}
} else {
newEvent[field] = calculateNewValue();
}
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
/**
* Utility function to calculate the new time value
*/
function calculateNewValue(): number {
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() + parseUserTime(remainingString);
} else {
newValMillis = parseUserTime(value);
}
// dont allow timer values over 23:59:59
return Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
}
/**
* Utility function to get the previous event end time
*/
function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN); const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) { if (!cachedRundown?.order || !cachedRundown?.rundown) {
@@ -229,34 +304,6 @@ export const useEventAction = () => {
} }
} }
return previousEnd; 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() + parseUserTime(remainingString);
} else {
newValMillis = parseUserTime(value);
}
// dont allow timer values over 23:59:59
const cappedMillis = Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
const newEvent = {
id: eventId,
[field]: cappedMillis,
};
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
} }
}, },
[_updateEventMutation, queryClient], [_updateEventMutation, queryClient],
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { debounce } from '../../common/utils/debounce'; import { throttle } from '../../common/utils/throttle';
export const useFadeOutOnInactivity = () => { export const useFadeOutOnInactivity = () => {
const [isMouseMoved, setIsMouseMoved] = useState(false); const [isMouseMoved, setIsMouseMoved] = useState(false);
@@ -16,11 +16,11 @@ export const useFadeOutOnInactivity = () => {
fadeOut = setTimeout(() => setIsMouseMoved(false), 3000); fadeOut = setTimeout(() => setIsMouseMoved(false), 3000);
}; };
const debouncedShowMenu = debounce(setShowMenuTrue, 1000); const throttledShowMenu = throttle(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu); document.addEventListener('mousemove', throttledShowMenu);
return () => { return () => {
document.removeEventListener('mousemove', debouncedShowMenu); document.removeEventListener('mousemove', throttledShowMenu);
if (fadeOut) { if (fadeOut) {
clearTimeout(fadeOut); clearTimeout(fadeOut);
} }
+18 -6
View File
@@ -65,6 +65,7 @@ export const useMessagePreview = () => {
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external), showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null, timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
@@ -148,19 +149,21 @@ export const setAuxTimer = {
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }), setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
}; };
export const useCuesheet = () => { export const useSelectedEventId = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
currentBlockId: state.currentBlock.block?.id ?? null,
selectedEventId: state.eventNow?.id ?? null, selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
titleNow: state.eventNow?.title || '',
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
export const useCurrentBlockId = () => {
const featureSelector = (state: RuntimeStore) => ({
currentBlockId: state.currentBlock.block?.id ?? null,
});
return useRuntimeStore(featureSelector);
};
export const setEventPlayback = { export const setEventPlayback = {
loadEvent: (id: string) => socketSendJson('load', { id }), loadEvent: (id: string) => socketSendJson('load', { id }),
startEvent: (id: string) => socketSendJson('start', { id }), startEvent: (id: string) => socketSendJson('start', { id }),
@@ -238,3 +241,12 @@ export const usePing = () => {
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
}; };
/** convert ping into a derived value which changes less often */
export const useIsOnline = () => {
const featureSelector = (state: RuntimeStore) => ({
isOnline: state.ping > 0,
});
return useRuntimeStore(featureSelector);
};
@@ -15,4 +15,5 @@ export type ViewExtendedTimer = {
clock: number; clock: number;
timerType: TimerType; timerType: TimerType;
countToEnd: boolean;
}; };
+9 -3
View File
@@ -16,11 +16,17 @@ export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
/** /**
* Allows patching a property of the runtime store * Allows patching a property of the runtime store
* @param key
* @param value
*/ */
export function patchRuntime<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]): void { export function patchRuntimeProperty<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
const state = runtimeStore.getState(); const state = runtimeStore.getState();
state[key] = value; state[key] = value;
runtimeStore.setState({ ...state }); runtimeStore.setState({ ...state });
} }
/**
* Allows patching the entire runtime store
*/
export function patchRuntime(patch: Partial<RuntimeStore>) {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...patch });
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-uti
/** /**
* Parses a value in millis to a string which encodes a delay * Parses a value in millis to a string which encodes a delay
* @param millis * @param millis
* @param format * @param format
*/ */
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string { export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
if (millis == null || millis === 0) { if (millis == null || millis === 0) {
+4 -4
View File
@@ -1,12 +1,12 @@
export function debounce(callback: () => void, wait: number) { export function debounce<T extends any[]>(callback: (...args: T) => void, wait: number) {
let timeout: NodeJS.Timeout | null; let timeout: NodeJS.Timeout | null;
return () => { return (...args: T) => {
if (timeout) { if (timeout) {
return; clearTimeout(timeout);
} }
timeout = setTimeout(() => { timeout = setTimeout(() => {
timeout = null; timeout = null;
callback(); callback(...args);
}, wait); }, wait);
}; };
} }
@@ -7,7 +7,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
* @return {OntimeEvent} clean event * @return {OntimeEvent} clean event
*/ */
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>; type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => { export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return { return {
type: SupportedEvent.Event, type: SupportedEvent.Event,
title: event.title, title: event.title,
@@ -17,13 +17,16 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
timeEnd: event.timeEnd, timeEnd: event.timeEnd,
timerType: event.timerType, timerType: event.timerType,
timeStrategy: event.timeStrategy, timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart, linkStart: event.linkStart,
endAction: event.endAction, endAction: event.endAction,
isPublic: event.isPublic, isPublic: event.isPublic,
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
after,
revision: 0, revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: event.timeWarning, timeWarning: event.timeWarning,
timeDanger: event.timeDanger, timeDanger: event.timeDanger,
custom: {}, custom: {},
+8
View File
@@ -7,3 +7,11 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean { export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape'; return event.key === 'Escape';
} }
export function preventEscape<T>(event: KeyboardEvent<T>, callback?: () => void): void {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
callback?.();
}
}
-30
View File
@@ -1,30 +0,0 @@
/**
* Returns hostname
* @type {string}
*/
export const host = window?.location?.host;
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event, location) {
// we handle the link manually
event.preventDefault();
openLink(`http://${host}/${location}`);
}
+40
View File
@@ -0,0 +1,40 @@
import type { MouseEvent } from 'react';
import { baseURI, serverURL } from '../../externals';
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url: string) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event: MouseEvent, location: string) {
// we handle the link manually
event.preventDefault();
const destination = new URL(serverURL);
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
openLink(destination.toString());
}
export function linkToOtherHost(host: string, path?: string) {
const destination = new URL(serverURL);
destination.hostname = host;
if (path) {
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
}
return destination.toString();
}
+36 -22
View File
@@ -14,7 +14,7 @@ import {
} from '../stores/clientStore'; } from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore'; import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { patchRuntime, runtimeStore } from '../stores/runtime'; import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
export let websocket: WebSocket | null = null; export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -39,12 +39,13 @@ export const connectSocket = () => {
} }
socketSendJson('set-client-type', 'ontime'); socketSendJson('set-client-type', 'ontime');
setOnlineStatus(true);
socketSendJson('set-client-path', location.pathname + location.search);
}; };
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
setOnlineStatus(false);
if (shouldReconnect) { if (shouldReconnect) {
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
@@ -73,8 +74,8 @@ export const connectSocket = () => {
switch (type) { switch (type) {
case 'pong': { case 'pong': {
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5; const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
patchRuntime('ping', offset); patchRuntimeProperty('ping', offset);
updateDevTools({ ping: offset }, ['PING']); updateDevTools({ ping: offset });
break; break;
} }
case 'client-id': { case 'client-id': {
@@ -131,64 +132,65 @@ export const connectSocket = () => {
break; break;
} }
case 'ontime': { case 'ontime': {
runtimeStore.setState(payload as RuntimeStore); // eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload
if (!isProduction) { const { ping, ...serverPayload } = payload as Partial<RuntimeStore>;
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
} patchRuntime(serverPayload);
updateDevTools(serverPayload);
break; break;
} }
case 'ontime-clock': { case 'ontime-clock': {
patchRuntime('clock', payload); patchRuntimeProperty('clock', payload);
updateDevTools({ clock: payload }); updateDevTools({ clock: payload });
break; break;
} }
case 'ontime-timer': { case 'ontime-timer': {
patchRuntime('timer', payload); patchRuntimeProperty('timer', payload);
updateDevTools({ timer: payload }); updateDevTools({ timer: payload });
break; break;
} }
case 'ontime-onAir': { case 'ontime-onAir': {
patchRuntime('onAir', payload); patchRuntimeProperty('onAir', payload);
updateDevTools({ onAir: payload }); updateDevTools({ onAir: payload });
break; break;
} }
case 'ontime-message': { case 'ontime-message': {
patchRuntime('message', payload); patchRuntimeProperty('message', payload);
updateDevTools({ message: payload }); updateDevTools({ message: payload });
break; break;
} }
case 'ontime-runtime': { case 'ontime-runtime': {
patchRuntime('runtime', payload); patchRuntimeProperty('runtime', payload);
updateDevTools({ runtime: payload }); updateDevTools({ runtime: payload });
break; break;
} }
case 'ontime-eventNow': { case 'ontime-eventNow': {
patchRuntime('eventNow', payload); patchRuntimeProperty('eventNow', payload);
updateDevTools({ eventNow: payload }); updateDevTools({ eventNow: payload });
break; break;
} }
case 'ontime-currentBlock': { case 'ontime-currentBlock': {
patchRuntime('currentBlock', payload); patchRuntimeProperty('currentBlock', payload);
updateDevTools({ currentBlock: payload }); updateDevTools({ currentBlock: payload });
break; break;
} }
case 'ontime-publicEventNow': { case 'ontime-publicEventNow': {
patchRuntime('publicEventNow', payload); patchRuntimeProperty('publicEventNow', payload);
updateDevTools({ publicEventNow: payload }); updateDevTools({ publicEventNow: payload });
break; break;
} }
case 'ontime-eventNext': { case 'ontime-eventNext': {
patchRuntime('eventNext', payload); patchRuntimeProperty('eventNext', payload);
updateDevTools({ eventNext: payload }); updateDevTools({ eventNext: payload });
break; break;
} }
case 'ontime-publicEventNext': { case 'ontime-publicEventNext': {
patchRuntime('publicEventNext', payload); patchRuntimeProperty('publicEventNext', payload);
updateDevTools({ publicEventNext: payload }); updateDevTools({ publicEventNext: payload });
break; break;
} }
case 'ontime-auxtimer1': { case 'ontime-auxtimer1': {
patchRuntime('auxtimer1', payload); patchRuntimeProperty('auxtimer1', payload);
updateDevTools({ auxtimer1: payload }); updateDevTools({ auxtimer1: payload });
break; break;
} }
@@ -232,11 +234,23 @@ export const socketSendJson = (type: string, payload?: unknown) => {
); );
}; };
function updateDevTools(newData: Partial<RuntimeStore>, store = RUNTIME) { function updateDevTools(newData: Partial<RuntimeStore>) {
if (!isProduction) { if (!isProduction) {
ontimeQueryClient.setQueryData(store, (oldData: RuntimeStore) => ({ ontimeQueryClient.setQueryData(RUNTIME, (oldData: RuntimeStore) => ({
...oldData, ...oldData,
...newData, ...newData,
})); }));
} }
} }
/**
* Allows setting the status of the client
* We leverage the ping as an indication of the client's online status
* @example ping < 0 - client is offline
* @example ping > 0 -> client is online
*/
function setOnlineStatus(status: boolean) {
const derivedPing = status ? 1 : -1;
patchRuntimeProperty('ping', derivedPing);
updateDevTools({ ping: derivedPing });
}
+12
View File
@@ -0,0 +1,12 @@
export function throttle<T extends any[]>(callback: (...args: T) => void, wait: number) {
let timeout: NodeJS.Timeout | null;
return (...args: T) => {
if (timeout) {
return;
}
timeout = setTimeout(() => {
timeout = null;
callback(...args);
}, wait);
};
}
+21
View File
@@ -6,6 +6,9 @@ declare module '*.scss' {
type ListenerType = (event: 'string', args: unknown[]) => void; type ListenerType = (event: 'string', args: unknown[]) => void;
declare global { declare global {
/**
* Register the electron properties
*/
interface Window { interface Window {
ipcRenderer: { ipcRenderer: {
send: (channel: string, args?: string | object) => void; send: (channel: string, args?: string | object) => void;
@@ -17,6 +20,24 @@ declare global {
} }
} }
/**
* We pass a custom property to the table meta to allow field update
*/
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
};
}
}
/**
* Allow passing CSS Properties
*/
declare module 'react' { declare module 'react' {
interface CSSProperties { interface CSSProperties {
[key: `--${string}`]: string | number; [key: `--${string}`]: string | number;
+56 -9
View File
@@ -1,8 +1,9 @@
import { version } from '../../../package.json';
/** /**
* This file contains a list of constants that may need to be resolved at runtime * This file contains a list of constants that may need to be resolved at runtime
*/ */
import { version } from '../../../package.json';
export const githubUrl = 'https://www.github.com/cpvalente/ontime'; export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest'; export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no'; export const websiteUrl = 'https://www.getontime.no';
@@ -16,13 +17,59 @@ export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production'; export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction; export const isDev = !isProduction;
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
export const isOntimeCloud = Boolean(import.meta.env.VITE_IS_CLOUD); export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
// resolve protocol // resolve entrypoint URLs
const socketProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
// resolve port /**
const STATIC_PORT = 4001; * Resolve base
export const serverPort = isProduction ? window.location.port : STATIC_PORT; * @example '' for electron
export const serverURL = `${window.location.protocol}//${location.hostname}:${serverPort}`; * @example '/client-hash' for cloud
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`; */
export const baseURI = resolveBaseURI();
export const serverURL = resolveUrl('http', '');
export const websocketUrl = resolveUrl('ws', 'ws');
function resolveUrl(protocol: 'http' | 'ws', path: string) {
const url = new URL(window.location.origin);
// generate ws url
if (protocol === 'ws') {
// ensure we remain in a secure context
const isSecure = window.location.protocol === 'https:';
url.protocol = isSecure ? 'wss' : 'ws';
}
// make path name relative to the base URI
url.pathname = baseURI ? `${baseURI}/${path}` : path;
// in development mode, we use the React port for UI, but need the requests to target the server
if (isDev) {
// this is used as a fallback port for development
url.port = '4001';
}
const result = url.toString();
// prevent trailing slash
return result.endsWith('/') ? result.slice(0, -1) : result;
}
/**
* Resolves a base URI for a client that is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
* This is necessary for ontime cloud and should otherwise not affect the client
*/
function resolveBaseURI(): string {
// in ontime cloud, the base tag is set by the server
const baseHref = document.querySelector('base')?.getAttribute('href');
const base = baseHref ?? '';
// prevent a trailing slash from either an empty base or a base with a trailing slash
if (base.endsWith('/')) {
return base.slice(0, -1);
}
return base;
}
@@ -70,7 +70,7 @@ function PanelListItem(props: PanelListItemProps) {
</li> </li>
{panel.secondary?.map((secondary) => { {panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1]; const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]); const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]);
return ( return (
<li <li
key={secondary.id} key={secondary.id}
@@ -26,15 +26,24 @@ $inner-padding: 1rem;
color: $gray-300; color: $gray-300;
} }
.section { .section, .indent {
position: relative;
margin-top: 2rem;
font-size: calc(1rem - 1px); font-size: calc(1rem - 1px);
max-width: 800px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
color: $ui-white; color: $ui-white;
}
.section {
position: relative;
margin-top: 2rem;
max-width: 800px;
}
.indent {
padding: 1rem;
background-color: $gray-1350;
} }
.paragraph { .paragraph {
@@ -84,7 +93,6 @@ $inner-padding: 1rem;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 3; z-index: 3;
box-shadow: 0 1px $white-10;
background-color: $gray-1350; background-color: $gray-1350;
} }
@@ -163,6 +171,40 @@ $inner-padding: 1rem;
animation: animloader 1s ease-in infinite; animation: animloader 1s ease-in infinite;
} }
.empty {
background-color: $black-10;
text-align: center;
color: $muted-gray;
td {
padding: 2rem;
}
button {
margin-top: 1rem;
}
}
.inlineSiblings {
display: flex;
align-items: center;
gap: 1rem;
}
.empty {
background-color: $black-10;
text-align: center;
color: $muted-gray;
td {
padding: 2rem;
}
button {
margin-top: 1rem;
}
}
@keyframes animloader { @keyframes animloader {
0% { 0% {
transform: scale(0); transform: scale(0);
@@ -1,4 +1,6 @@
import { HTMLAttributes, ReactNode } from 'react'; import { HTMLAttributes, ReactNode } from 'react';
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
@@ -22,10 +24,19 @@ type SectionProps<C extends AllowedTags> = {
children: ReactNode; children: ReactNode;
} & JSX.IntrinsicElements[C]; } & JSX.IntrinsicElements[C];
export function Section<C extends AllowedTags = 'div'>({ as, children, ...props }: SectionProps<C>) { export function Section<C extends AllowedTags = 'div'>({ as, className, children, ...props }: SectionProps<C>) {
const Element = as ?? 'div'; const Element = as ?? 'div';
return ( return (
<Element className={style.section} {...(props as HTMLAttributes<HTMLElement>)}> <Element className={cx([style.section, className])} {...(props as HTMLAttributes<HTMLElement>)}>
{children}
</Element>
);
}
export function Indent<C extends AllowedTags = 'div'>({ as, className, children, ...props }: SectionProps<C>) {
const Element = as ?? 'div';
return (
<Element className={cx([style.indent, className])} {...(props as HTMLAttributes<HTMLElement>)}>
{children} {children}
</Element> </Element>
); );
@@ -52,8 +63,21 @@ export function Table({ className, children }: { className?: string; children: R
); );
} }
export function ListGroup({ children }: { children: ReactNode }) { export function TableEmpty({ handleClick }: { handleClick: () => void }) {
return <ul className={style.listGroup}>{children}</ul>; return (
<tr className={style.empty}>
<td colSpan={99}>
<div>No data yet</div>
<Button onClick={handleClick} variant='ontime-subtle' rightIcon={<IoAdd />} size='sm'>
New
</Button>
</td>
</tr>
);
}
export function ListGroup({ className, children }: { className?: string; children: ReactNode }) {
return <ul className={cx([style.listGroup, className])}>{children}</ul>;
} }
export function ListItem({ children }: { children: ReactNode }) { export function ListItem({ children }: { children: ReactNode }) {
@@ -74,7 +74,7 @@ export default function ClientList() {
)} )}
{name} {name}
</td> </td>
{isCurrent ? <td /> : <td className={style.pathList}>{path}</td>} <td className={style.pathList}>{path}</td>
<td className={style.actionButtons}> <td className={style.actionButtons}>
<Button <Button
size='xs' size='xs'
@@ -149,9 +149,14 @@ export default function UrlPresetsForm() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{fields.length === 0 && <Panel.TableEmpty handleClick={addNew} />}
{fields.map((preset, index) => { {fields.map((preset, index) => {
const maybeAliasError = errors.data?.[index]?.alias?.message; const maybeAliasError = errors.data?.[index]?.alias?.message;
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message; const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
// only saved and enabled URLs can be tested
const canTest =
preset.alias && preset.enabled && preset.pathAndParams && !maybeAliasError && !maybeUrlError;
return ( return (
<tr key={preset.id}> <tr key={preset.id}>
<td className={style.fit}> <td className={style.fit}>
@@ -190,6 +195,7 @@ export default function UrlPresetsForm() {
<td className={style.flex}> <td className={style.flex}>
<TooltipActionBtn <TooltipActionBtn
size='sm' size='sm'
isDisabled={!canTest}
clickHandler={(event) => handleLinks(event, preset.alias)} clickHandler={(event) => handleLinks(event, preset.alias)}
tooltip='Test preset' tooltip='Test preset'
aria-label='Test preset' aria-label='Test preset'
@@ -6,7 +6,7 @@ import { ViewSettings } from 'ontime-types';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import { postViewSettings } from '../../../../common/api/viewSettings'; import { postViewSettings } from '../../../../common/api/viewSettings';
import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker'; import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import useViewSettings from '../../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -111,15 +111,15 @@ export default function ViewSettingsForm() {
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Timer colour' description='Default colour of a running timer' /> <Panel.Field title='Timer colour' description='Default colour of a running timer' />
<PopoverPickerRHF name='normalColor' control={control} /> <SwatchPickerRHF name='normalColor' control={control} />
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Warning colour' description='Colour of a running timer in warning mode' /> <Panel.Field title='Warning colour' description='Colour of a running timer in warning mode' />
<PopoverPickerRHF name='warningColor' control={control} /> <SwatchPickerRHF name='warningColor' control={control} />
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field title='Danger colour' description='Colour of a running timer in danger mode' /> <Panel.Field title='Danger colour' description='Colour of a running timer in danger mode' />
<PopoverPickerRHF name='dangerColor' control={control} /> <SwatchPickerRHF name='dangerColor' control={control} />
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
<Panel.ListGroup> <Panel.ListGroup>
@@ -8,8 +8,9 @@ import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings'; import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex'; import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './integrationUtils'; import { cycles } from './integrationUtils';
@@ -62,13 +63,6 @@ export default function OscIntegrations() {
} }
}; };
const preventEscape = (event: React.KeyboardEvent) => {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
}
};
const handleAddNewSubscription = () => { const handleAddNewSubscription = () => {
prepend({ prepend({
id: generateId(), id: generateId(),
@@ -106,6 +100,9 @@ export default function OscIntegrations() {
</Button> </Button>
</div> </div>
</Panel.SubHeader> </Panel.SubHeader>
{isOntimeCloud && (
<Panel.Highlight>For security reasons OSC integrations are not available in the cloud service.</Panel.Highlight>
)}
<Panel.Divider /> <Panel.Divider />
@@ -88,7 +88,6 @@ export default function EditorSettingsForm() {
> >
<option value={TimerType.CountDown}>Count down</option> <option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option> <option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option> <option value={TimerType.Clock}>Clock</option>
<option value={TimerType.None}>None</option> <option value={TimerType.None}>None</option>
</Select> </Select>
@@ -2,8 +2,8 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import { openLink } from '../../../../common/utils/linkUtils'; import { linkToOtherHost, openLink } from '../../../../common/utils/linkUtils';
import { isLocalhost, serverPort } from '../../../../externals'; import { isLocalhost } from '../../../../externals';
import style from './NetworkInterfaces.module.scss'; import style from './NetworkInterfaces.module.scss';
@@ -14,11 +14,11 @@ export default function InfoNif() {
return ( return (
<div className={style.interfaces}> <div className={style.interfaces}>
{data.networkInterfaces?.map((nif) => { {data.networkInterfaces.map((nif) => {
// interfaces outside localhost wont have access // interfaces outside localhost wont have access
if (nif.name === 'localhost' && !isLocalhost) return null; if (nif.name === 'localhost' && !isLocalhost) return null;
const address = linkToOtherHost(nif.address);
const address = `http://${nif.address}:${serverPort}`;
return ( return (
<CopyTag <CopyTag
key={nif.name} key={nif.name}
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket'; import { usePing } from '../../../../common/hooks/useSocket';
import { socketSendJson } from '../../../../common/utils/socket'; import { socketSendJson } from '../../../../common/utils/socket';
import { isOntimeCloud } from '../../../../externals'; import { isDockerImage } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel'; import ClientControlPanel from '../client-control-panel/ClientControlPanel';
@@ -19,7 +19,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
<> <>
<Panel.Header>Network</Panel.Header> <Panel.Header>Network</Panel.Header>
<Panel.Section> <Panel.Section>
{isOntimeCloud && <OntimeCloudStats />} {isDockerImage && <OntimeCloudStats />}
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph> <Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
</Panel.Section> </Panel.Section>
<InfoNif /> <InfoNif />
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_LIST } from '../../../../common/api/constants'; import { PROJECT_LIST } from '../../../../common/api/constants';
import { createProject } from '../../../../common/api/db'; import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import { documentationUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
@@ -118,7 +119,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Input <Input
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
placeholder='www.getontime.no' placeholder={websiteUrl}
autoComplete='off' autoComplete='off'
{...register('publicUrl')} {...register('publicUrl')}
/> />
@@ -140,7 +141,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
<Input <Input
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
placeholder='http://docs.getontime.no' placeholder={documentationUrl}
autoComplete='off' autoComplete='off'
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
@@ -10,6 +10,7 @@ import { postProjectData, uploadProjectLogo } from '../../../../common/api/proje
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import useProjectData from '../../../../common/hooks-query/useProjectData'; import useProjectData from '../../../../common/hooks-query/useProjectData';
import { validateLogo } from '../../../../common/utils/uploadUtils'; import { validateLogo } from '../../../../common/utils/uploadUtils';
import { documentationUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss'; import style from './ProjectPanel.module.scss';
@@ -203,7 +204,7 @@ export default function ProjectData() {
<Input <Input
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
placeholder='www.getontime.no' placeholder={websiteUrl}
autoComplete='off' autoComplete='off'
{...register('publicUrl')} {...register('publicUrl')}
/> />
@@ -225,7 +226,7 @@ export default function ProjectData() {
<Input <Input
variant='ontime-filled' variant='ontime-filled'
size='sm' size='sm'
placeholder='http://docs.getontime.no' placeholder={documentationUrl}
autoComplete='off' autoComplete='off'
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
@@ -11,7 +11,7 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent'; import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
import { isLocalhost } from '../../../../externals'; import { isLocalhost, isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
export default function ShutdownPanel() { export default function ShutdownPanel() {
@@ -24,18 +24,28 @@ export default function ShutdownPanel() {
onClose(); onClose();
}; };
const canShutdown = isElectron || isLocalhost;
return ( return (
<> <>
<Panel.Header>Shutdown Ontime</Panel.Header> <Panel.Header>Shutdown Ontime</Panel.Header>
<Panel.Section> <Panel.Section>
<Panel.Paragraph> {isOntimeCloud ? (
This will shutdown the Ontime server. <br /> <Panel.Highlight>
The runtime state will be lost, but your project is kept for next time. For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
</Panel.Paragraph> </Panel.Highlight>
) : (
<Panel.Paragraph>
This will shutdown the Ontime server. <br />
The runtime state will be lost, but your project is kept for next time.
</Panel.Paragraph>
)}
<Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}> <Button colorScheme='red' onClick={onOpen} maxWidth='350px' isDisabled={!(isElectron || isLocalhost)}>
Shutdown ontime Shutdown ontime
</Button> </Button>
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description> {!canShutdown && (
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
)}
<AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}> <AlertDialog variant='ontime' isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay> <AlertDialogOverlay>
<AlertDialogContent> <AlertDialogContent>
@@ -49,7 +59,7 @@ export default function ShutdownPanel() {
<Button ref={cancelRef} onClick={onClose} variant='ontime-ghosted-white'> <Button ref={cancelRef} onClick={onClose} variant='ontime-ghosted-white'>
Cancel Cancel
</Button> </Button>
<Button colorScheme='red' onClick={sendShutdown} ml={4}> <Button colorScheme='red' onClick={sendShutdown} disabled={!canShutdown}>
Shutdown Shutdown
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>
@@ -11,6 +11,7 @@ export const namedImportMap = {
Duration: 'duration', Duration: 'duration',
Cue: 'cue', Cue: 'cue',
Title: 'title', Title: 'title',
'Count to end': 'count to end',
'Is Public': 'public', 'Is Public': 'public',
Skip: 'skip', Skip: 'skip',
Note: 'notes', Note: 'notes',
@@ -47,6 +48,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
duration: namedImportMap.Duration, duration: namedImportMap.Duration,
cue: namedImportMap.Cue, cue: namedImportMap.Cue,
title: namedImportMap.Title, title: namedImportMap.Title,
countToEnd: namedImportMap['Count to end'],
isPublic: namedImportMap['Is Public'], isPublic: namedImportMap['Is Public'],
skip: namedImportMap.Skip, skip: namedImportMap.Skip,
note: namedImportMap.Note, note: namedImportMap.Note,
@@ -40,6 +40,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<th>Duration</th> <th>Duration</th>
<th>Warning Time</th> <th>Warning Time</th>
<th>Danger Time</th> <th>Danger Time</th>
<th>Count to end</th>
<th>Is Public</th> <th>Is Public</th>
<th>Skip</th> <th>Skip</th>
<th>Colour</th> <th>Colour</th>
@@ -71,6 +72,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
} }
eventIndex += 1; eventIndex += 1;
const colour = event.colour ? getAccessibleColour(event.colour) : {}; const colour = event.colour ? getAccessibleColour(event.colour) : {};
const countToEnd = booleanToText(event.countToEnd);
const isPublic = booleanToText(event.isPublic); const isPublic = booleanToText(event.isPublic);
const skip = booleanToText(event.skip); const skip = booleanToText(event.skip);
@@ -93,6 +95,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<td>{millisToString(event.duration)}</td> <td>{millisToString(event.duration)}</td>
<td>{millisToString(event.timeWarning)}</td> <td>{millisToString(event.timeWarning)}</td>
<td>{millisToString(event.timeDanger)}</td> <td>{millisToString(event.timeDanger)}</td>
<td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td>
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td> <td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td> <td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{event.colour}</td> <td style={{ ...colour }}>{event.colour}</td>
@@ -16,7 +16,7 @@ import { Corner } from '../../editors/editor-utils/EditorUtils';
import style from './MessageControl.module.scss'; import style from './MessageControl.module.scss';
export default function TimerPreview() { export default function TimerPreview() {
const { blink, blackout, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } = const { blink, blackout, countToEnd, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
useMessagePreview(); useMessagePreview();
const { data } = useViewSettings(); const { data } = useViewSettings();
@@ -24,11 +24,11 @@ export default function TimerPreview() {
const main = (() => { const main = (() => {
if (showTimerMessage) return 'Message'; if (showTimerMessage) return 'Message';
if (timerType === TimerType.None) return timerPlaceholder;
if (phase === TimerPhase.Pending) return 'Standby to start'; if (phase === TimerPhase.Pending) return 'Standby to start';
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message'; if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
if (timerType === TimerType.TimeToEnd) return 'Time to end';
if (timerType === TimerType.Clock) return 'Clock'; if (timerType === TimerType.Clock) return 'Clock';
if (timerType === TimerType.None) return timerPlaceholder; if (countToEnd) return 'Count to End';
return 'Timer'; return 'Timer';
})(); })();
@@ -74,12 +74,12 @@ export default function TimerPreview() {
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren> <Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} /> <IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
</Tooltip> </Tooltip>
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
</Tooltip>
<Tooltip label='Time type: None' openDelay={tooltipDelayMid} shouldWrapChildren> <Tooltip label='Time type: None' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoBan className={style.statusIcon} data-active={timerType === TimerType.None} /> <IoBan className={style.statusIcon} data-active={timerType === TimerType.None} />
</Tooltip> </Tooltip>
<Tooltip label={countToEnd ? 'Count to end' : 'Count duration'} openDelay={tooltipDelayMid} shouldWrapChildren>
<IoFlag className={style.statusIcon} data-active={countToEnd} />
</Tooltip>
</div> </div>
</div> </div>
); );
@@ -16,4 +16,8 @@
&.finished { &.finished {
color: $timer-finished-color; color: $timer-finished-color;
} }
&.muted {
color: $muted-gray;
}
} }
@@ -19,7 +19,7 @@ export default function TimerDisplay(props: TimerDisplayProps) {
const isNegative = (time ?? 0) < 0; const isNegative = (time ?? 0) < 0;
const display = const display =
time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', ''); time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
const classes = cx([style.timer, isNegative ? style.finished : null]); const classes = cx([style.timer, isNegative ? style.finished : null, time === null && style.muted]);
return <div className={classes}>{display}</div>; return <div className={classes}>{display}</div>;
} }
@@ -1,183 +0,0 @@
import { useCallback, useRef } from 'react';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import BlockRow from './cuesheet-table-elements/BlockRow';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import { useCuesheetSettings } from './store/CuesheetSettings';
import useColumnManager from './useColumnManager';
import style from './Cuesheet.module.scss';
interface CuesheetProps {
data: OntimeRundown;
columns: ColumnDef<OntimeRundownEntry>[];
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
selectedId: string | null;
currentBlockId: string | null;
}
export default function Cuesheet({ data, columns, handleUpdate, selectedId, currentBlockId }: CuesheetProps) {
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
const {
columnVisibility,
columnOrder,
columnSizing,
resetColumnOrder,
setColumnVisibility,
saveColumnOrder,
setColumnSizing,
} = useColumnManager(columns);
const selectedRef = useRef<HTMLTableRowElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
const table = useReactTable({
data,
columns,
columnResizeMode: 'onChange',
state: {
columnOrder,
columnVisibility,
columnSizing,
},
meta: {
handleUpdate,
},
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
});
const setAllVisible = () => {
table.toggleAllColumnsVisible(true);
};
const resetColumnResizing = () => {
setColumnSizing({});
};
const reorder = useCallback(
(fromId: string, toId: string) => {
// get index of from
const fromIndex = columnOrder.indexOf(fromId);
// get index of to
const toIndex = columnOrder.indexOf(toId);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
},
[columnOrder, saveColumnOrder],
);
const headerGroups = table.getHeaderGroups();
const rowModel = table.getRowModel();
const allLeafColumns = table.getAllLeafColumns();
let eventIndex = 0;
let isPast = Boolean(selectedId);
return (
<>
{showSettings && (
<CuesheetTableSettings
columns={allLeafColumns}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
)}
<div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet}>
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} showIndexColumn={showIndexColumn} />
<tbody>
{rowModel.rows.map((row) => {
const key = row.original.id;
const isSelected = selectedId === key;
if (isSelected) {
isPast = false;
}
if (isOntimeBlock(row.original)) {
if (isPast && !showPrevious && key !== currentBlockId) {
return null;
}
return <BlockRow key={key} title={row.original.title} />;
}
if (isOntimeDelay(row.original)) {
if (isPast && !showPrevious) {
return null;
}
const delayVal = row.original.duration;
if (!showDelayBlock || delayVal === 0) {
return null;
}
return <DelayRow key={key} duration={delayVal} />;
}
if (isOntimeEvent(row.original)) {
eventIndex++;
const isSelected = key === selectedId;
if (isPast && !showPrevious) {
return null;
}
let rowBgColour: string | undefined;
if (isSelected) {
rowBgColour = '#D20300'; // $red-700
} else if (row.original.colour) {
try {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = Color(getAccessibleColour(row.original.colour).backgroundColor);
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
} catch (_error) {
/* we do not handle errors here */
}
}
return (
<EventRow
key={key}
eventIndex={eventIndex}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
skip={row.original.skip}
colour={row.original.colour}
showIndexColumn={showIndexColumn}
>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</EventRow>
);
}
// currently there is no scenario where entryType is not handled above, either way...
return null;
})}
</tbody>
</table>
</div>
</>
);
}
@@ -1,114 +0,0 @@
import { useCallback, useMemo } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { CuesheetOverview } from '../overview/Overview';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import { useCuesheetSettings } from './store/CuesheetSettings';
import Cuesheet from './Cuesheet';
import { makeCuesheetColumns } from './cuesheetCols';
import styles from './CuesheetWrapper.module.scss';
export default function CuesheetWrapper() {
// TODO: can we use the normalised rundown for the table?
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: customFields } = useCustomFields();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { updateCustomField } = useEventAction();
const featureData = useCuesheet();
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
useWindowTitle('Cuesheet');
/**
* Handles updating a field
* Currently, only custom fields can be updated from the cuesheet
*/
const handleUpdate = useCallback(
async (rowIndex: number, accessor: CustomFieldLabel, payload: unknown) => {
if (!flatRundown || rundownStatus !== 'success') {
return;
}
if (rowIndex == null || accessor == null || payload == null) {
return;
}
// check if value is the same
const event = flatRundown[rowIndex];
if (!event || !isOntimeEvent(event)) {
return;
}
const previousValue = event.custom[accessor];
if (previousValue === payload) {
return;
}
// check if value is valid
// in anticipation to different types of event here
if (typeof payload !== 'string') {
return;
}
// cleanup
const cleanVal = payload.trim();
// submit
try {
await updateCustomField(event.id, accessor, cleanVal);
} catch (error) {
console.error(error);
}
},
[flatRundown, rundownStatus, updateCustomField],
);
if (!customFields || !flatRundown || rundownStatus !== 'success') {
return <Empty text='Loading...' />;
}
return (
<div className={styles.tableWrapper} data-testid='cuesheet'>
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<CuesheetOverview>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoSettingsOutline />}
onClick={() => toggleSettings()}
/>
</CuesheetOverview>
<CuesheetProgress />
<Cuesheet
data={flatRundown}
columns={columns}
handleUpdate={handleUpdate}
selectedId={featureData.selectedEventId}
currentBlockId={featureData.currentBlockId}
/>
</div>
);
}
@@ -1,41 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`makeTable() > returns array of arrays with given fields 1`] = `
[
[
"Ontime · Rundown export",
],
[
"Project title: test title",
],
[
"Project description: test description",
],
[
"Time Start",
"Time End",
"Duration",
"ID",
"Colour",
"Cue",
"Title",
"Note",
"Is Public? (x)",
"Skip?",
"lighting",
],
[
"00:00:00",
"00:00:00",
"...",
"",
"",
"",
"test title 1",
"",
"x",
"",
"",
],
]
`;
@@ -1,3 +0,0 @@
.progressOverride {
height: 1rem;
}
@@ -1,18 +0,0 @@
import { memo } from 'react';
import style from '../Cuesheet.module.scss';
interface BlockRowProps {
title: string;
}
function BlockRow(props: BlockRowProps) {
const { title } = props;
return (
<tr className={style.blockRow}>
<td>{title}</td>
</tr>
);
}
export default memo(BlockRow);
@@ -1,89 +0,0 @@
import {
closestCorners,
DndContext,
DragEndEvent,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { SortableCell } from './SortableCell';
import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps {
headerGroups: HeaderGroup<OntimeRundownEntry>[];
saveColumnOrder: (fromId: string, toId: string) => void;
showIndexColumn: boolean;
}
export default function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups, saveColumnOrder, showIndexColumn } = props;
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
// cancel if we do not have an over id
if (over?.id == null) return;
saveColumnOrder(active.id as string, over.id as string);
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
);
return (
<thead className={style.tableHeader}>
{headerGroups.map((headerGroup) => {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>{showIndexColumn && '#'}</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
// @ts-expect-error -- we inject this into react-table
const customBackground = header.column.columnDef?.meta?.colour;
let customStyles = {};
if (customBackground) {
const customColour = getAccessibleColour(customBackground);
customStyles = { backgroundColor: customColour.backgroundColor, color: customColour.color };
}
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
);
}
@@ -1,55 +0,0 @@
import { ChangeEvent, memo, useCallback, useEffect, useRef, useState } from 'react';
import { getHotkeyHandler } from '@mantine/hooks';
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
interface EditableCellProps {
value: string;
handleUpdate: (newValue: string) => void;
}
const EditableCell = (props: EditableCellProps) => {
const { value: initialValue, handleUpdate } = props;
// We need to keep and update the state of the cell normally
const [value, setValue] = useState(initialValue);
const ref = useRef<HTMLAreaElement>();
const onChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => setValue(event.target.value), []);
// We'll only update the external data when the input is blurred
const onBlur = useCallback(() => handleUpdate(value), [handleUpdate, value]);
//TODO: maybe we can unify this with `useReactiveTextInput`
const onKeyDown = getHotkeyHandler([
['mod + Enter', () => ref.current?.blur()],
[
'Escape',
() => {
setValue(initialValue);
setTimeout(() => ref.current?.blur());
},
],
]);
// If the initialValue is changed external, sync it up with our state
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return (
<AutoTextArea
size='sm'
value={value}
inputref={ref}
onChange={onChange}
onBlur={onBlur}
rows={1}
onKeyDown={onKeyDown}
transition='none'
spellCheck={false}
style={{ padding: 0 }}
/>
);
};
export default memo(EditableCell);
@@ -1,120 +0,0 @@
$label-colour: $gray-700;
$active-colour: $gray-500;
@mixin label {
font-size: $inner-section-text-size;
color: $label-colour;
text-align: center;
align-self: end;
}
@mixin time {
font-family: 'Open Sans Light', $ontime-font-family;
font-size: 2rem;
text-align: center;
}
.header {
grid-area: header;
display: grid;
width: 100%;
padding: 0.25rem 1rem;
height: max-content;
column-gap: 2rem;
grid-template-areas: 'event playback timer clock actions';
grid-template-columns: 1fr auto auto auto auto;
align-items: center;
justify-items: center;
}
.event {
grid-area: event;
justify-self: start;
.title {
font-size: 1.75rem;
}
.eventNow {
justify-self: start;
font-size: 1.5rem;
}
}
.playback {
grid-area: playback;
display: flex;
flex-direction: column;
justify-items: center;
align-items: center;
.playbackLabel {
@include label;
}
svg {
font-size: 2rem;
height: 3rem;
color: $label-colour;
}
}
.timer {
grid-area: timer;
.timerLabel {
@include label;
}
.value {
@include time;
}
}
.clock {
grid-area: clock;
.clockLabel {
@include label;
}
.value {
grid-area: clock;
@include time;
}
}
.headerActions {
grid-area: actions;
display: flex;
align-items: center;
gap: 0.5rem;
color: $label-colour;
height: 100%;
font-size: 1rem;
.actionIcon,
.actionText {
cursor: pointer;
&.enabled {
color: $active-indicator;
}
&:hover {
color: $active-colour;
}
}
.actionIcon {
font-size: 1.25rem;
}
}
@media (min-width: 1200px) {
// in large screens we want to space the buttons
.headerActions {
padding-left: 10vw;
}
}
@@ -1,70 +0,0 @@
import { Tooltip } from '@chakra-ui/react';
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { Playback, ProjectData } from 'ontime-types';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { cx, enDash } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
import style from './CuesheetTableHeader.module.scss';
interface CuesheetTableHeaderProps {
handleExport: (headerData: ProjectData) => void;
featureData: {
playback: Playback;
selectedEventIndex: number | null;
numEvents: number;
titleNow: string | null;
};
}
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected);
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
const { data: project } = useProjectData();
const exportProject = () => {
if (project) {
handleExport(project);
}
};
const selected = !featureData.numEvents
? 'No events'
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : enDash}/${
featureData.numEvents ? featureData.numEvents : enDash
}`;
return (
<div className={style.header}>
<div className={style.event}>
<div className={style.title}>{project?.title || enDash}</div>
<div className={style.eventNow}>{featureData?.titleNow || enDash}</div>
</div>
<div className={style.playback}>
<div className={style.playbackLabel}>{selected}</div>
<PlaybackIcon state={featureData.playback} />
</div>
<CuesheetTableHeaderTimers />
<div className={style.headerActions}>
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
<span
onClick={() => toggleFollow()}
className={cx([style.actionIcon, followSelected ? style.enabled : null])}
>
<IoLocate />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
<span className={style.actionText} onClick={exportProject}>
Export CSV
</span>
</Tooltip>
</div>
</div>
);
}
@@ -1,23 +0,0 @@
import { useClock, useTimer } from '../../../common/hooks/useSocket';
import ClockTime from '../../viewers/common/clock-time/ClockTime';
import RunningTime from '../../viewers/common/running-time/RunningTime';
import style from './CuesheetTableHeader.module.scss';
export default function CuesheetTableHeaderTimers() {
const { current } = useTimer();
const { clock } = useClock();
return (
<>
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<RunningTime className={style.value} value={current} hideLeadingZero />
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<ClockTime className={style.value} value={clock} />
</div>
</>
);
}
@@ -1,106 +0,0 @@
import { memo, ReactNode } from 'react';
import { Button, Checkbox, Switch } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import style from './CuesheetTableSettings.module.scss';
// reusable button styles
const buttonProps = {
size: 'sm',
variant: 'ontime-subtle',
};
interface CuesheetTableSettingsProps {
columns: Column<OntimeRundownEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
}
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const {
followSelected,
showIndexColumn,
toggleFollow,
showPrevious,
togglePreviousVisibility,
showDelayBlock,
hideSeconds,
showDelayedTimes,
toggleIndexColumn,
toggleDelayedTimes,
toggleDelayVisibility,
toggleSecondsVisibility,
} = useCuesheetSettings();
return (
<div className={style.tableSettings}>
<div className={style.leftPanel}>
<div className={style.sectionTitle}>Toggle column visibility</div>
<div className={style.options}>
{columns.map((column) => {
const columnHeader = column.columnDef.header;
const visible = column.getIsVisible();
return (
<label key={`${column.id}-${visible}`} className={style.option}>
<Checkbox
variant='ontime-ondark'
defaultChecked={visible}
onChange={column.getToggleVisibilityHandler()}
/>
{columnHeader as ReactNode}
</label>
);
})}
</div>
<div className={style.sectionTitle}>Table Options</div>
<div className={style.options}>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={followSelected} onChange={() => toggleFollow()} />
Follow selected event
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
Show past events
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showIndexColumn} onChange={() => toggleIndexColumn()} />
Show Event Order
</label>
</div>
<div className={style.sectionTitle}>Delay Flow</div>
<div className={style.options}>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showDelayedTimes} onChange={() => toggleDelayedTimes()} />
Show delayed times
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
Show delay blocks
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={hideSeconds} onChange={() => toggleSecondsVisibility()} />
Hide seconds
</label>
</div>
</div>
<div className={style.rightPanel}>
<Button onClick={handleClearToggles} {...buttonProps}>
Show All
</Button>
<Button onClick={handleResetResizing} {...buttonProps}>
Reset Resizing
</Button>
<Button onClick={handleResetReordering} {...buttonProps}>
Reset Reordering
</Button>
</div>
</div>
);
}
export default memo(CuesheetTableSettings);
@@ -1,126 +0,0 @@
import { useCallback } from 'react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import RunningTime from '../viewers/common/running-time/RunningTime';
import EditableCell from './cuesheet-table-elements/EditableCell';
import { useCuesheetSettings } from './store/CuesheetSettings';
import style from './Cuesheet.module.scss';
function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
const cellValue = row.getValue();
return cellValue ? <IoCheckmark className={style.check} /> : '';
}
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
const cellValue = (getValue() as number | null) ?? 0;
const delayValue = (original as OntimeEvent)?.delay ?? 0;
return (
<span className={style.time}>
<DelayIndicator delayValue={delayValue} />
<RunningTime value={cellValue} hideSeconds={hideSeconds} />
{delayValue !== 0 && showDelayedTimes && (
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideSeconds} />
)}
</span>
);
}
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
const cellValue = (getValue() as number | null) ?? 0;
return <RunningTime value={cellValue} hideSeconds={hideSeconds} />;
}
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
// @ts-expect-error -- we inject this into react-table
table.options.meta?.handleUpdate(row.index, column.id, newValue);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
);
const event = row.original;
if (!isOntimeEvent(event)) {
return null;
}
// events dont necessarily contain all custom fields
const initialValue = event.custom[column.id] ?? '';
return <EditableCell value={initialValue} handleUpdate={update} />;
}
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
accessorKey: key,
id: key,
header: customFields[key].label,
meta: { colour: customFields[key].colour },
cell: MakeCustomField,
size: 250,
}));
return [
{
accessorKey: 'cue',
id: 'cue',
header: 'Cue',
cell: (row) => row.getValue(),
size: 75,
},
{
accessorKey: 'isPublic',
id: 'isPublic',
header: 'Public',
cell: makePublic,
size: 45,
},
{
accessorKey: 'timeStart',
id: 'timeStart',
header: 'Start',
cell: MakeTimer,
size: 75,
},
{
accessorKey: 'timeEnd',
id: 'timeEnd',
header: 'End',
cell: MakeTimer,
size: 75,
},
{
accessorKey: 'duration',
id: 'duration',
header: 'Duration',
cell: MakeDuration,
size: 75,
},
{
accessorKey: 'title',
id: 'title',
header: 'Title',
cell: (row) => row.getValue(),
size: 250,
},
{
accessorKey: 'note',
id: 'note',
header: 'Note',
cell: (row) => row.getValue(),
size: 250,
},
...dynamicCustomFields,
];
}
@@ -1,19 +0,0 @@
import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
/**
* @description set default column order
*/
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
'isPublic',
'cue',
'timeStart',
'timeEnd',
'duration',
'title',
'note',
];
/**
* @description set default hidden columns
*/
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [];
@@ -1,85 +0,0 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
interface CuesheetSettings {
showSettings: boolean;
showIndexColumn: boolean;
followSelected: boolean;
showPrevious: boolean;
showDelayBlock: boolean;
showDelayedTimes: boolean;
hideSeconds: boolean;
toggleSettings: (newValue?: boolean) => void;
toggleFollow: (newValue?: boolean) => void;
togglePreviousVisibility: (newValue?: boolean) => void;
toggleIndexColumn: (newValue?: boolean) => void;
toggleDelayVisibility: (newValue?: boolean) => void;
toggleDelayedTimes: (newValue?: boolean) => void;
toggleSecondsVisibility: (newValue?: boolean) => void;
}
function toggle(oldValue: boolean, value?: boolean) {
if (typeof value === 'undefined') {
return !oldValue;
}
return value;
}
enum CuesheetKeys {
Follow = 'ontime-cuesheet-follow-selected',
DelayVisibility = 'ontime-cuesheet-show-delay',
PreviousVisibility = 'ontime-cuesheet-show-previous',
ColumnIndex = 'ontime-cuesheet-show-index-column',
DelayedTimes = 'ontime-cuesheet-show-delayed',
Seconds = 'ontime-cuesheet-hide-sceconds',
}
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
showSettings: false,
showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true),
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
hideSeconds: booleanFromLocalStorage(CuesheetKeys.Seconds, false),
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
toggleFollow: (newValue?: boolean) =>
set((state) => {
const followSelected = toggle(state.followSelected, newValue);
localStorage.setItem(CuesheetKeys.Follow, String(followSelected));
return { followSelected };
}),
toggleIndexColumn: (newValue?: boolean) =>
set((state) => {
const showIndexColumn = toggle(state.showIndexColumn, newValue);
localStorage.setItem(CuesheetKeys.ColumnIndex, String(showIndexColumn));
return { showIndexColumn };
}),
togglePreviousVisibility: (newValue?: boolean) =>
set((state) => {
const showPrevious = toggle(state.showPrevious, newValue);
localStorage.setItem(CuesheetKeys.PreviousVisibility, String(showPrevious));
return { showPrevious };
}),
toggleDelayVisibility: (newValue?: boolean) =>
set((state) => {
const showDelayBlock = toggle(state.showDelayBlock, newValue);
localStorage.setItem(CuesheetKeys.DelayVisibility, String(showDelayBlock));
return { showDelayBlock };
}),
toggleDelayedTimes: (newValue?: boolean) =>
set((state) => {
const showDelayedTimes = toggle(state.showDelayedTimes, newValue);
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
return { showDelayedTimes };
}),
toggleSecondsVisibility: (newValue?: boolean) =>
set((state) => {
const hideSeconds = toggle(state.hideSeconds, newValue);
localStorage.setItem(CuesheetKeys.Seconds, String(hideSeconds));
return { hideSeconds };
}),
}));
@@ -8,7 +8,7 @@
color: $ui-white; color: $ui-white;
transition-property: color; transition-property: color;
transition-duration: $transition-time-action; transition-duration: $transition-time-action;
border-radius: 99px; border-radius: $component-border-radius-full;
// similar styles to ontime-button-subtle // similar styles to ontime-button-subtle
background-color: $gray-1050; background-color: $gray-1050;
@@ -27,13 +27,16 @@
.title { .title {
font-size: 1rem; font-size: 1rem;
color: $label-gray; color: $title-gray;
display: flex;
align-items: center;
justify-content: space-between;
} }
.label { .label {
display: block; display: block;
font-size: calc(1rem - 3px); font-size: $aux-text-size;
color: $label-gray; color: $label-gray;
margin-bottom: 0.25rem; margin-bottom: $element-inner-spacing;
max-width: max-content; // prevent label from taking entire row max-width: max-content; // prevent label from taking entire row
} }
@@ -1,4 +1,4 @@
import type { LabelHTMLAttributes, ReactNode } from 'react'; import type { HTMLAttributes, LabelHTMLAttributes } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { type IconBaseProps } from '@react-icons/all-files/lib'; import { type IconBaseProps } from '@react-icons/all-files/lib';
@@ -10,8 +10,13 @@ export function Corner({ className, ...elementProps }: IconBaseProps) {
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />; return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
} }
export function Title({ children }: { children: ReactNode }) { export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={style.title}>{children}</h3>; const classes = cx([style.title, className]);
return (
<h3 className={classes} {...elementProps}>
{children}
</h3>
);
} }
export function Label({ children, className, ...elementProps }: LabelHTMLAttributes<HTMLLabelElement>) { export function Label({ children, className, ...elementProps }: LabelHTMLAttributes<HTMLLabelElement>) {
@@ -27,6 +27,7 @@
} }
.tableContainer { .tableContainer {
height: 350px;
max-height: 350px; max-height: 350px;
overflow-y: auto; overflow-y: auto;
} }
@@ -53,7 +53,7 @@ export default function Welcome(props: WelcomeProps) {
}; };
return ( return (
<Modal isOpen onClose={handleClose} variant='ontime'> <Modal isOpen onClose={handleClose} closeOnOverlayClick={false} variant='ontime'>
<ModalOverlay /> <ModalOverlay />
<ModalContent maxWidth='max(640px, 40vw)'> <ModalContent maxWidth='max(640px, 40vw)'>
<ModalCloseButton /> <ModalCloseButton />
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types'; import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import Empty from '../../common/components/state/Empty'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket'; import { useOperator } from '../../common/hooks/useSocket';
@@ -12,7 +12,7 @@ import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown'; import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import { debounce } from '../../common/utils/debounce'; import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time'; import { getDefaultFormat } from '../../common/utils/time';
import { getPropertyValue, isStringBoolean } from '../viewers/common/viewUtils'; import { getPropertyValue, isStringBoolean } from '../viewers/common/viewUtils';
@@ -85,7 +85,7 @@ export default function Operator() {
} }
} }
}; };
const debouncedHandleScroll = debounce(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
const handleScroll = () => { const handleScroll = () => {
if (timeoutId.current) { if (timeoutId.current) {
@@ -97,7 +97,7 @@ export default function Operator() {
setShowEditPrompt(true); setShowEditPrompt(true);
debouncedHandleScroll(); throttledHandleScroll();
}; };
const handleEdit = useCallback((event: EditEvent) => { const handleEdit = useCallback((event: EditEvent) => {
@@ -108,7 +108,7 @@ export default function Operator() {
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending'; const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
if (missingData || isLoading) { if (missingData || isLoading) {
return <Empty text='Loading...' />; return <EmptyPage text='Loading...' />;
} }
// get fields which the user subscribed to // get fields which the user subscribed to
@@ -4,6 +4,23 @@
display: flex; display: flex;
} }
.isOffline {
.info {
opacity: $opacity-disabled;
}
&::after {
content: 'Disconnected';
position: absolute;
padding-inline: 0.5rem;
bottom: 0.5rem;
right: 0.5rem;
background-color: $red-700;
border-radius: 2px;
font-size: calc(1rem - 2px);
z-index: 10;
}
}
.nav { .nav {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
+89 -48
View File
@@ -1,10 +1,10 @@
import { memo, useMemo } from 'react'; import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { useRuntimeOverview, useRuntimePlaybackOverview, useTimer } from '../../common/hooks/useSocket'; import { useIsOnline, useRuntimeOverview, useRuntimePlaybackOverview, useTimer } from '../../common/hooks/useSocket';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import { enDash } from '../../common/utils/styleUtils'; import { cx, enDash, timerPlaceholder } from '../../common/utils/styleUtils';
import { TimeColumn, TimeRow } from './composite/TimeLayout'; import { TimeColumn, TimeRow } from './composite/TimeLayout';
import { calculateEndAndDaySpan, formatedTime, getOffsetText } from './overviewUtils'; import { calculateEndAndDaySpan, formatedTime, getOffsetText } from './overviewUtils';
@@ -13,7 +13,7 @@ import style from './Overview.module.scss';
export const EditorOverview = memo(_EditorOverview); export const EditorOverview = memo(_EditorOverview);
function _EditorOverview({ children }: { children: React.ReactNode }) { function _EditorOverview({ children }: PropsWithChildren) {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview(); const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
@@ -23,36 +23,48 @@ function _EditorOverview({ children }: { children: React.ReactNode }) {
const expectedEndText = formatedTime(maybeExpectedEnd); const expectedEndText = formatedTime(maybeExpectedEnd);
return ( return (
<div className={style.overview}> <OverviewWrapper navElements={children}>
<ErrorBoundary> <TitlesOverview />
<div className={style.nav}>{children}</div> <div>
<div className={style.info}> <TimeRow
<TitlesOverview /> label='Planned start'
<div> value={formatedTime(plannedStart)}
<TimeRow label='Planned start' value={formatedTime(plannedStart)} className={style.start} /> className={style.start}
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} /> muted={plannedStart === null}
</div> />
<ProgressOverview /> <TimeRow
<CurrentBlockOverview /> label='Actual start'
<RuntimeOverview /> value={formatedTime(actualStart)}
<div> className={style.start}
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} /> muted={actualStart === null}
<TimeRow />
label='Expected end' </div>
value={expectedEndText} <ProgressOverview />
className={style.end} <CurrentBlockOverview />
daySpan={maybeExpectedDaySpan} <RuntimeOverview />
/> <div>
</div> <TimeRow
</div> label='Planned end'
</ErrorBoundary> value={plannedEndText}
</div> className={style.end}
daySpan={maybePlannedDaySpan}
muted={maybePlannedEnd === null}
/>
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
muted={maybeExpectedEnd === null}
/>
</div>
</OverviewWrapper>
); );
} }
export const CuesheetOverview = memo(_CuesheetOverview); export const CuesheetOverview = memo(_CuesheetOverview);
function _CuesheetOverview({ children }: { children: React.ReactNode }) { function _CuesheetOverview({ children }: PropsWithChildren) {
const { plannedEnd, expectedEnd } = useRuntimeOverview(); const { plannedEnd, expectedEnd } = useRuntimeOverview();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
@@ -62,23 +74,41 @@ function _CuesheetOverview({ children }: { children: React.ReactNode }) {
const expectedEndText = formatedTime(maybeExpectedEnd); const expectedEndText = formatedTime(maybeExpectedEnd);
return ( return (
<div className={style.overview}> <OverviewWrapper navElements={children}>
<TitlesOverview />
<TimerOverview />
<RuntimeOverview />
<div>
<TimeRow
label='Planned end'
value={plannedEndText}
className={style.end}
daySpan={maybePlannedDaySpan}
muted={maybePlannedEnd === null}
/>
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
muted={maybeExpectedEnd === null}
/>
</div>
</OverviewWrapper>
);
}
interface OverviewWrapperProps {
navElements: ReactNode;
}
function OverviewWrapper({ navElements, children }: PropsWithChildren<OverviewWrapperProps>) {
const { isOnline } = useIsOnline();
return (
<div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary> <ErrorBoundary>
<div className={style.nav}>{children}</div> <div className={style.nav}>{navElements}</div>
<div className={style.info}> <div className={style.info}>{children}</div>
<TitlesOverview />
<TimerOverview />
<RuntimeOverview />
<div>
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
/>
</div>
</div>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
); );
@@ -87,6 +117,10 @@ function _CuesheetOverview({ children }: { children: React.ReactNode }) {
function TitlesOverview() { function TitlesOverview() {
const { data } = useProjectData(); const { data } = useProjectData();
if (!data.title && !data.description) {
return null;
}
return ( return (
<div> <div>
<div className={style.title}>{data.title}</div> <div className={style.title}>{data.title}</div>
@@ -100,15 +134,22 @@ function CurrentBlockOverview() {
const timeInBlock = formatedTime(currentBlock.startedAt === null ? null : clock - currentBlock.startedAt); const timeInBlock = formatedTime(currentBlock.startedAt === null ? null : clock - currentBlock.startedAt);
return <TimeColumn label='Time in block' value={timeInBlock} className={style.clock} />; return (
<TimeColumn
label='Time in block'
value={timeInBlock}
className={style.clock}
muted={currentBlock.startedAt === null}
/>
);
} }
function TimerOverview() { function TimerOverview() {
const { current } = useTimer(); const { current } = useTimer();
const display = millisToString(current); const display = millisToString(current, { fallback: timerPlaceholder });
return <TimeColumn label='Running timer' value={display} />; return <TimeColumn label='Running timer' value={display} muted={current === null} />;
} }
function ProgressOverview() { function ProgressOverview() {
@@ -44,6 +44,10 @@
content: "*"; content: "*";
vertical-align: super; vertical-align: super;
font-size: 0.75em; font-size: 0.75em;
color: $blue-500; color: $info-blue;
} }
} }
.muted {
color: $muted-gray;
}
@@ -7,20 +7,21 @@ import style from './TimeLayout.module.scss';
interface TimeLayoutProps { interface TimeLayoutProps {
label: string; label: string;
value: string; value: string;
muted?: boolean;
daySpan?: number; daySpan?: number;
className?: string; className?: string;
} }
export function TimeColumn({ label, value, className }: TimeLayoutProps) { export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) {
return ( return (
<div className={style.column}> <div className={style.column}>
<span className={style.label}>{label}</span> <span className={style.label}>{label}</span>
<span className={cx([style.clock, className])}>{value}</span> <span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
</div> </div>
); );
} }
export function TimeRow({ label, value, daySpan, className }: TimeLayoutProps) { export function TimeRow({ label, value, daySpan, muted, className }: TimeLayoutProps) {
return ( return (
<div className={style.row}> <div className={style.row}>
<span className={style.label}>{label}</span> <span className={style.label}>{label}</span>
@@ -29,7 +30,7 @@ export function TimeRow({ label, value, daySpan, className }: TimeLayoutProps) {
<span className={cx([style.clock, style.daySpan, className])}>{value}</span> <span className={cx([style.clock, style.daySpan, className])}>{value}</span>
</Tooltip> </Tooltip>
) : ( ) : (
<span className={cx([style.clock, className])}>{value}</span> <span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
)} )}
</div> </div>
); );
+24 -21
View File
@@ -6,12 +6,14 @@ import {
isOntimeBlock, isOntimeBlock,
isOntimeEvent, isOntimeEvent,
isPlayableEvent, isPlayableEvent,
MaybeString,
PlayableEvent, PlayableEvent,
Playback, Playback,
RundownCached, RundownCached,
SupportedEvent, SupportedEvent,
} from 'ontime-types'; } from 'ontime-types';
import { import {
checkIsNextDay,
getFirstNormal, getFirstNormal,
getLastNormal, getLastNormal,
getNextBlockNormal, getNextBlockNormal,
@@ -21,7 +23,7 @@ import {
isNewLatest, isNewLatest,
} from 'ontime-utils'; } from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction'; import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore'; import { AppMode, useAppMode } from '../../common/stores/appModeStore';
@@ -82,36 +84,36 @@ export default function Rundown({ data }: RundownProps) {
const cloneEntry = rundown[copyId]; const cloneEntry = rundown[copyId];
if (cloneEntry?.type === SupportedEvent.Event) { if (cloneEntry?.type === SupportedEvent.Event) {
//if we don't have a cursor add the new event on top //if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry, adjustedCursor ?? undefined); const newEvent = cloneEvent(cloneEntry);
addEvent(newEvent); addEvent(newEvent, { after: adjustedCursor ?? undefined });
} }
}, },
[addEvent, order, rundown], [addEvent, order, rundown],
); );
const insertAtId = useCallback( const insertAtId = useCallback(
(type: SupportedEvent, id: string | null, above = false) => { (type: SupportedEvent, id: MaybeString, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, id ?? '').entry?.id ?? null : id; const options: EventOptions =
if (adjustedCursor === null) { id === null
// the only thing to do is adding an event at top ? {}
addEvent({ type }); : {
return; after: above ? undefined : id,
} before: above ? id : undefined,
};
if (type === SupportedEvent.Event) { if (type === SupportedEvent.Event) {
const newEvent = { const newEvent = {
type: SupportedEvent.Event, type: SupportedEvent.Event,
}; };
const options = { if (!above && id) {
after: adjustedCursor, options.lastEventId = id;
lastEventId: adjustedCursor, }
};
addEvent(newEvent, options); addEvent(newEvent, options);
} else { } else {
addEvent({ type }, { after: adjustedCursor }); addEvent({ type }, options);
} }
}, },
[rundown, order, addEvent], [addEvent],
); );
const selectBlock = useCallback( const selectBlock = useCallback(
@@ -266,7 +268,7 @@ export default function Rundown({ data }: RundownProps) {
let eventIndex = 0; let eventIndex = 0;
// all events before the current selected are in the past // all events before the current selected are in the past
let isPast = Boolean(featureData?.selectedEventId); let isPast = Boolean(featureData?.selectedEventId);
let isNextDay = false;
const isEditMode = appMode === AppMode.Edit; const isEditMode = appMode === AppMode.Edit;
return ( return (
@@ -285,6 +287,7 @@ export default function Rundown({ data }: RundownProps) {
if (index === 0) { if (index === 0) {
eventIndex = 0; eventIndex = 0;
} }
isNextDay = false;
previousEntryId = thisId; previousEntryId = thisId;
thisId = entryId; thisId = entryId;
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
@@ -293,8 +296,9 @@ export default function Rundown({ data }: RundownProps) {
lastEvent = thisEvent; lastEvent = thisEvent;
if (isPlayableEvent(entry)) { if (isPlayableEvent(entry)) {
// populate previous entry isNextDay = checkIsNextDay(entry, lastEvent);
if (isNewLatest(entry.timeStart, entry.timeEnd, lastEvent?.timeStart, lastEvent?.timeEnd)) { if (isNewLatest(entry, lastEvent)) {
// populate previous entry
thisEvent = entry; thisEvent = entry;
} }
} }
@@ -322,12 +326,11 @@ export default function Rundown({ data }: RundownProps) {
loaded={isLoaded} loaded={isLoaded}
hasCursor={hasCursor} hasCursor={hasCursor}
isNext={isNext} isNext={isNext}
previousStart={lastEvent?.timeStart}
previousEnd={lastEvent?.timeEnd}
previousEntryId={previousEntryId} previousEntryId={previousEntryId}
previousEventId={lastEvent?.id} previousEventId={lastEvent?.id}
playback={isLoaded ? featureData.playback : undefined} playback={isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll} isRolling={featureData.playback === Playback.Roll}
isNextDay={isNextDay}
/> />
</div> </div>
</div> </div>
@@ -32,8 +32,7 @@ interface RundownEntryProps {
eventIndex: number; eventIndex: number;
hasCursor: boolean; hasCursor: boolean;
isNext: boolean; isNext: boolean;
previousStart?: number; isNextDay: boolean;
previousEnd?: number;
previousEntryId?: string; previousEntryId?: string;
previousEventId?: string; previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing playback?: Playback; // we only care about this if this event is playing
@@ -47,13 +46,12 @@ export default function RundownEntry(props: RundownEntryProps) {
loaded, loaded,
hasCursor, hasCursor,
isNext, isNext,
previousStart,
previousEnd,
previousEntryId, previousEntryId,
previousEventId, previousEventId,
playback, playback,
isRolling, isRolling,
eventIndex, eventIndex,
isNextDay,
} = props; } = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
@@ -115,7 +113,7 @@ export default function RundownEntry(props: RundownEntryProps) {
return deleteEvent([data.id]); return deleteEvent([data.id]);
} }
case 'clone': { case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent, data.id); const newEvent = cloneEvent(data as OntimeEvent);
addEvent(newEvent, { after: data.id }); addEvent(newEvent, { after: data.id });
break; break;
} }
@@ -150,6 +148,7 @@ export default function RundownEntry(props: RundownEntryProps) {
if (data.type === SupportedEvent.Event) { if (data.type === SupportedEvent.Event) {
return ( return (
<EventBlock <EventBlock
eventId={data.id}
eventIndex={eventIndex} eventIndex={eventIndex}
cue={data.cue} cue={data.cue}
timeStart={data.timeStart} timeStart={data.timeStart}
@@ -157,15 +156,13 @@ export default function RundownEntry(props: RundownEntryProps) {
duration={data.duration} duration={data.duration}
timeStrategy={data.timeStrategy} timeStrategy={data.timeStrategy}
linkStart={data.linkStart} linkStart={data.linkStart}
eventId={data.id} countToEnd={data.countToEnd}
isPublic={data.isPublic} isPublic={data.isPublic}
endAction={data.endAction} endAction={data.endAction}
timerType={data.timerType} timerType={data.timerType}
title={data.title} title={data.title}
note={data.note} note={data.note}
delay={data.delay ?? 0} delay={data.delay ?? 0}
previousStart={previousStart}
previousEnd={previousEnd}
colour={data.colour} colour={data.colour}
isPast={isPast} isPast={isPast}
isNext={isNext} isNext={isNext}
@@ -174,6 +171,8 @@ export default function RundownEntry(props: RundownEntryProps) {
hasCursor={hasCursor} hasCursor={hasCursor}
playback={playback} playback={playback}
isRolling={isRolling} isRolling={isRolling}
gap={data.gap}
isNextDay={isNextDay}
actionHandler={actionHandler} actionHandler={actionHandler}
/> />
); );
@@ -36,7 +36,7 @@
.side { .side {
max-height: 100%; max-height: 100%;
max-width: 45rem; max-width: 45rem;
margin: 2rem 0; margin: 0.5rem 0;
padding: 1rem; padding: 1rem;
padding-right: 0; padding-right: 0;
background-color: $gray-1325; background-color: $gray-1325;
@@ -7,7 +7,7 @@ import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { Corner } from '../editors/editor-utils/EditorUtils'; import { Corner } from '../editors/editor-utils/EditorUtils';
import EventEditor from './event-editor/EventEditor'; import RundownEventEditor from './event-editor/RundownEventEditor';
import RundownWrapper from './RundownWrapper'; import RundownWrapper from './RundownWrapper';
import style from './RundownExport.module.scss'; import style from './RundownExport.module.scss';
@@ -33,7 +33,7 @@ const RundownExport = () => {
{!hideSideBar && ( {!hideSideBar && (
<div className={style.side}> <div className={style.side}>
<ErrorBoundary> <ErrorBoundary>
<EventEditor /> <RundownEventEditor />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
)} )}
@@ -24,13 +24,14 @@ import RundownIndicators from './RundownIndicators';
import style from './EventBlock.module.scss'; import style from './EventBlock.module.scss';
interface EventBlockProps { interface EventBlockProps {
eventId: string;
cue: string; cue: string;
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: MaybeString;
eventId: string; countToEnd: boolean;
eventIndex: number; eventIndex: number;
isPublic: boolean; isPublic: boolean;
endAction: EndAction; endAction: EndAction;
@@ -38,8 +39,6 @@ interface EventBlockProps {
title: string; title: string;
note: string; note: string;
delay: number; delay: number;
previousStart?: number;
previousEnd?: number;
colour: string; colour: string;
isPast: boolean; isPast: boolean;
isNext: boolean; isNext: boolean;
@@ -48,6 +47,8 @@ interface EventBlockProps {
hasCursor: boolean; hasCursor: boolean;
playback?: Playback; playback?: Playback;
isRolling: boolean; isRolling: boolean;
gap: number;
isNextDay: boolean;
actionHandler: ( actionHandler: (
action: EventItemActions, action: EventItemActions,
payload?: payload?:
@@ -68,6 +69,7 @@ export default function EventBlock(props: EventBlockProps) {
duration, duration,
timeStrategy, timeStrategy,
linkStart, linkStart,
countToEnd,
isPublic = true, isPublic = true,
eventIndex, eventIndex,
endAction, endAction,
@@ -75,8 +77,6 @@ export default function EventBlock(props: EventBlockProps) {
title, title,
note, note,
delay, delay,
previousStart,
previousEnd,
colour, colour,
isPast, isPast,
isNext, isNext,
@@ -85,6 +85,8 @@ export default function EventBlock(props: EventBlockProps) {
hasCursor, hasCursor,
playback, playback,
isRolling, isRolling,
gap,
isNextDay,
actionHandler, actionHandler,
} = props; } = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
@@ -271,7 +273,7 @@ export default function EventBlock(props: EventBlockProps) {
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
id='event-block' id='event-block'
> >
<RundownIndicators timeStart={timeStart} previousStart={previousStart} previousEnd={previousEnd} delay={delay} /> <RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}> <div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}> <span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
@@ -286,6 +288,7 @@ export default function EventBlock(props: EventBlockProps) {
timeEnd={timeEnd} timeEnd={timeEnd}
duration={duration} duration={duration}
linkStart={linkStart} linkStart={linkStart}
countToEnd={countToEnd}
timeStrategy={timeStrategy} timeStrategy={timeStrategy}
eventId={eventId} eventId={eventId}
eventIndex={eventIndex} eventIndex={eventIndex}
@@ -1,11 +1,4 @@
import { import { millisToString, removeTrailingZero } from 'ontime-utils';
calculateDuration,
checkIsNextDay,
dayInMs,
getTimeFromPrevious,
millisToString,
removeTrailingZero,
} from 'ontime-utils';
import { formatDuration } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
@@ -17,25 +10,15 @@ export function formatDelay(timeStart: number, delay: number): string | undefine
const timeTag = removeTrailingZero(millisToString(delayedStart)); const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start ${timeTag}`; return `New start ${timeTag}`;
} }
export function formatGap(gap: number, isNextDay: boolean) {
export function formatOverlap(timeStart: number, previousStart?: number, previousEnd?: number): string | undefined { if (gap === 0) {
const noPreviousElement = previousEnd === undefined || previousStart === undefined; if (isNextDay) {
if (noPreviousElement) return; // We show a next day warning even if there is no gap
return '(next day)';
const normalisedDuration = calculateDuration(previousStart, previousEnd); }
const timeFromPrevious = getTimeFromPrevious(timeStart, previousStart, previousEnd, normalisedDuration); return;
if (timeFromPrevious === 0) return;
if (checkIsNextDay(previousStart, timeStart, normalisedDuration)) {
const previousCrossMidnight = previousStart > previousEnd;
const normalisedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
const gap = dayInMs - normalisedPreviousEnd + timeStart;
if (gap === 0) return;
const gapString = formatDuration(Math.abs(gap), false);
return `Gap ${gapString} (next day)`;
} }
const overlapString = formatDuration(Math.abs(timeFromPrevious), false); const gapString = formatDuration(Math.abs(gap), false);
return `${timeFromPrevious < 0 ? 'Overlap' : 'Gap'} ${overlapString}`; return `${gap < 0 ? 'Overlap' : 'Gap'} ${gapString}${isNextDay ? ' (next day)' : ''}`;
} }
@@ -23,12 +23,13 @@ import EventBlockProgressBar from './composite/EventBlockProgressBar';
import style from './EventBlock.module.scss'; import style from './EventBlock.module.scss';
interface EventBlockInnerProps { interface EventBlockInnerProps {
eventId: string;
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: MaybeString;
eventId: string; countToEnd: boolean;
eventIndex: number; eventIndex: number;
isPublic: boolean; isPublic: boolean;
endAction: EndAction; endAction: EndAction;
@@ -43,14 +44,15 @@ interface EventBlockInnerProps {
isRolling: boolean; isRolling: boolean;
} }
const EventBlockInner = (props: EventBlockInnerProps) => { function EventBlockInner(props: EventBlockInnerProps) {
const { const {
eventId,
timeStart, timeStart,
timeEnd, timeEnd,
duration, duration,
timeStrategy, timeStrategy,
linkStart, linkStart,
eventId, countToEnd,
isPublic = true, isPublic = true,
endAction, endAction,
timerType, timerType,
@@ -91,7 +93,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
delay={delay} delay={delay}
timeStrategy={timeStrategy} timeStrategy={timeStrategy}
linkStart={linkStart} linkStart={linkStart}
timerType={timerType} countToEnd={countToEnd}
/> />
</div> </div>
<div className={style.titleSection}> <div className={style.titleSection}>
@@ -122,6 +124,11 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
<EndActionIcon action={endAction} className={style.statusIcon} /> <EndActionIcon action={endAction} className={style.statusIcon} />
</span> </span>
</Tooltip> </Tooltip>
<Tooltip label={`${countToEnd ? 'Count to End' : 'Count duration'}`} openDelay={tooltipDelayMid}>
<span>
<IoFlag className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
</span>
</Tooltip>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}> <Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}>
<span> <span>
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} /> <IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
@@ -131,7 +138,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
</div> </div>
</> </>
); );
}; }
export default memo(EventBlockInner); export default memo(EventBlockInner);
@@ -162,9 +169,5 @@ function TimerIcon(props: { type: TimerType; className: string }) {
if (type === TimerType.None) { if (type === TimerType.None) {
return <IoBan className={className} />; return <IoBan className={className} />;
} }
if (type === TimerType.TimeToEnd) {
const classes = cx([style.active, className]);
return <IoFlag className={classes} />;
}
return <IoArrowDown className={className} />; return <IoArrowDown className={className} />;
} }
@@ -1,24 +1,24 @@
import { formatDelay, formatOverlap } from './EventBlock.utils'; import { formatDelay, formatGap } from './EventBlock.utils';
import style from './RundownIndicators.module.scss'; import style from './RundownIndicators.module.scss';
interface RundownIndicatorProps { interface RundownIndicatorProps {
timeStart: number; timeStart: number;
previousStart?: number; isNextDay: boolean;
previousEnd?: number;
delay: number; delay: number;
gap: number;
} }
export default function RundownIndicators(props: RundownIndicatorProps) { export default function RundownIndicators(props: RundownIndicatorProps) {
const { timeStart, previousStart, previousEnd, delay } = props; const { timeStart, delay, gap, isNextDay } = props;
const hasOverlap = formatOverlap(timeStart, previousStart, previousEnd); const hasGap = formatGap(gap, isNextDay);
const hasDelay = formatDelay(timeStart, delay); const hasDelay = formatDelay(timeStart, delay);
return ( return (
<div className={style.indicators}> <div className={style.indicators}>
{hasDelay && <div className={style.delay}>{hasDelay}</div>} {hasDelay && <div className={style.delay}>{hasDelay}</div>}
{hasOverlap && <div className={style.gap}>{hasOverlap}</div>} {hasGap && <div className={style.gap}>{hasGap}</div>}
</div> </div>
); );
} }
@@ -1,6 +1,4 @@
import { MILLIS_PER_HOUR } from 'ontime-utils'; import { formatDelay } from '../EventBlock.utils';
import { formatDelay, formatOverlap } from '../EventBlock.utils';
describe('formatDelay()', () => { describe('formatDelay()', () => {
it('adds a given delay to the start time', () => { it('adds a given delay to the start time', () => {
@@ -10,69 +8,3 @@ describe('formatDelay()', () => {
expect(result).toEqual('New start 00:02'); expect(result).toEqual('New start 00:02');
}); });
}); });
describe('formatOverlap()', () => {
it('recognises an overlap between two times', () => {
const previousStart = 0;
const previousEnd = 60000; // 1 min
const timeStart = 30000; // 30 sec
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toEqual('Overlap 30s');
});
it('bug #949 recognises an overlap between two times', () => {
const previousStart = 46800000; // 13:00:00
const previousEnd = 48600000; // 13:30:00
const timeStart = 48300000; // 13:25:00
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toEqual('Overlap 5m');
});
it('handles events the day after, without overlap', () => {
const previousStart = 11 * MILLIS_PER_HOUR;
const previousEnd = 12 * MILLIS_PER_HOUR;
const timeStart = 6 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 18h (next day)');
});
it('handles events the day after, with gap', () => {
const previousStart = 17 * MILLIS_PER_HOUR;
const previousEnd = 23 * MILLIS_PER_HOUR;
const timeStart = 9 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 10h (next day)');
});
it('handles events the day after, with previous ending at midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR; // 23:00:00
const previousEnd = 0; // 00:00:00
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00:00
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 1h (next day)');
});
it('handles sequential events the day after, with previous ending over midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 1 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBeUndefined();
});
it('handles events the day after, with previous ending over midnight with overlap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
const timeStart = 1 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Overlap 1h');
});
it('handles events the day after, with previous ending over midnight with gap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 1h');
});
});
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown';
import EventEditor from './EventEditor';
import style from './EventEditor.module.scss';
interface CuesheetEventEditorProps {
eventId: string;
}
export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
const { eventId } = props;
const { data } = useRundown();
const { order, rundown } = data;
const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => {
if (order.length === 0) {
setEvent(null);
return;
}
const event = rundown[eventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
} else {
setEvent(null);
}
}, [data, eventId, order, rundown]);
if (!event) {
return null;
}
return (
<div className={style.eventEditor} data-testid='editor-container'>
<EventEditor event={event} />
</div>
);
}
@@ -1,8 +1,7 @@
.eventEditor { .eventEditor {
color: $label-gray;
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
padding: 0.5rem; padding-inline: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -16,18 +15,10 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 1.5rem;
overflow-y: auto; overflow-y: auto;
} }
.footer {
border-top: 1px solid $white-10;
padding-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.timeSettings { .timeSettings {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -38,18 +29,22 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
h3 {
margin-bottom: -0.5rem; // bring the title closer to the section elements
}
} }
.decorated { .decorated {
color: var(--decorator-color, $ui-white); color: var(--decorator-color, $ui-white);
background-color: var(--decorator-bg, $gray-1100); background-color: var(--decorator-bg, $gray-1100);
width: fit-content; width: fit-content;
padding: 0 0.5rem; padding-inline: 0.5rem;
border-radius: 2px; border-radius: $component-border-radius-sm;
} }
.delayLabel { .delayLabel {
font-size: calc(1rem - 3px); font-size: $aux-text-size;
color: $ontime-delay-text; color: $ontime-delay-text;
&::after { &::after {
@@ -63,6 +58,8 @@
gap: 0.5rem; gap: 0.5rem;
max-width: max-content; max-width: max-content;
cursor: pointer; cursor: pointer;
height: 2rem; // manually match the height of a text input
margin-bottom: 0; // reset margin from label component
} }
.inline { .inline {
@@ -74,6 +71,13 @@
.splitTwo { .splitTwo {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
column-gap: 1.5rem; column-gap: 1rem;
row-gap: 1rem; row-gap: 1rem;
} }
.tooltipIcon {
color: $blue-500;
display: inline-block;
font-size: 1.25em;
margin-left: 0.25em;
}
@@ -1,15 +1,12 @@
import { CSSProperties, memo, useCallback, useEffect, useState } from 'react'; import { CSSProperties, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types'; import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import CopyTag from '../../../common/components/copy-tag/CopyTag';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import useRundown from '../../../common/hooks-query/useRundown';
import { getAccessibleColour } from '../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../common/utils/styleUtils';
import * as Editor from '../../editors/editor-utils/EditorUtils'; import * as Editor from '../../editors/editor-utils/EditorUtils';
import { useEventSelection } from '../useEventSelection';
import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles'; import EventEditorTitles from './composite/EventEditorTitles';
@@ -22,35 +19,17 @@ export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel; export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
export default function EventEditor() { interface EventEditorProps {
const selectedEvents = useEventSelection((state) => state.selectedEvents); event: OntimeEvent;
const { data } = useRundown(); }
export default function EventEditor(props: EventEditorProps) {
const { event } = props;
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { order, rundown } = data;
const { updateEvent } = useEventAction(); const { updateEvent } = useEventAction();
const [_searchParams, setSearchParams] = useSearchParams(); const [_searchParams, setSearchParams] = useSearchParams();
const [event, setEvent] = useState<OntimeEvent | null>(null); const isEditor = window.location.pathname.includes('editor');
useEffect(() => {
if (order.length === 0) {
setEvent(null);
return;
}
const selectedEventId = order.find((eventId) => selectedEvents.has(eventId));
if (!selectedEventId) {
setEvent(null);
return;
}
const event = rundown[selectedEventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
} else {
setEvent(null);
}
}, [order, rundown, selectedEvents]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => { (field: EditorUpdateFields, value: string) => {
@@ -73,86 +52,61 @@ export default function EventEditor() {
} }
return ( return (
<div className={style.eventEditor} data-testid='editor-container'> <div className={style.content}>
<div className={style.content}> <EventEditorTimes
<EventEditorTimes key={`${event.id}-times`}
key={`${event.id}-times`} eventId={event.id}
eventId={event.id} timeStart={event.timeStart}
timeStart={event.timeStart} timeEnd={event.timeEnd}
timeEnd={event.timeEnd} duration={event.duration}
duration={event.duration} timeStrategy={event.timeStrategy}
timeStrategy={event.timeStrategy} linkStart={event.linkStart}
linkStart={event.linkStart} countToEnd={event.countToEnd}
delay={event.delay ?? 0} delay={event.delay ?? 0}
isPublic={event.isPublic} isPublic={event.isPublic}
endAction={event.endAction} endAction={event.endAction}
timerType={event.timerType} timerType={event.timerType}
timeWarning={event.timeWarning} timeWarning={event.timeWarning}
timeDanger={event.timeDanger} timeDanger={event.timeDanger}
/> />
<EventEditorTitles <EventEditorTitles
key={`${event.id}-titles`} key={`${event.id}-titles`}
eventId={event.id} eventId={event.id}
cue={event.cue} cue={event.cue}
title={event.title} title={event.title}
note={event.note} note={event.note}
colour={event.colour} colour={event.colour}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
/> />
<div className={style.column}> <div className={style.column}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Editor.Title>
<Editor.Title>Custom Fields</Editor.Title> Custom Fields
{isEditor && (
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}> <Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
Manage Manage
</Button> </Button>
</div> )}
{Object.keys(customFields).map((fieldKey) => { </Editor.Title>
const key = `${event.id}-${fieldKey}`; {Object.keys(customFields).map((fieldKey) => {
const fieldName = `custom-${fieldKey}`; const key = `${event.id}-${fieldKey}`;
const initialValue = event.custom[fieldKey] ?? ''; const fieldName = `custom-${fieldKey}`;
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour); const initialValue = event.custom[fieldKey] ?? '';
const labelText = customFields[fieldKey].label; const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label;
return ( return (
<EventTextArea <EventTextArea
key={key} key={key}
field={fieldName} field={fieldName}
label={labelText} label={labelText}
initialValue={initialValue} initialValue={initialValue}
submitHandler={handleSubmit} submitHandler={handleSubmit}
className={style.decorated} className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties} style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/> />
); );
})} })}
</div>
</div> </div>
<EventEditorFooter id={event.id} cue={event.cue} />
</div>
);
}
interface EventEditorFooterProps {
id: string;
cue: string;
}
const EventEditorFooter = memo(_EventEditorFooter);
function _EventEditorFooter(props: EventEditorFooterProps) {
const { id, cue } = props;
const loadById = `/ontime/load/id "${id}"`;
const loadByCue = `/ontime/load/cue "${cue}"`;
return (
<div className={style.footer}>
<CopyTag copyValue={loadById} label='OSC trigger by ID'>
{loadById}
</CopyTag>
<CopyTag copyValue={loadByCue} label='OSC trigger by cue'>
{loadByCue}
</CopyTag>
</div> </div>
); );
} }
@@ -38,8 +38,6 @@
} }
.prompt { .prompt {
font-size: 1rem;
text-align: left;
margin-left: 4rem; margin-left: 4rem;
} }
@@ -1,7 +1,8 @@
import { memo } from 'react'; import { memo, PropsWithChildren } from 'react';
import { Kbd } from '@chakra-ui/react'; import { Kbd } from '@chakra-ui/react';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import * as Editor from '../../editors/editor-utils/EditorUtils';
import style from './EventEditorEmpty.module.scss'; import style from './EventEditorEmpty.module.scss';
@@ -11,7 +12,7 @@ function EventEditorEmpty() {
return ( return (
<div className={style.eventEditor} data-testid='editor-container'> <div className={style.eventEditor} data-testid='editor-container'>
<div className={style.shortcutSection}> <div className={style.shortcutSection}>
<div className={style.prompt}>Rundown shortcuts:</div> <Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
<table className={style.shortcuts}> <table className={style.shortcuts}>
<tbody> <tbody>
<tr> <tr>
@@ -168,6 +169,6 @@ function EventEditorEmpty() {
); );
} }
function AuxKey({ children }: { children: React.ReactNode }) { function AuxKey({ children }: PropsWithChildren) {
return <span className={style.divider}>{children}</span>; return <span className={style.divider}>{children}</span>;
} }
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection';
import { EventEditorFooter } from './composite/EventEditorFooter';
import EventEditor from './EventEditor';
import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss';
export default function RundownEventEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
const { order, rundown } = data;
const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => {
if (order.length === 0) {
setEvent(null);
return;
}
const selectedEventId = order.find((eventId) => selectedEvents.has(eventId));
if (!selectedEventId) {
setEvent(null);
return;
}
const event = rundown[selectedEventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
} else {
setEvent(null);
}
}, [order, rundown, selectedEvents]);
if (!event) {
return <EventEditorEmpty />;
}
return (
<div className={style.eventEditor} data-testid='editor-container'>
<EventEditor event={event} />
<EventEditorFooter id={event.id} cue={event.cue} />
</div>
);
}

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