From 6c9222bce33913865513a44a5d7eaee217958dc1 Mon Sep 17 00:00:00 2001
From: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Date: Mon, 10 Jul 2023 22:15:13 +0200
Subject: [PATCH 01/14] chore: improve bug reporting data (#443)
---
.../common/components/error-boundary/ErrorBoundary.jsx | 7 ++++++-
apps/client/src/common/utils/socket.ts | 8 ++++++--
apps/client/src/index.tsx | 2 ++
3 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx b/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx
index 5cbb1e595..dcd522a08 100644
--- a/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx
+++ b/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx
@@ -3,6 +3,9 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react';
+import { runtime } from '@/common/stores/runtime';
+import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
+
import style from './ErrorBoundary.module.scss';
class ErrorBoundary extends React.Component {
@@ -25,7 +28,9 @@ class ErrorBoundary extends React.Component {
});
Sentry.withScope((scope) => {
- scope.setExtras(error);
+ scope.setExtras('error', error);
+ scope.setExtras('store', runtime.getState());
+ scope.setExtras('hasSocket', { hasConnected, shouldReconnect, reconnectAttempts });
const eventId = Sentry.captureException(error);
this.setState({ eventId, info });
});
diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts
index c0c899819..ddeeb21cf 100644
--- a/apps/client/src/common/utils/socket.ts
+++ b/apps/client/src/common/utils/socket.ts
@@ -8,13 +8,16 @@ import { runtime } from '../stores/runtime';
export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000;
-let shouldReconnect = true;
-
+export let shouldReconnect = true;
+export let hasConnected = false;
+export let reconnectAttempts = 0;
export const connectSocket = () => {
websocket = new WebSocket(websocketUrl);
websocket.onopen = () => {
clearTimeout(reconnectTimeout as NodeJS.Timeout);
+ hasConnected = true;
+ reconnectAttempts = 0;
};
websocket.onclose = () => {
@@ -23,6 +26,7 @@ export const connectSocket = () => {
reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) {
+ reconnectAttempts += 1;
connectSocket();
}
}, reconnectInterval);
diff --git a/apps/client/src/index.tsx b/apps/client/src/index.tsx
index 8a8271d53..a203885e2 100644
--- a/apps/client/src/index.tsx
+++ b/apps/client/src/index.tsx
@@ -18,6 +18,8 @@ Sentry.init({
tracesSampleRate: 1.0,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
+ ignoreErrors: ['top.GLOBALS', 'Unable to preload CSS'],
+ denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
root.render(
From 91a2d395cb416b6fc88af2eabcd83bf0263b8479 Mon Sep 17 00:00:00 2001
From: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Date: Tue, 11 Jul 2023 21:54:10 +0200
Subject: [PATCH 02/14] style: small tweaks on code style and editor UI (#450)
* fix: notes > note
* style: improve roll visibility
---
.../control/playback/playback-buttons/PlaybackButtons.tsx | 4 ++--
apps/client/src/features/table/__tests__/utils.test.js | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx
index 7730e8496..045afd63e 100644
--- a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx
+++ b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx
@@ -5,7 +5,7 @@ import { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
-import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
+import { IoTime} from '@react-icons/all-files/io5/IoTime';
import { Playback } from 'ontime-types';
import { setPlayback } from '../../../../common/hooks/useSocket';
@@ -88,7 +88,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
theme={Playback.Roll}
active={isRolling}
>
-
+
diff --git a/apps/client/src/features/table/__tests__/utils.test.js b/apps/client/src/features/table/__tests__/utils.test.js
index 5fda14d3c..e7341053b 100644
--- a/apps/client/src/features/table/__tests__/utils.test.js
+++ b/apps/client/src/features/table/__tests__/utils.test.js
@@ -35,7 +35,7 @@ describe('parseField()', () => {
{ field: 'title', value: 'test' },
{ field: 'presenter', value: 'test' },
{ field: 'subtitle', value: 'test' },
- { field: 'notes', value: 'test' },
+ { field: 'note', value: 'test' },
{ field: 'colour', value: 'test' },
{ field: 'user0', value: 'test' },
{ field: 'user1', value: 'test' },
From b89d0787d44b6862a0b18f84ffd3f99ec3ba6b1a Mon Sep 17 00:00:00 2001
From: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Date: Sat, 15 Jul 2023 11:06:10 +0200
Subject: [PATCH 03/14] refactor: roll handover (#451)
* style: tweak active colour
* refactor: create shared playback validation
* feat: allow handover from and to roll
---
.../playback-buttons/PlaybackButtons.tsx | 36 ++++++++-----------
apps/client/src/theme/_v2Styles.scss | 2 +-
apps/server/src/services/PlaybackService.ts | 9 ++---
apps/server/src/services/TimerService.ts | 32 +++++++++++------
packages/utils/index.ts | 1 +
packages/utils/package.json | 1 +
.../src/validate-action/validatePlayback.ts | 10 ++++++
pnpm-lock.yaml | 2 ++
8 files changed, 56 insertions(+), 37 deletions(-)
create mode 100644 packages/utils/src/validate-action/validatePlayback.ts
diff --git a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx
index 045afd63e..1f6d49bf7 100644
--- a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx
+++ b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx
@@ -5,8 +5,9 @@ import { IoPlaySkipBack } from '@react-icons/all-files/io5/IoPlaySkipBack';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
-import { IoTime} from '@react-icons/all-files/io5/IoTime';
+import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { Playback } from 'ontime-types';
+import { validatePlayback } from 'ontime-utils';
import { setPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig';
@@ -34,7 +35,13 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const noEvents = numEvents === 0;
const disableGo = isRolling || noEvents || (isLast && !isArmed);
- const disablePrev = isRolling || noEvents || isFirst;
+ const disablePrev = noEvents || isFirst;
+
+ const playbackCan = validatePlayback(playback);
+ const disableStart = !playbackCan.start;
+ const disablePause = !playbackCan.pause;
+ const disableRoll = !playbackCan.roll || noEvents;
+ const disableStop = !playbackCan.stop;
const goModeText = selectedEventIndex === null || isArmed ? 'Start' : 'Next';
const goModeAction = () => {
@@ -51,21 +58,11 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
{goModeText}
-
+
-
+
@@ -82,21 +79,16 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
-
+
-
+
-
+
diff --git a/apps/client/src/theme/_v2Styles.scss b/apps/client/src/theme/_v2Styles.scss
index 74b0c7771..0f64a0c5b 100644
--- a/apps/client/src/theme/_v2Styles.scss
+++ b/apps/client/src/theme/_v2Styles.scss
@@ -24,7 +24,7 @@ $ontime-delay-text: #E69056;
$ontime-paused: #c05621;
$ontime-stop: #E4281E;
$playback-negative: $red-500;
-$active-indicator: #899948;
+$active-indicator: #8bb33d;
$text-black: $gray-1350;
// interface panels
diff --git a/apps/server/src/services/PlaybackService.ts b/apps/server/src/services/PlaybackService.ts
index 5831a2376..813906f4f 100644
--- a/apps/server/src/services/PlaybackService.ts
+++ b/apps/server/src/services/PlaybackService.ts
@@ -1,4 +1,5 @@
-import { OntimeEvent, Playback } from 'ontime-types';
+import { OntimeEvent } from 'ontime-types';
+import { validatePlayback } from 'ontime-utils';
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
import { eventStore } from '../stores/EventStore.js';
@@ -133,7 +134,7 @@ export class PlaybackService {
* Starts playback on selected event
*/
static start() {
- if (eventTimer.playback === Playback.Armed || eventTimer.playback === Playback.Pause) {
+ if (validatePlayback(eventTimer.playback).start) {
eventTimer.start();
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
@@ -155,7 +156,7 @@ export class PlaybackService {
* Pauses playback on selected event
*/
static pause() {
- if (eventTimer.playback === Playback.Play) {
+ if (validatePlayback(eventTimer.playback).pause) {
eventTimer.pause();
const newState = eventTimer.playback;
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
@@ -166,7 +167,7 @@ export class PlaybackService {
* Stops timer and unloads any events
*/
static stop() {
- if (eventTimer.playback !== Playback.Stop) {
+ if (validatePlayback(eventTimer.playback).stop) {
eventLoader.reset();
eventTimer.stop();
const newState = eventTimer.playback;
diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts
index 96c985283..0608dcd24 100644
--- a/apps/server/src/services/TimerService.ts
+++ b/apps/server/src/services/TimerService.ts
@@ -7,6 +7,13 @@ import { DAY_TO_MS } from '../utils/time.js';
import { integrationService } from './integration-service/IntegrationService.js';
import { getCurrent, getElapsed, getExpectedFinish } from './timerUtils.js';
import { clock } from './Clock.js';
+import { logger } from '../classes/Logger.js';
+
+type initialLoadingData = {
+ startedAt?: number | null;
+ expectedFinish?: number | null;
+ current?: number | null;
+};
export class TimerService {
private readonly _interval: NodeJS.Timer;
@@ -113,7 +120,7 @@ export class TimerService {
* @param {string} timer.timerType
* @param {boolean} timer.skip
*/
- load(timer) {
+ load(timer, initialData?: initialLoadingData) {
if (timer.skip) {
throw new Error('Refuse load of skipped event');
}
@@ -129,6 +136,10 @@ export class TimerService {
this.pausedTime = 0;
this.pausedAt = 0;
+ if (typeof initialData !== 'undefined') {
+ this.timer = { ...this.timer, ...initialData };
+ }
+
this._onLoad();
}
@@ -146,6 +157,9 @@ export class TimerService {
start() {
if (!this.loadedTimerId) {
+ if (this.playback === Playback.Roll) {
+ logger.error('PLAYBACK', 'Cannot start while waiting for event');
+ }
return;
}
@@ -155,12 +169,12 @@ export class TimerService {
this.timer.clock = clock.timeNow();
- // add paused time
+ // add paused time if it exists
if (this.pausedTime) {
this.timer.addedTime += this.pausedTime;
this.pausedAt = null;
this.pausedTime = 0;
- } else {
+ } else if (this.timer.startedAt === null) {
this.timer.startedAt = this.timer.clock;
}
@@ -188,10 +202,6 @@ export class TimerService {
}
pause() {
- if (this.playback !== Playback.Play) {
- return;
- }
-
this.playback = Playback.Pause;
this.timer.clock = clock.timeNow();
this.pausedAt = this.timer.clock;
@@ -370,9 +380,11 @@ export class TimerService {
// when we load a timer in roll, we do the same things as before
// but also pre-populate some data as to the running state
- this.load(currentEvent);
- this.timer.startedAt = currentEvent.timeStart;
- this.timer.expectedFinish = currentEvent.timeEnd;
+ this.load(currentEvent, {
+ startedAt: currentEvent.timeStart,
+ expectedFinish: currentEvent.timeEnd,
+ current: currentEvent.timeEnd - this.timer.clock,
+ });
} else if (nextEvent) {
// account for day after
const nextStart = nextEvent.timeStart < this.timer.clock ? nextEvent.timeStart + DAY_TO_MS : nextEvent.timeStart;
diff --git a/packages/utils/index.ts b/packages/utils/index.ts
index 323704a96..6fceea01a 100644
--- a/packages/utils/index.ts
+++ b/packages/utils/index.ts
@@ -3,3 +3,4 @@ export { formatFromMillis } from './src/date-utils/formatFromMillis.js';
export { isTimeString } from './src/date-utils/isTimeString.js';
export { millisToString } from './src/date-utils/millisToString.js';
export { generateId } from './src/generate-id/generateId.js';
+export { validatePlayback } from './src/validate-action/validatePlayback.js';
diff --git a/packages/utils/package.json b/packages/utils/package.json
index f2e3b63d9..1e5bdd7cf 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -23,6 +23,7 @@
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-simple-import-sort": "^8.0.0",
+ "ontime-types": "workspace:*",
"prettier": "^2.8.3",
"typescript": "^4.9.4",
"vitest": "^0.30.1"
diff --git a/packages/utils/src/validate-action/validatePlayback.ts b/packages/utils/src/validate-action/validatePlayback.ts
new file mode 100644
index 000000000..6911c73ff
--- /dev/null
+++ b/packages/utils/src/validate-action/validatePlayback.ts
@@ -0,0 +1,10 @@
+import { Playback } from 'ontime-types';
+
+export function validatePlayback(currentPlayback: Playback) {
+ return {
+ start: currentPlayback !== Playback.Stop,
+ pause: currentPlayback === Playback.Play || currentPlayback === Playback.Roll,
+ roll: true,
+ stop: currentPlayback !== Playback.Stop,
+ };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e07297b7d..4e3da0fb6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -267,6 +267,7 @@ importers:
eslint-plugin-simple-import-sort: ^8.0.0
luxon: ^3.3.0
nanoid: ^4.0.1
+ ontime-types: workspace:*
prettier: ^2.8.3
typescript: ^4.9.4
vitest: ^0.30.1
@@ -281,6 +282,7 @@ importers:
eslint-config-prettier: 8.6.0_eslint@8.31.0
eslint-plugin-prettier: 4.2.1_do5yx3dogbskqc4h5x5ilvlwyy
eslint-plugin-simple-import-sort: 8.0.0_eslint@8.31.0
+ ontime-types: link:../types
prettier: 2.8.3
typescript: 4.9.4
vitest: 0.30.1
From 4a0d83d970b416481dc01c31a64433c0f36d168d Mon Sep 17 00:00:00 2001
From: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Date: Mon, 17 Jul 2023 13:51:03 +0200
Subject: [PATCH 04/14] fix: fullscreen permissions (#453)
attempt fixing issues with going fullscreen by configuring the Content Security Policy
---
apps/client/index.html | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/client/index.html b/apps/client/index.html
index c6a234f36..88135e8ca 100644
--- a/apps/client/index.html
+++ b/apps/client/index.html
@@ -6,6 +6,7 @@
+
Date: Mon, 17 Jul 2023 13:53:50 +0200
Subject: [PATCH 05/14] fix: issues with clock overflow on midnight (#456)
* fix: issues with clock overflow on midnight
* chore: version bump
---
apps/client/package.json | 2 +-
apps/electron/package.json | 2 +-
apps/server/package.json | 2 +-
apps/server/src/services/TimerService.ts | 4 ++++
package.json | 2 +-
packages/utils/package.json | 2 +-
6 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/apps/client/package.json b/apps/client/package.json
index 81db38c6c..3cd8d5de0 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
- "version": "2.0.2",
+ "version": "2.0.8",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.5.5",
diff --git a/apps/electron/package.json b/apps/electron/package.json
index 906e0b19b..a767ef868 100644
--- a/apps/electron/package.json
+++ b/apps/electron/package.json
@@ -1,6 +1,6 @@
{
"name": "ontime",
- "version": "2.0.2",
+ "version": "2.0.8",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
diff --git a/apps/server/package.json b/apps/server/package.json
index 242ee3921..3e326d88c 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
- "version": "2.0.2",
+ "version": "2.0.8",
"exports": "./src/index.js",
"dependencies": {
"body-parser": "^1.20.0",
diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts
index 0608dcd24..8831692fb 100644
--- a/apps/server/src/services/TimerService.ts
+++ b/apps/server/src/services/TimerService.ts
@@ -319,7 +319,11 @@ export class TimerService {
}
update(force = false) {
+ const previousTime = this.timer.clock;
this.timer.clock = clock.timeNow();
+ if (previousTime > this.timer.clock) {
+ force = true;
+ }
// we call integrations if we update timers
let shouldNotify = false;
diff --git a/package.json b/package.json
index 964cbdc7b..ebe13f751 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ontime",
- "version": "2.0.2",
+ "version": "2.0.8",
"description": "Time keeping for live events",
"keywords": [
"lighdev",
diff --git a/packages/utils/package.json b/packages/utils/package.json
index 1e5bdd7cf..0e00759ac 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -2,7 +2,7 @@
"name": "ontime-utils",
"type": "module",
"exports": "./index.ts",
- "version": "2.0.2",
+ "version": "2.0.8",
"private": true,
"description": "shared logic for ontime",
"scripts": {
From 6ef6b0fdcdcc7924afb88f61f00a0db47eab3cac Mon Sep 17 00:00:00 2001
From: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Date: Thu, 20 Jul 2023 21:35:09 +0200
Subject: [PATCH 06/14] fix: app image cannot find resources (#458)
* fix: typo prevents linux from finding resources
---
apps/client/package.json | 2 +-
apps/electron/package.json | 2 +-
apps/server/package.json | 2 +-
apps/server/src/setup.ts | 2 +-
package.json | 2 +-
packages/utils/package.json | 2 +-
6 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/apps/client/package.json b/apps/client/package.json
index 3cd8d5de0..29e25934e 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
- "version": "2.0.8",
+ "version": "2.0.9",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.5.5",
diff --git a/apps/electron/package.json b/apps/electron/package.json
index a767ef868..1edf35f45 100644
--- a/apps/electron/package.json
+++ b/apps/electron/package.json
@@ -1,6 +1,6 @@
{
"name": "ontime",
- "version": "2.0.8",
+ "version": "2.0.9",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
diff --git a/apps/server/package.json b/apps/server/package.json
index 3e326d88c..cb80ae90a 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
- "version": "2.0.8",
+ "version": "2.0.9",
"exports": "./src/index.js",
"dependencies": {
"body-parser": "^1.20.0",
diff --git a/apps/server/src/setup.ts b/apps/server/src/setup.ts
index f31e72b81..4684362a1 100644
--- a/apps/server/src/setup.ts
+++ b/apps/server/src/setup.ts
@@ -41,7 +41,7 @@ export const isDocker = env === 'docker';
// =================================================
// resolve path to external
-const productionPath = '../../Resources/extraResources/client';
+const productionPath = '../../resources/extraResources/client';
const devPath = '../../client/build/';
const dockerPath = 'client/';
diff --git a/package.json b/package.json
index ebe13f751..df56270ae 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ontime",
- "version": "2.0.8",
+ "version": "2.0.9",
"description": "Time keeping for live events",
"keywords": [
"lighdev",
diff --git a/packages/utils/package.json b/packages/utils/package.json
index 0e00759ac..71dd461c5 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -2,7 +2,7 @@
"name": "ontime-utils",
"type": "module",
"exports": "./index.ts",
- "version": "2.0.8",
+ "version": "2.0.9",
"private": true,
"description": "shared logic for ontime",
"scripts": {
From db1155ce1a2778866506eecdbe4dae192ab35159 Mon Sep 17 00:00:00 2001
From: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Date: Fri, 21 Jul 2023 20:15:54 +0200
Subject: [PATCH 07/14] feat: allow renaming connection (#457)
* feat: allow renaming connection
---
apps/client/src/App.tsx | 4 +-
.../navigation-menu/NavigationMenu.tsx | 29 ++++++--
.../RenameClientModal.module.scss | 5 ++
.../rename-client-modal/RenameClientModal.tsx | 73 +++++++++++++++++++
apps/client/src/common/hooks/useSocket.ts | 2 +
.../src/common/stores/connectionName.ts | 26 +++++++
apps/client/src/common/utils/socket.ts | 11 ++-
apps/server/src/adapters/WebsocketAdapter.ts | 51 +++++++++----
8 files changed, 180 insertions(+), 21 deletions(-)
create mode 100644 apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.module.scss
create mode 100644 apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.tsx
create mode 100644 apps/client/src/common/stores/connectionName.ts
diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index e2aa429b9..1cb17bfb9 100644
--- a/apps/client/src/App.tsx
+++ b/apps/client/src/App.tsx
@@ -9,6 +9,7 @@ import { AppContextProvider } from './common/context/AppContext';
import { ContextMenuProvider } from './common/context/ContextMenuContext';
import useElectronEvent from './common/hooks/useElectronEvent';
import { ontimeQueryClient } from './common/queryClient';
+import { socketClientName } from './common/stores/connectionName';
import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
@@ -18,7 +19,8 @@ import AppRouter from './AppRouter';
// @ts-expect-error no types from font import
import('typeface-open-sans');
-connectSocket();
+const preferredClientName = socketClientName.getState().name;
+connectSocket(preferredClientName);
function App() {
const { isElectron, sendToElectron } = useElectronEvent();
diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx
index 7195e668e..461cc62ce 100644
--- a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx
+++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx
@@ -1,6 +1,7 @@
-import { KeyboardEvent, useEffect, useRef, useState } from 'react';
+import { KeyboardEvent, memo, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
+import { useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
@@ -14,9 +15,11 @@ import useFullscreen from '../../hooks/useFullscreen';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useViewOptionsStore } from '../../stores/viewOptions';
+import RenameClientModal from './rename-client-modal/RenameClientModal';
+
import style from './NavigationMenu.module.scss';
-export default function NavigationMenu() {
+function NavigationMenu() {
const location = useLocation();
const { isFullScreen, toggleFullScreen } = useFullscreen();
@@ -27,8 +30,10 @@ export default function NavigationMenu() {
const menuRef = useRef(null);
useClickOutside(menuRef, () => setShowMenu(false));
+ const { isOpen, onOpen, onClose } = useDisclosure();
+
const toggleMenu = () => setShowMenu((prev) => !prev);
- useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' });
+ useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen });
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
@@ -51,6 +56,7 @@ export default function NavigationMenu() {
const isKeyEnter = (event: KeyboardEvent) => event.key === 'Enter';
const handleFullscreen = () => toggleFullScreen();
const handleMirror = () => toggleMirror();
+
const showEditFormDrawer = () => {
searchParams.append('edit', 'true');
setSearchParams(searchParams);
@@ -58,6 +64,7 @@ export default function NavigationMenu() {
return createPortal(
{navigatorConstants.map((route) => (
@@ -116,3 +131,5 @@ export default function NavigationMenu() {
document.body,
);
}
+
+export default memo(NavigationMenu);
diff --git a/apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.module.scss b/apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.module.scss
new file mode 100644
index 000000000..6d48a1ba4
--- /dev/null
+++ b/apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.module.scss
@@ -0,0 +1,5 @@
+.modalBody {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
diff --git a/apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.tsx b/apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.tsx
new file mode 100644
index 000000000..10af3d969
--- /dev/null
+++ b/apps/client/src/common/components/navigation-menu/rename-client-modal/RenameClientModal.tsx
@@ -0,0 +1,73 @@
+import { useEffect, useState } from 'react';
+import {
+ Button,
+ Input,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalHeader,
+ ModalOverlay,
+} from '@chakra-ui/react';
+
+import { setClientName } from '../../../hooks/useSocket';
+import { useSocketClientName } from '../../../stores/connectionName';
+
+import style from './RenameClientModal.module.scss';
+
+interface RenameClientModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export default function RenameClientModal({ isOpen, onClose }: RenameClientModalProps) {
+ const { name: clientName, persistName } = useSocketClientName();
+ const [newName, setNewName] = useState(clientName);
+
+ useEffect(() => {
+ setNewName(clientName);
+ }, [isOpen, clientName]);
+
+ const handleRename = async () => {
+ if (newName) {
+ await setClientName(newName);
+ persistName(newName);
+ onClose();
+ }
+ };
+
+ return (
+
+
+
+ Rename client
+
+
+ setNewName(e.target.value)}
+ variant='ontime-filled-on-light'
+ />
+
+
+
+
+ );
+}
diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts
index 606b5e9a3..95be26d8f 100644
--- a/apps/client/src/common/hooks/useSocket.ts
+++ b/apps/client/src/common/hooks/useSocket.ts
@@ -105,3 +105,5 @@ export const useTimer = () => {
return useRuntimeStore(featureSelector, deepCompare);
};
+
+export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
diff --git a/apps/client/src/common/stores/connectionName.ts b/apps/client/src/common/stores/connectionName.ts
new file mode 100644
index 000000000..00aaf9eb6
--- /dev/null
+++ b/apps/client/src/common/stores/connectionName.ts
@@ -0,0 +1,26 @@
+import { useStore } from 'zustand';
+import { createStore } from 'zustand/vanilla';
+
+interface SocketClientNameState {
+ name?: string;
+ setName: (newValue: string) => void;
+ persistName: (newValue: string) => void;
+}
+
+const clientNameKey = 'ontime-client-name';
+
+function persistKeyToStorage(newValue: string) {
+ localStorage.setItem(clientNameKey, newValue);
+}
+
+export const socketClientName = createStore((set) => ({
+ name: localStorage.getItem(clientNameKey) ?? undefined,
+ setName: (newValue: string) => set(() => ({ name: newValue })),
+ persistName: (newValue: string) =>
+ set(() => {
+ persistKeyToStorage(newValue);
+ return { name: newValue };
+ }),
+}));
+
+export const useSocketClientName = () => useStore(socketClientName);
diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts
index ddeeb21cf..2a54fa16a 100644
--- a/apps/client/src/common/utils/socket.ts
+++ b/apps/client/src/common/utils/socket.ts
@@ -2,6 +2,7 @@ import { Log, RuntimeStore } from 'ontime-types';
import { RUNTIME, websocketUrl } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
+import { socketClientName } from '../stores/connectionName';
import { addLog } from '../stores/logger';
import { runtime } from '../stores/runtime';
@@ -11,13 +12,17 @@ const reconnectInterval = 1000;
export let shouldReconnect = true;
export let hasConnected = false;
export let reconnectAttempts = 0;
-export const connectSocket = () => {
+export const connectSocket = (preferredClientName?: string) => {
websocket = new WebSocket(websocketUrl);
websocket.onopen = () => {
clearTimeout(reconnectTimeout as NodeJS.Timeout);
hasConnected = true;
reconnectAttempts = 0;
+
+ if (preferredClientName) {
+ socketSendJson('set-client-name', preferredClientName);
+ }
};
websocket.onclose = () => {
@@ -49,6 +54,10 @@ export const connectSocket = () => {
// TODO: implement partial store updates
switch (type) {
+ case 'client-name': {
+ socketClientName.getState().setName(payload);
+ break;
+ }
case 'ontime-log': {
addLog(payload as Log);
break;
diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts
index d5fe7d000..d092c7cca 100644
--- a/apps/server/src/adapters/WebsocketAdapter.ts
+++ b/apps/server/src/adapters/WebsocketAdapter.ts
@@ -45,9 +45,9 @@ export class SocketServer implements IAdapter {
this.wss = new WebSocketServer({ path: '/ws', server });
this.wss.on('connection', (ws) => {
- const clientId = getRandomName();
+ let clientId = getRandomName();
this.clientIds.add(clientId);
- logger.info('RX', `${this.wss.clients.size} Connections with new: ${clientId}`);
+ logger.info('CLIENT', `${this.wss.clients.size} Connections with new: ${clientId}`);
// send store payload on connect
ws.send(
@@ -57,10 +57,17 @@ export class SocketServer implements IAdapter {
}),
);
+ ws.send(
+ JSON.stringify({
+ type: 'client-name',
+ payload: clientId,
+ }),
+ );
+
ws.on('error', console.error);
ws.on('close', () => {
- logger.info('RX', `${this.wss.clients.size} Connections with disconnected: ${clientId}`);
+ logger.info('CLIENT', `${this.wss.clients.size} Connections with disconnected: ${clientId}`);
this.clientIds.delete(clientId);
});
@@ -69,20 +76,37 @@ export class SocketServer implements IAdapter {
ws.close();
}
- // TODO: protocol specific stuff should be handled here
- // eg: rename-client
- // socket.on('rename-client', (newName) => {
- // if (newName) {
- // const previousName = this._clientNames[socket.id];
- // this._clientNames[socket.id] = newName;
- // this.info('CLIENT', `Client ${previousName} renamed to ${newName}`);
- // }
- // });
-
try {
const message = JSON.parse(data);
const { type, payload } = message;
+ if (type === 'get-client-name') {
+ ws.send(
+ JSON.stringify({
+ type: 'client-name',
+ payload: clientId,
+ }),
+ );
+ return;
+ }
+
+ if (type === 'set-client-name') {
+ if (payload) {
+ const previousName = clientId;
+ clientId = payload;
+ this.clientIds.delete(previousName);
+ this.clientIds.add(clientId);
+ logger.info('CLIENT', `Client ${previousName} renamed to ${clientId}`);
+ }
+ ws.send(
+ JSON.stringify({
+ type: 'client-name',
+ payload: clientId,
+ }),
+ );
+ return;
+ }
+
if (type === 'hello') {
ws.send('hi');
return;
@@ -95,6 +119,7 @@ export class SocketServer implements IAdapter {
return;
}
+ // Protocol specific stuff handled above
try {
const reply = dispatchFromAdapter(type, payload, 'ws');
if (reply) {
From e3444747bbf5a858c7126341686f4bced13bd4cc Mon Sep 17 00:00:00 2001
From: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Date: Fri, 21 Jul 2023 23:12:03 +0200
Subject: [PATCH 08/14] fix: prevent clipboard error in secure context (#462)
---
apps/client/src/common/utils/copyToClipboard.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/client/src/common/utils/copyToClipboard.ts b/apps/client/src/common/utils/copyToClipboard.ts
index 8423ab693..a7430690a 100644
--- a/apps/client/src/common/utils/copyToClipboard.ts
+++ b/apps/client/src/common/utils/copyToClipboard.ts
@@ -1,4 +1,4 @@
// we need to this as a promise because safari
export default async function copyToClipboard(text: string) {
- setTimeout(async () => await navigator.clipboard.writeText(text));
+ setTimeout(async () => await navigator.clipboard?.writeText(text));
}
From bee7a8dcb48096afaddcb2920f9f438ade7d9d18 Mon Sep 17 00:00:00 2001
From: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Date: Fri, 21 Jul 2023 23:12:15 +0200
Subject: [PATCH 09/14] fix: prevent typerror on fullscreen request (#461)
---
apps/client/index.html | 1 -
apps/client/src/common/hooks/useFullscreen.ts | 24 ++++++++++++-------
2 files changed, 15 insertions(+), 10 deletions(-)
diff --git a/apps/client/index.html b/apps/client/index.html
index 88135e8ca..c6a234f36 100644
--- a/apps/client/index.html
+++ b/apps/client/index.html
@@ -6,7 +6,6 @@
-
{
if (!document.fullscreenElement && !(document as WebkitDocument).webkitIsFullScreen) {
// Fullscreen mode is not active, so we can enter fullscreen mode
- if (document.documentElement.requestFullscreen) {
+ const element = document.documentElement;
+ if (element.requestFullscreen) {
// Standard fullscreen API is supported
- document.documentElement.requestFullscreen();
- // @ts-expect-error -- the whole casting dance is not worth it
- } else if (document.documentElement.webkitRequestFullscreen) {
+ element.requestFullscreen().catch(() => {
+ /* nothing to do */
+ });
+ } else if (element.webkitRequestFullscreen) {
// iOS Safari fullscreen API is supported
- // @ts-expect-error -- we know this is not undefined now
- (document.documentElement as WebkitDocument).webkitRequestFullscreen();
+ element.webkitRequestFullscreen().catch(() => {
+ /* nothing to do */
+ });
}
} else {
// Fullscreen mode is active, so we can exit fullscreen mode
if (document.exitFullscreen) {
// Standard fullscreen API is supported
- document.exitFullscreen();
+ document.exitFullscreen().catch((error) => {
+ console.error('Error while trying to exit fullscreen:', error);
+ });
} else if ((document as WebkitDocument).webkitExitFullscreen) {
// iOS Safari fullscreen API is supported
- // @ts-expect-error -- we know this is not undefined now
- (document as WebkitDocument).webkitExitFullscreen();
+ (document as WebkitDocument).webkitExitFullscreen().catch(() => {
+ /* nothing to do */
+ });
}
}
}, []);
From 3603b836f6d7efdabd7ec8c3530dfd50acbaa31d Mon Sep 17 00:00:00 2001
From: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Date: Sat, 22 Jul 2023 09:32:40 +0200
Subject: [PATCH 10/14] refactor: cuesheet v2 (#435)
* refactor: typescript migration
* chore: remove prop-types package
* refactor: file structure
* refactor: migrate ontime table to tanstack table 8
* refactor: rundown controller uses service as data source
* refactor: convert to typescript
* feat: caching store
* refactor: add delay values to rundown
* feat: toggle past visibility
* chore: update tests
* refactor: add extra fields to CSV
* style: show skipped events
* chore: add route to navigation menu
* style: allow jumping to bottom
* chore: add tests
---
DEVELOPMENT.md | 6 +
apps/client/.stylelintrc | 6 -
apps/client/package.json | 12 +-
apps/client/src/AppRouter.tsx | 8 +-
.../input/auto-text-area/AutoTextArea.tsx | 11 +-
.../navigation-menu/NavigationMenu.tsx | 5 +
.../common/context/TableSettingsContext.jsx | 79 --
apps/client/src/common/stores/logger.ts | 4 +-
.../common/utils/__tests__/dateConfig.test.js | 19 +-
apps/client/src/common/utils/dateConfig.ts | 7 +
apps/client/src/common/utils/eventsManager.ts | 13 +-
.../features/cuesheet/Cuesheet.module.scss | 157 +++
.../client/src/features/cuesheet/Cuesheet.tsx | 278 +++++
.../cuesheet/CuesheetWrapper.module.scss | 24 +
.../CuesheetWrapper.tsx} | 44 +-
.../features/cuesheet/ProtectedCuesheet.tsx | 11 +
.../__snapshots__/utils.test.js.snap | 8 +-
.../__tests__/utils.test.js | 2 +-
.../CuesheetTableHeader.module.scss | 119 ++
.../CuesheetTableHeader.tsx | 100 ++
.../CuesheetTableSettings.module.scss | 44 +
.../CuesheetTableSettings.tsx | 83 ++
.../src/features/cuesheet/cuesheetCols.tsx | 195 +++
.../cuesheetUtils.ts} | 35 +-
.../defaults.js => cuesheet/defaults.ts} | 6 +-
.../cuesheet/store/CuesheetSettings.tsx | 65 +
.../cuesheet/tableElements/EditableCell.tsx | 40 +
.../cuesheet/tableElements/PlaybackIcon.tsx | 36 +
.../cuesheet/tableElements/SortableCell.tsx | 48 +
apps/client/src/features/info/InfoLogger.tsx | 62 +-
...tSettings.tsx => CuesheetSettingsForm.tsx} | 2 +-
.../modals/settings-modal/SettingsModal.tsx | 4 +-
apps/client/src/features/rundown/Rundown.tsx | 9 +-
.../src/features/rundown/RundownEntry.tsx | 4 +-
.../client/src/features/table/OntimeTable.jsx | 245 ----
.../src/features/table/ProtectedTable.tsx | 14 -
.../src/features/table/Table.module.scss | 310 -----
.../client/src/features/table/TableHeader.jsx | 106 --
apps/client/src/features/table/columns.jsx | 104 --
.../table/tableElements/EditableCell.jsx | 56 -
.../table/tableElements/PlaybackIcon.tsx | 50 -
.../table/tableElements/SortableCell.jsx | 37 -
.../table/tableElements/TableSettings.jsx | 56 -
.../src/features/table/tableRows/BlockRow.jsx | 16 -
.../src/features/table/tableRows/DelayRow.jsx | 21 -
.../src/features/table/tableRows/EventRow.jsx | 46 -
.../{StudioClock.jsx => StudioClock.tsx} | 46 +-
apps/client/src/index.scss | 1 -
apps/client/src/theme/_main.scss | 132 --
apps/client/src/theme/_v2Styles.scss | 4 +-
apps/client/src/theme/ontimeTextInputs.ts | 7 +
apps/client/src/theme/theme.ts | 2 +
apps/server/package.json | 1 +
apps/server/src/adapters/OscAdapter.ts | 9 +-
apps/server/src/adapters/WebsocketAdapter.ts | 10 +-
apps/server/src/app.ts | 12 +-
.../src/classes/data-provider/DataProvider.ts | 74 +-
.../src/classes/event-loader/EventLoader.ts | 10 +-
.../src/controllers/ontimeController.ts | 10 +-
...ackController.js => playbackController.ts} | 0
...downController.js => rundownController.ts} | 14 +-
...idate.js => rundownController.validate.ts} | 0
apps/server/src/models/eventsDefinition.ts | 2 +-
apps/server/src/routes/rundownRouter.ts | 4 -
apps/server/src/services/PlaybackService.ts | 40 +-
.../{ => rundown-service}/RundownService.ts | 206 ++--
.../__tests__/RundownService.test.ts | 319 +++++
.../__tests__/delayedRundown.utils.test.ts | 445 +++++++
.../rundown-service/delayedRundown.utils.ts | 261 ++++
.../src/stores/__tests__/cachingStore.test.ts | 67 +
apps/server/src/stores/cachingStore.ts | 47 +
.../src/utils/__tests__/arrayUtils.tests.ts | 54 +
apps/server/src/utils/arrayUtils.ts | 50 +
e2e/tests/features/202-cuesheet.spec.ts | 62 +
.../src/definitions/core/OntimeEvent.type.ts | 5 +-
.../src/definitions/core/Rundown.type.ts | 5 +-
.../src/definitions/runtime/Logger.type.ts | 9 +
packages/types/src/index.ts | 7 +-
.../utils/src/date-utils/millisToString.ts | 2 +-
pnpm-lock.yaml | 1098 +++++------------
80 files changed, 3188 insertions(+), 2434 deletions(-)
delete mode 100644 apps/client/.stylelintrc
delete mode 100644 apps/client/src/common/context/TableSettingsContext.jsx
create mode 100644 apps/client/src/features/cuesheet/Cuesheet.module.scss
create mode 100644 apps/client/src/features/cuesheet/Cuesheet.tsx
create mode 100644 apps/client/src/features/cuesheet/CuesheetWrapper.module.scss
rename apps/client/src/features/{table/TableWrapper.jsx => cuesheet/CuesheetWrapper.tsx} (62%)
create mode 100644 apps/client/src/features/cuesheet/ProtectedCuesheet.tsx
rename apps/client/src/features/{table => cuesheet}/__tests__/__snapshots__/utils.test.js.snap (88%)
rename apps/client/src/features/{table => cuesheet}/__tests__/utils.test.js (97%)
create mode 100644 apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.module.scss
create mode 100644 apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx
create mode 100644 apps/client/src/features/cuesheet/cuesheet-table-settings/CuesheetTableSettings.module.scss
create mode 100644 apps/client/src/features/cuesheet/cuesheet-table-settings/CuesheetTableSettings.tsx
create mode 100644 apps/client/src/features/cuesheet/cuesheetCols.tsx
rename apps/client/src/features/{table/tableUtils.js => cuesheet/cuesheetUtils.ts} (68%)
rename apps/client/src/features/{table/defaults.js => cuesheet/defaults.ts} (69%)
create mode 100644 apps/client/src/features/cuesheet/store/CuesheetSettings.tsx
create mode 100644 apps/client/src/features/cuesheet/tableElements/EditableCell.tsx
create mode 100644 apps/client/src/features/cuesheet/tableElements/PlaybackIcon.tsx
create mode 100644 apps/client/src/features/cuesheet/tableElements/SortableCell.tsx
rename apps/client/src/features/modals/settings-modal/{CuesheetSettings.tsx => CuesheetSettingsForm.tsx} (99%)
delete mode 100644 apps/client/src/features/table/OntimeTable.jsx
delete mode 100644 apps/client/src/features/table/ProtectedTable.tsx
delete mode 100644 apps/client/src/features/table/Table.module.scss
delete mode 100644 apps/client/src/features/table/TableHeader.jsx
delete mode 100644 apps/client/src/features/table/columns.jsx
delete mode 100644 apps/client/src/features/table/tableElements/EditableCell.jsx
delete mode 100644 apps/client/src/features/table/tableElements/PlaybackIcon.tsx
delete mode 100644 apps/client/src/features/table/tableElements/SortableCell.jsx
delete mode 100644 apps/client/src/features/table/tableElements/TableSettings.jsx
delete mode 100644 apps/client/src/features/table/tableRows/BlockRow.jsx
delete mode 100644 apps/client/src/features/table/tableRows/DelayRow.jsx
delete mode 100644 apps/client/src/features/table/tableRows/EventRow.jsx
rename apps/client/src/features/viewers/studio/{StudioClock.jsx => StudioClock.tsx} (79%)
delete mode 100644 apps/client/src/theme/_main.scss
rename apps/server/src/controllers/{playbackController.js => playbackController.ts} (100%)
rename apps/server/src/controllers/{rundownController.js => rundownController.ts} (85%)
rename apps/server/src/controllers/{rundownController.validate.js => rundownController.validate.ts} (100%)
rename apps/server/src/services/{ => rundown-service}/RundownService.ts (54%)
create mode 100644 apps/server/src/services/rundown-service/__tests__/RundownService.test.ts
create mode 100644 apps/server/src/services/rundown-service/__tests__/delayedRundown.utils.test.ts
create mode 100644 apps/server/src/services/rundown-service/delayedRundown.utils.ts
create mode 100644 apps/server/src/stores/__tests__/cachingStore.test.ts
create mode 100644 apps/server/src/stores/cachingStore.ts
create mode 100644 apps/server/src/utils/__tests__/arrayUtils.tests.ts
create mode 100644 apps/server/src/utils/arrayUtils.ts
create mode 100644 e2e/tests/features/202-cuesheet.spec.ts
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 2c19cae2d..6cf8f2165 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -20,6 +20,12 @@ From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev`
+### Debugging backend
+To debug backend code in Node.js:
+- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
+- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server applications.
+- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect`.
+
## TESTING
Generally we have 2 types of tests.
diff --git a/apps/client/.stylelintrc b/apps/client/.stylelintrc
deleted file mode 100644
index 5a9002904..000000000
--- a/apps/client/.stylelintrc
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "extends": [
- "stylelint-config-standard-scss",
- "stylelint-config-prettier"
- ]
-}
diff --git a/apps/client/package.json b/apps/client/package.json
index 29e25934e..d71fd4ab9 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -3,7 +3,7 @@
"version": "2.0.9",
"private": true,
"dependencies": {
- "@chakra-ui/react": "^2.5.5",
+ "@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
@@ -14,7 +14,8 @@
"@sentry/tracing": "^7.46.0",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.29.0",
- "autosize": "^5.0.2",
+ "@tanstack/react-table": "^8.9.2",
+ "autosize": "^6.0.1",
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.2.3",
@@ -27,7 +28,6 @@
"react-hook-form": "^7.43.5",
"react-qr-code": "^2.0.11",
"react-router-dom": "^6.3.0",
- "react-table": "^7.7.0",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1",
"zustand": "^4.3.6"
@@ -40,7 +40,6 @@
"build:local": "cross-env NODE_ENV=local vite build",
"build:docker": "vite build",
"lint": "eslint .",
- "stylelint": "npx stylelint \"**/*.scss\"\n",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
@@ -65,7 +64,6 @@
"@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3",
"@types/luxon": "^3.2.0",
- "@types/prop-types": "^15.7.5",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
@@ -84,11 +82,7 @@
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "^2.8.3",
- "prop-types": "^15.8.1",
"sass": "^1.57.1",
- "stylelint": "^14.16.1",
- "stylelint-config-prettier": "^9.0.4",
- "stylelint-config-standard-scss": "^6.1.0",
"typescript": "^4.9.4",
"vite": "^4.3.1",
"vite-plugin-compression2": "^0.9.0",
diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx
index a71c3e945..b8e42c73a 100644
--- a/apps/client/src/AppRouter.tsx
+++ b/apps/client/src/AppRouter.tsx
@@ -5,7 +5,7 @@ import useAliases from './common/hooks-query/useAliases';
import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
-const Table = lazy(() => import('./features/table/ProtectedTable'));
+const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
@@ -76,9 +76,9 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/}
} />
- } />
- } />
- } />
+ } />
+ } />
+ } />
{/*/!* Protected Routes - Elements *!/*/}
{
- const { isDark, ...rest } = props;
+export const AutoTextArea = (props: TextareaProps) => {
const ref = useRef(null);
useEffect(() => {
@@ -26,8 +21,8 @@ export const AutoTextArea = (props: AutoTextAreaProps) => {
resize='none'
ref={ref}
transition='height none'
- variant={isDark ? 'ontime-filled' : 'ontime-filled-on-light'}
- {...rest}
+ variant='ontime-transparent'
+ {...props}
/>
);
};
diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx
index 461cc62ce..23dfbcf79 100644
--- a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx
+++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx
@@ -112,6 +112,11 @@ function NavigationMenu() {
+
+ Cuesheet
+
+
+
{navigatorConstants.map((route) => (
undefined,
- toggleTheme: () => undefined,
- toggleFollow: () => undefined,
-});
-
-export const TableSettingsProvider = ({ children }) => {
- const [theme, setTheme] = useLocalStorage('table-color-theme', 'dark');
- const [followSelected, setFollowSelected] = useLocalStorage('table-follow-selected', false);
- const [showSettings, setShowSettings] = useState(false);
-
- /**
- * @description Toggles the current value of dark mode
- * @param {string} val - 'light' or 'dark'
- */
- const toggleTheme = useCallback(
- (val) => {
- if (val === undefined) {
- setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
- } else {
- setTheme(val);
- }
- },
- [setTheme]
- );
-
- /**
- * @description Toggles visibility state for settings
- * @param {boolean} val - whether the settings window is visible
- */
- const toggleSettings = useCallback(
- (val) => {
- if (val === undefined) {
- setShowSettings((prev) => !prev);
- } else {
- setShowSettings(val);
- }
- },
- [setShowSettings]
- );
-
- /**
- * @description Toggles follow option
- * @param {boolean} val - whether the window follows selected event
- */
- const toggleFollow = useCallback(
- (val) => {
- if (val === undefined) {
- setFollowSelected((prev) => !prev);
- } else {
- setFollowSelected(val);
- }
- },
- [setFollowSelected]
- );
-
- return (
-
- {children}
-
- );
-};
diff --git a/apps/client/src/common/stores/logger.ts b/apps/client/src/common/stores/logger.ts
index 9587ce7c8..fb60d07c4 100644
--- a/apps/client/src/common/stores/logger.ts
+++ b/apps/client/src/common/stores/logger.ts
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
-import { Log, LogLevel } from 'ontime-types';
+import { Log, LogLevel, LogOrigin } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
@@ -34,7 +34,7 @@ export function useEmitLog() {
const _emit = useCallback((text: string, level: LogLevel) => {
const log = {
id: generateId(),
- origin: 'CLIENT',
+ origin: LogOrigin.Client,
time: millisToString(nowInMillis()),
level,
text,
diff --git a/apps/client/src/common/utils/__tests__/dateConfig.test.js b/apps/client/src/common/utils/__tests__/dateConfig.test.js
index eba30d62f..34b889021 100644
--- a/apps/client/src/common/utils/__tests__/dateConfig.test.js
+++ b/apps/client/src/common/utils/__tests__/dateConfig.test.js
@@ -1,4 +1,21 @@
-import { forgivingStringToMillis, millisToDelayString, millisToMinutes, millisToSeconds } from '../dateConfig';
+import {
+ forgivingStringToMillis,
+ millisToDelayString,
+ millisToMinutes,
+ millisToSeconds,
+ secondsInMillis,
+} from '../dateConfig';
+
+describe('test secondsInMillis function', () => {
+ it('return 0 if value is null', () => {
+ expect(secondsInMillis(null)).toBe(0);
+ });
+ it('returns the seconds value of a millis date', () => {
+ const date = 1686255053619; // Thu Jun 08 2023 20:10:53
+ const seconds = secondsInMillis(date);
+ expect(seconds).toBe(53);
+ });
+});
describe('test millisToSeconds function', () => {
it('test with null values', () => {
diff --git a/apps/client/src/common/utils/dateConfig.ts b/apps/client/src/common/utils/dateConfig.ts
index e001d9836..b976c8664 100644
--- a/apps/client/src/common/utils/dateConfig.ts
+++ b/apps/client/src/common/utils/dateConfig.ts
@@ -5,6 +5,13 @@ import { mth, mtm, mts } from './timeConstants';
export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
+export function secondsInMillis(millis: number | null) {
+ if (!millis) {
+ return 0;
+ }
+ return Math.floor((millis % mtm) / mts);
+}
+
/**
* @description Converts milliseconds to seconds
* @param {number | null} millis - time in seconds
diff --git a/apps/client/src/common/utils/eventsManager.ts b/apps/client/src/common/utils/eventsManager.ts
index 9f8fbbc27..1edcf9172 100644
--- a/apps/client/src/common/utils/eventsManager.ts
+++ b/apps/client/src/common/utils/eventsManager.ts
@@ -41,7 +41,7 @@ export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[]
* @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum objects
*/
-export const trimRundown = (rundown: OntimeRundownEntry[], selectedId: string, limit: number) => {
+export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
if (rundown == null) return [];
const BEFORE = 2;
@@ -78,7 +78,7 @@ export const formatEventList = (
selectedId: string,
nextId: string,
options: FormatEventListOptionsProp,
-) => {
+): ScheduleEvent[] => {
if (rundown == null) return [];
const { showEnd = false } = options;
@@ -103,6 +103,15 @@ export const formatEventList = (
return formattedEvents;
};
+export type ScheduleEvent = {
+ id: string;
+ time: string;
+ title: string;
+ isNow: boolean;
+ isNext: boolean;
+ colour: string;
+};
+
/**
* @description Creates a safe duplicate of an event
* @param {object} event
diff --git a/apps/client/src/features/cuesheet/Cuesheet.module.scss b/apps/client/src/features/cuesheet/Cuesheet.module.scss
new file mode 100644
index 000000000..de94398b9
--- /dev/null
+++ b/apps/client/src/features/cuesheet/Cuesheet.module.scss
@@ -0,0 +1,157 @@
+@use '../../theme/ontimeColours' as *;
+@use '../../theme/v2Styles' as *;
+
+$table-font-size: calc(1rem - 2px);
+$table-header-font-size: calc(1rem - 3px);
+
+@mixin ellipsis-overflow() {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.cuesheetContainer {
+ grid-area: table;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ padding-bottom: 640px; // allow focus to reach last elements
+}
+
+.cuesheet {
+ font-size: $table-font-size;
+ font-weight: 400;
+
+ tr {
+ display: flex;
+ }
+
+ th, td {
+ margin: 1px;
+ font-weight: inherit;
+ font-size: inherit;
+ text-align: left;
+ position: relative;
+ @include ellipsis-overflow;
+ }
+}
+
+.tableHeader,
+.eventRow {
+ .indexColumn {
+ min-width: 2rem;
+ text-align: right;
+ font-weight: 400;
+ }
+}
+
+.tableHeader {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ background-color: $gray-1300;
+
+ font-size: $table-header-font-size;
+ color: $gray-700;
+ font-weight: 400;
+}
+
+.eventRow {
+ vertical-align: top;
+
+ td {
+ background-color: $gray-1250;
+ border-radius: 2px;
+ padding: 0.25rem;
+ }
+
+ &.skip {
+ text-decoration: line-through;
+ opacity: $opacity-disabled !important; // fighting inline styles
+ }
+}
+
+.blockRow {
+ width: 100%;
+ background-color: $gray-1350;
+ font-size: 1rem;
+ height: 2.5rem;
+
+ td {
+ align-self: flex-end;
+ position: sticky;
+ left: 1rem;
+ padding: 0.25rem 0;
+ }
+}
+
+.delayRow {
+ width: 100%;
+ color: $ontime-delay-text;
+
+ td {
+ position: sticky;
+ left: 47.5%; // center of the screen, ish
+ padding: 0.5rem 0;
+ }
+}
+
+.check {
+ font-size: 1.5rem;
+ margin: 0 auto;
+}
+
+.time {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+
+ > * {
+ @include ellipsis-overflow;
+ }
+}
+
+.delaySymbol {
+ svg {
+ font-size: 1.5rem;
+ color: $ontime-delay;
+ margin: 0 auto;
+ }
+}
+
+.delayedTime {
+ color: $ontime-delay-text;
+ font-size: calc(1rem - 2px);
+}
+
+.resizer {
+ cursor: col-resize;
+ opacity: 0;
+ display: inline-block;
+ width: 3px;
+ height: 100%;
+ position: absolute;
+ right: 0;
+ top: 0;
+ transform: translateX(50%);
+ background-color: $action-blue;
+
+ user-select: none;
+ touch-action: none;
+
+ transition-duration: $transition-time-action;
+ transition-property: width, background-color;
+
+ &:hover {
+ opacity: $opacity-disabled;
+ width: 6px;
+ }
+
+ &.isResizing {
+ opacity: 1;
+ width: 6px;
+ background-color: $action-blue;
+ }
+}
diff --git a/apps/client/src/features/cuesheet/Cuesheet.tsx b/apps/client/src/features/cuesheet/Cuesheet.tsx
new file mode 100644
index 000000000..bd863aedc
--- /dev/null
+++ b/apps/client/src/features/cuesheet/Cuesheet.tsx
@@ -0,0 +1,278 @@
+import { MutableRefObject, useEffect, useRef } from 'react';
+import { Tooltip } from '@chakra-ui/react';
+import {
+ closestCenter,
+ DndContext,
+ DragEndEvent,
+ KeyboardSensor,
+ PointerSensor,
+ TouchSensor,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
+import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
+import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
+import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
+
+import { useLocalStorage } from '../../common/hooks/useLocalStorage';
+import { millisToDelayString } from '../../common/utils/dateConfig';
+import { getAccessibleColour } from '../../common/utils/styleUtils';
+import { tooltipDelayFast } from '../../ontimeConfig';
+
+import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
+import { useCuesheetSettings } from './store/CuesheetSettings';
+import { SortableCell } from './tableElements/SortableCell';
+import { initialColumnOrder } from './cuesheetCols';
+
+import style from './Cuesheet.module.scss';
+
+const pastOpacity = '0.2';
+
+interface CuesheetProps {
+ data: OntimeRundown;
+ columns: ColumnDef[];
+ handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
+ selectedId: string | null;
+}
+
+export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
+ const followSelected = useCuesheetSettings((state) => state.followSelected);
+ const showSettings = useCuesheetSettings((state) => state.showSettings);
+ const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
+ const showPrevious = useCuesheetSettings((state) => state.showPrevious);
+
+ const [columnVisibility, setColumnVisibility] = useLocalStorage('table-hidden', {});
+ const [columnOrder, saveColumnOrder] = useLocalStorage('table-order', initialColumnOrder);
+ const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
+
+ const selectedRef = useRef(null);
+
+ const table = useReactTable({
+ data,
+ columns,
+ columnResizeMode: 'onChange',
+ state: {
+ columnOrder,
+ columnVisibility,
+ columnSizing,
+ },
+ meta: {
+ handleUpdate,
+ },
+ onColumnVisibilityChange: setColumnVisibility,
+ onColumnSizingChange: setColumnSizing,
+ getCoreRowModel: getCoreRowModel(),
+ });
+ const tableContainerRef = useRef(null);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ delay: 100,
+ tolerance: 50,
+ },
+ }),
+ useSensor(TouchSensor, {
+ activationConstraint: {
+ delay: 100,
+ tolerance: 50,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ // when selection moves, view should follow
+ useEffect(() => {
+ function scrollToComponent(
+ componentRef: MutableRefObject,
+ scrollRef: MutableRefObject,
+ ) {
+ const componentRect = componentRef.current.getBoundingClientRect();
+ const scrollRect = scrollRef.current.getBoundingClientRect();
+ const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
+ scrollRef.current.scrollTo({ top, behavior: 'smooth' });
+ }
+
+ if (!followSelected) {
+ return;
+ }
+
+ if (selectedRef.current && tableContainerRef.current) {
+ // Use requestAnimationFrame to ensure the component is fully loaded
+ window.requestAnimationFrame(() => {
+ scrollToComponent(
+ selectedRef as MutableRefObject,
+ tableContainerRef as MutableRefObject,
+ );
+ });
+ }
+ // eslint-disable-next-line -- the prompt seems incorrect, we need the refs
+ }, [selectedRef.current, tableContainerRef.current, followSelected]);
+
+ 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;
+
+ // get index of from
+ const fromIndex = columnOrder.indexOf(active.id as string);
+
+ // get index of to
+ const toIndex = columnOrder.indexOf(over.id as string);
+
+ if (toIndex === -1) {
+ return;
+ }
+
+ const reorderedCols = [...columnOrder];
+ const reorderedItem = reorderedCols.splice(fromIndex, 1);
+ reorderedCols.splice(toIndex, 0, reorderedItem[0]);
+
+ saveColumnOrder(reorderedCols);
+ };
+
+ const resetColumnOrder = () => {
+ saveColumnOrder(initialColumnOrder);
+ };
+
+ const setAllVisible = () => {
+ table.toggleAllColumnsVisible(true);
+ };
+
+ const resetColumnResizing = () => {
+ setColumnSizing({});
+ };
+
+ let eventIndex = 0;
+ let isPast = Boolean(selectedId);
+
+ return (
+ <>
+ {showSettings && (
+
+ )}
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => {
+ const key = headerGroup.id;
+
+ return (
+
+
+ |
+
+ #
+
+ |
+
+ {headerGroup.headers.map((header) => {
+ const width = header.getSize();
+
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+
+ );
+ })}
+
+
+
+ );
+ })}
+
+
+
+ {table.getRowModel().rows.map((row) => {
+ const entryType = row.original.type as SupportedEvent;
+ const key = row.original.id;
+ const isSelected = selectedId === key;
+ if (isSelected) {
+ isPast = false;
+ }
+
+ if (entryType === SupportedEvent.Block) {
+ const title = (row.original as OntimeBlock).title;
+
+ return (
+
+ | {title} |
+
+ );
+ }
+ if (entryType === SupportedEvent.Delay) {
+ const delayVal = (row.original as OntimeDelay).duration;
+
+ if (!showDelayBlock || delayVal === 0) {
+ return null;
+ }
+
+ const delayTime = millisToDelayString(delayVal);
+ return (
+
+ | {delayTime} |
+
+ );
+ }
+ if (entryType === SupportedEvent.Event) {
+ eventIndex++;
+ const isSelected = key === selectedId;
+ if (isSelected) {
+ isPast = false;
+ }
+
+ if (isPast && !showPrevious) {
+ return null;
+ }
+
+ const bgFallback = 'transparent';
+ const bgColour = (row.original as OntimeEvent).colour || bgFallback;
+ const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
+ const isSkipped = (row.original as OntimeEvent).skip;
+
+ let rowBgColour: string | undefined;
+ if (row.original.id === selectedId) {
+ rowBgColour = '#D20300'; // $red-700
+ }
+ return (
+
+ |
+ {eventIndex}
+ |
+ {row.getVisibleCells().map((cell) => {
+ return (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ |
+ );
+ })}
+
+ );
+ }
+
+ // currently there is no scenario where entryType is not handled above, either way...
+ return null;
+ })}
+
+
+
+ >
+ );
+}
diff --git a/apps/client/src/features/cuesheet/CuesheetWrapper.module.scss b/apps/client/src/features/cuesheet/CuesheetWrapper.module.scss
new file mode 100644
index 000000000..851ddadef
--- /dev/null
+++ b/apps/client/src/features/cuesheet/CuesheetWrapper.module.scss
@@ -0,0 +1,24 @@
+@use '../../theme/v2Styles' as *;
+@use '../../theme/ontimeColours' as *;
+
+.tableWrapper {
+ width: 100%;
+ height: 100vh;
+ padding: 1rem;
+
+ display: grid;
+ grid-template-rows: auto auto 1fr;
+ grid-template-areas:
+ 'header'
+ 'settings'
+ 'table';
+ gap: 1rem;
+
+ background-color: $gray-1300;
+ color: white;
+
+ & > * {
+ border: 1px solid $white-10;
+ border-radius: 4px;
+ }
+}
diff --git a/apps/client/src/features/table/TableWrapper.jsx b/apps/client/src/features/cuesheet/CuesheetWrapper.tsx
similarity index 62%
rename from apps/client/src/features/table/TableWrapper.jsx
rename to apps/client/src/features/cuesheet/CuesheetWrapper.tsx
index ddfed6a10..1f1330df3 100644
--- a/apps/client/src/features/table/TableWrapper.jsx
+++ b/apps/client/src/features/cuesheet/CuesheetWrapper.tsx
@@ -1,23 +1,25 @@
-import { useCallback, useContext, useEffect } from 'react';
+import { useCallback, useEffect, useMemo } from 'react';
+import { EventData, OntimeRundownEntry } from 'ontime-types';
-import { TableSettingsContext } from '../../common/context/TableSettingsContext';
+import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket';
import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
-import OntimeTable from './OntimeTable';
-import TableHeader from './TableHeader';
-import { makeCSV, makeTable } from './tableUtils';
+import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
+import Cuesheet from './Cuesheet';
+import { makeCuesheetColumns } from './cuesheetCols';
+import { makeCSV, makeTable } from './cuesheetUtils';
-import style from './Table.module.scss';
+import styles from './CuesheetWrapper.module.scss';
-export default function TableWrapper() {
+export default function CuesheetWrapper() {
const { data: rundown } = useRundown();
const { data: userFields } = useUserFields();
const { updateEvent } = useEventAction();
const featureData = useCuesheet();
- const { theme } = useContext(TableSettingsContext);
+ const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
// Set window title
useEffect(() => {
@@ -25,14 +27,18 @@ export default function TableWrapper() {
}, []);
const handleUpdate = useCallback(
- async (rowIndex, accessor, payload) => {
+ async (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => {
+ if (!rundown) {
+ return;
+ }
+
if (rowIndex == null || accessor == null || payload == null) {
return;
}
// check if value is the same
const event = rundown[rowIndex];
- if (event == null) {
+ if (!event) {
return;
}
@@ -63,7 +69,7 @@ export default function TableWrapper() {
);
const exportHandler = useCallback(
- (headerData) => {
+ (headerData: EventData) => {
if (!headerData || !rundown || !userFields) {
return;
}
@@ -80,18 +86,14 @@ export default function TableWrapper() {
[rundown, userFields],
);
- if (typeof rundown === 'undefined' || typeof userFields === 'undefined') {
- return loading...;
+ if (!rundown || !userFields) {
+ return ;
}
+
return (
-
-
-
+
+
+
);
}
diff --git a/apps/client/src/features/cuesheet/ProtectedCuesheet.tsx b/apps/client/src/features/cuesheet/ProtectedCuesheet.tsx
new file mode 100644
index 000000000..6a31c1742
--- /dev/null
+++ b/apps/client/src/features/cuesheet/ProtectedCuesheet.tsx
@@ -0,0 +1,11 @@
+import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
+
+import CuesheetWrapper from './CuesheetWrapper';
+
+export default function ProtectedCuesheet() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/client/src/features/table/__tests__/__snapshots__/utils.test.js.snap b/apps/client/src/features/cuesheet/__tests__/__snapshots__/utils.test.js.snap
similarity index 88%
rename from apps/client/src/features/table/__tests__/__snapshots__/utils.test.js.snap
rename to apps/client/src/features/cuesheet/__tests__/__snapshots__/utils.test.js.snap
index b0c7aefe6..399b5867d 100644
--- a/apps/client/src/features/table/__tests__/__snapshots__/utils.test.js.snap
+++ b/apps/client/src/features/cuesheet/__tests__/__snapshots__/utils.test.js.snap
@@ -25,8 +25,11 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
"Presenter Name",
"Event Subtitle",
"Is Public? (x)",
- "Notes",
+ "Note",
"Colour",
+ "End Action",
+ "Timer Type",
+ "Skip?",
"user0:test",
],
[
@@ -38,6 +41,9 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
"x",
"",
"",
+ "",
+ "",
+ "",
"test",
"test",
"",
diff --git a/apps/client/src/features/table/__tests__/utils.test.js b/apps/client/src/features/cuesheet/__tests__/utils.test.js
similarity index 97%
rename from apps/client/src/features/table/__tests__/utils.test.js
rename to apps/client/src/features/cuesheet/__tests__/utils.test.js
index e7341053b..70c68c0b9 100644
--- a/apps/client/src/features/table/__tests__/utils.test.js
+++ b/apps/client/src/features/cuesheet/__tests__/utils.test.js
@@ -1,4 +1,4 @@
-import { makeCSV, makeTable, parseField } from '../tableUtils';
+import { makeCSV, makeTable, parseField } from '../cuesheetUtils';
describe('parseField()', () => {
it('returns a string from given millis on timeStart and TimeEnd', () => {
diff --git a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.module.scss b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.module.scss
new file mode 100644
index 000000000..f5b988dee
--- /dev/null
+++ b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.module.scss
@@ -0,0 +1,119 @@
+@use '../../../theme/ontimeColours' as *;
+@use '../../../theme/v2Styles' as *;
+
+$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;
+ font-size: 1.125rem;
+ color: $label-colour;
+ height: 100%;
+
+ .actionIcon {
+ cursor: pointer;
+
+ &:hover {
+ color: $active-colour;
+ }
+
+ &.enabled {
+ color: $active-indicator;
+ }
+ }
+}
+
+@media (min-width: 1200px) {
+ // in large screens we want to space the buttons
+ .headerActions {
+ padding-left: 10vw;
+ }
+}
diff --git a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx
new file mode 100644
index 000000000..52e3cda2b
--- /dev/null
+++ b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx
@@ -0,0 +1,100 @@
+import { Tooltip } from '@chakra-ui/react';
+import { IoContract } from '@react-icons/all-files/io5/IoContract';
+import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
+import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
+import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
+import { EventData, Playback } from 'ontime-types';
+import { formatDisplay } from 'ontime-utils';
+
+import useFullscreen from '../../../common/hooks/useFullscreen';
+import { useTimer } from '../../../common/hooks/useSocket';
+import useEventData from '../../../common/hooks-query/useEventData';
+import { formatTime } from '../../../common/utils/time';
+import { tooltipDelayFast } from '../../../ontimeConfig';
+import { useCuesheetSettings } from '../store/CuesheetSettings';
+import PlaybackIcon from '../tableElements/PlaybackIcon';
+
+import style from './CuesheetTableHeader.module.scss';
+
+interface CuesheetTableHeaderProps {
+ handleCSVExport: (headerData: EventData) => void;
+ featureData: {
+ playback: Playback;
+ selectedEventIndex: number | null;
+ numEvents: number;
+ titleNow: string | null;
+ };
+}
+
+export default function CuesheetTableHeader({ handleCSVExport, featureData }: CuesheetTableHeaderProps) {
+ const followSelected = useCuesheetSettings((state) => state.followSelected);
+ const showSettings = useCuesheetSettings((state) => state.showSettings);
+ const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
+ const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
+ const timer = useTimer();
+ const { isFullScreen, toggleFullScreen } = useFullscreen();
+ const { data: event } = useEventData();
+
+ const exportCsv = () => {
+ if (event) {
+ handleCSVExport(event);
+ }
+ };
+
+ const selected = !featureData.numEvents
+ ? 'No events'
+ : `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : '-'}/${
+ featureData.numEvents ? featureData.numEvents : '-'
+ }`;
+
+ // prepare presentation variables
+ const isOvertime = (timer.current ?? 0) < 0;
+ const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
+ const timeNow = formatTime(timer.clock, {
+ showSeconds: true,
+ format: 'hh:mm:ss a',
+ });
+
+ return (
+
+
+
{event?.title || '-'}
+
{featureData?.titleNow || '-'}
+
+
+
+
Running Timer
+
{timerNow}
+
+
+
+
+ toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
+
+
+
+
+ toggleSettings()} className={`${style.actionIcon} ${showSettings ? style.enabled : ''}`}>
+
+
+
+
+ toggleFullScreen()} className={style.actionIcon}>
+ {isFullScreen ? : }
+
+
+
+
+ CSV
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/cuesheet/cuesheet-table-settings/CuesheetTableSettings.module.scss b/apps/client/src/features/cuesheet/cuesheet-table-settings/CuesheetTableSettings.module.scss
new file mode 100644
index 000000000..8ed07c661
--- /dev/null
+++ b/apps/client/src/features/cuesheet/cuesheet-table-settings/CuesheetTableSettings.module.scss
@@ -0,0 +1,44 @@
+@use '../../../theme/v2Styles' as *;
+@use '../../../theme/ontimeColours' as *;
+
+.tableSettings {
+ grid-area: settings;
+ padding: 1rem;
+ background-color: $ui-black;
+ display: flex;
+ font-size: $inner-section-text-size;
+}
+
+.leftPanel {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ gap: 0.5rem;
+}
+
+.sectionTitle {
+ color: $gray-700;
+ text-transform: uppercase;
+}
+
+.options {
+ display: flex;
+ flex-wrap: wrap;
+ column-gap: 1rem;
+ row-gap: 0.25em;
+}
+
+.option {
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.rightPanel {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+
+ padding-left: 2rem;
+}
diff --git a/apps/client/src/features/cuesheet/cuesheet-table-settings/CuesheetTableSettings.tsx b/apps/client/src/features/cuesheet/cuesheet-table-settings/CuesheetTableSettings.tsx
new file mode 100644
index 000000000..accd5ce9c
--- /dev/null
+++ b/apps/client/src/features/cuesheet/cuesheet-table-settings/CuesheetTableSettings.tsx
@@ -0,0 +1,83 @@
+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
[];
+ handleResetResizing: () => void;
+ handleResetReordering: () => void;
+ handleClearToggles: () => void;
+}
+
+export default function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
+ const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
+ const showPrevious = useCuesheetSettings((state) => state.showPrevious);
+ const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
+ const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
+ const toggleDelayVisibility = useCuesheetSettings((state) => state.toggleDelayVisibility);
+ const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
+ const toggleDelayedTimes = useCuesheetSettings((state) => state.toggleDelayedTimes);
+
+ return (
+
+
+
Toggle column visibility
+
+ {columns.map((column) => {
+ const columnHeader = column.columnDef.header;
+ const visible = column.getIsVisible();
+ return (
+
+ );
+ })}
+
+
Table Options
+
+
+
+
Delay Flow
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/cuesheet/cuesheetCols.tsx b/apps/client/src/features/cuesheet/cuesheetCols.tsx
new file mode 100644
index 000000000..ae826d329
--- /dev/null
+++ b/apps/client/src/features/cuesheet/cuesheetCols.tsx
@@ -0,0 +1,195 @@
+import { useCallback } from 'react';
+import { Tooltip } from '@chakra-ui/react';
+import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
+import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
+import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
+import { CellContext, ColumnDef } from '@tanstack/react-table';
+import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
+import { millisToString } from 'ontime-utils';
+
+import { millisToDelayString } from '../../common/utils/dateConfig';
+import { tooltipDelayFast } from '../../ontimeConfig';
+
+import { useCuesheetSettings } from './store/CuesheetSettings';
+import EditableCell from './tableElements/EditableCell';
+
+import style from './Cuesheet.module.scss';
+
+function makePublic(row: CellContext) {
+ const cellValue = row.getValue();
+ return cellValue ? : '';
+}
+
+function DelayIndicator(props: { delayValue: number }) {
+ const { delayValue } = props;
+ if (delayValue < 0) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (delayValue > 0) {
+ return (
+
+
+
+
+
+ );
+ }
+ return null;
+}
+
+function MakeTimer({ getValue, row: { original } }: CellContext) {
+ const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
+ const cellValue = (getValue() as number | null) ?? 0;
+ const delayValue = (original as OntimeEvent)?.delay ?? 0;
+
+ console.log(delayValue)
+
+ return (
+
+
+ {millisToString(cellValue)}
+ {(delayValue !== 0 && showDelayedTimes) && {` ${millisToString(cellValue + delayValue)}`}}
+
+ );
+}
+
+function MakeUserField({ getValue, row: { index }, column: { id }, table }: CellContext) {
+ const update = useCallback(
+ (newValue: string) => {
+ // @ts-expect-error -- we inject this into react-table
+ table.options.meta?.handleUpdate(index, id, newValue);
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
+ [id, index],
+ );
+
+ const initialValue = getValue() as string;
+
+ return ;
+}
+
+export function makeCuesheetColumns(userFields?: UserFields): ColumnDef[] {
+ return [
+ {
+ 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: (row) => millisToString(row.getValue() as number | null),
+ size: 75,
+ },
+ {
+ accessorKey: 'title',
+ id: 'title',
+ header: 'Title',
+ cell: (row) => row.getValue(),
+ },
+ {
+ accessorKey: 'subtitle',
+ id: 'subtitle',
+ header: 'Subtitle',
+ cell: (row) => row.getValue(),
+ },
+ {
+ accessorKey: 'presenter',
+ id: 'presenter',
+ header: 'Presenter',
+ cell: (row) => row.getValue(),
+ },
+ {
+ accessorKey: 'note',
+ id: 'note',
+ header: 'Note',
+ cell: (row) => row.getValue(),
+ },
+ {
+ accessorKey: 'user0',
+ id: 'user0',
+ header: userFields?.user0 || 'User 0',
+ cell: MakeUserField,
+ },
+ {
+ accessorKey: 'user1',
+ id: 'user1',
+ header: userFields?.user1 || 'User 1',
+ cell: MakeUserField,
+ },
+ {
+ accessorKey: 'user2',
+ id: 'user2',
+ header: userFields?.user2 || 'User 2',
+ cell: MakeUserField,
+ },
+ {
+ accessorKey: 'user3',
+ id: 'user3',
+ header: userFields?.user3 || 'User 3',
+ cell: MakeUserField,
+ },
+ {
+ accessorKey: 'user4',
+ id: 'user4',
+ header: userFields?.user4 || 'User 4',
+ cell: MakeUserField,
+ },
+ {
+ accessorKey: 'user5',
+ id: 'user5',
+ header: userFields?.user5 || 'User 5',
+ cell: MakeUserField,
+ },
+ {
+ accessorKey: 'user6',
+ id: 'user6',
+ header: userFields?.user6 || 'User 6',
+ cell: MakeUserField,
+ },
+ {
+ accessorKey: 'user7',
+ id: 'user7',
+ header: userFields?.user7 || 'User 7',
+ cell: MakeUserField,
+ },
+ {
+ accessorKey: 'user8',
+ id: 'user8',
+ header: userFields?.user8 || 'User 8',
+ cell: MakeUserField,
+ },
+ {
+ accessorKey: 'user9',
+ id: 'user9',
+ header: userFields?.user9 || 'User 9',
+ cell: MakeUserField,
+ },
+ ];
+}
+
+export const initialColumnOrder: string[] = makeCuesheetColumns().map((column) => column.id as string);
diff --git a/apps/client/src/features/table/tableUtils.js b/apps/client/src/features/cuesheet/cuesheetUtils.ts
similarity index 68%
rename from apps/client/src/features/table/tableUtils.js
rename to apps/client/src/features/cuesheet/cuesheetUtils.ts
index 862dc5fb1..0b253ad2e 100644
--- a/apps/client/src/features/table/tableUtils.js
+++ b/apps/client/src/features/cuesheet/cuesheetUtils.ts
@@ -1,4 +1,5 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
+import { EventData, OntimeEntryCommonKeys, OntimeRundown, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
/**
@@ -8,14 +9,15 @@ import { millisToString } from 'ontime-utils';
* @return {string}
*/
-export const parseField = (field, data) => {
+export const parseField = (field: keyof OntimeRundown, data: unknown): string => {
let val;
switch (field) {
case 'timeStart':
case 'timeEnd':
- val = millisToString(data);
+ val = millisToString(data as number | null);
break;
case 'isPublic':
+ case 'skip':
val = data ? 'x' : '';
break;
default:
@@ -25,17 +27,18 @@ export const parseField = (field, data) => {
if (typeof data === 'undefined') {
return '';
}
- return val;
+ // all other values are strings
+ return val as string;
};
/**
* @description Creates an array of arrays usable by xlsx for export
* @param {object} headerData
- * @param {array} tableData
+ * @param {array} rundown
* @param {object} userFields
* @return {(string[])[]}
*/
-export const makeTable = (headerData, tableData, userFields) => {
+export const makeTable = (headerData: EventData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
const data = [
['Ontime · Schedule Template'],
['Event Name', headerData?.title || ''],
@@ -44,15 +47,18 @@ export const makeTable = (headerData, tableData, userFields) => {
[],
];
- const fieldOrder = [
+ const fieldOrder: OntimeEntryCommonKeys[] = [
'timeStart',
'timeEnd',
'title',
'presenter',
'subtitle',
'isPublic',
- 'notes',
+ 'note',
'colour',
+ 'endAction',
+ 'timerType',
+ 'skip',
'user0',
'user1',
'user2',
@@ -72,20 +78,23 @@ export const makeTable = (headerData, tableData, userFields) => {
'Presenter Name',
'Event Subtitle',
'Is Public? (x)',
- 'Notes',
+ 'Note',
'Colour',
+ 'End Action',
+ 'Timer Type',
+ 'Skip?',
];
for (const field in userFields) {
- const fieldValue = userFields[field];
+ const fieldValue = userFields[field as keyof UserFields];
const displayName = `${field}${fieldValue !== field && fieldValue !== '' ? `:${fieldValue}` : ''}`;
fieldTitles.push(displayName);
}
data.push(fieldTitles);
- tableData.forEach((entry) => {
- const row = [];
+ rundown.forEach((entry) => {
+ const row: string[] = [];
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
data.push(row);
});
@@ -98,8 +107,8 @@ export const makeTable = (headerData, tableData, userFields) => {
* @param {array[]} arrayOfArrays
* @return {string}
*/
-export const makeCSV = (arrayOfArrays) => {
- let csvData = 'data:text/csv;charset=utf-8,';
+export const makeCSV = (arrayOfArrays: string[][]) => {
+ const csvData = 'data:text/csv;charset=utf-8,';
const stringifiedData = stringify(arrayOfArrays);
return csvData + stringifiedData;
};
diff --git a/apps/client/src/features/table/defaults.js b/apps/client/src/features/cuesheet/defaults.ts
similarity index 69%
rename from apps/client/src/features/table/defaults.js
rename to apps/client/src/features/cuesheet/defaults.ts
index 89735ecf3..842592b7a 100644
--- a/apps/client/src/features/table/defaults.js
+++ b/apps/client/src/features/cuesheet/defaults.ts
@@ -1,7 +1,9 @@
+import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
+
/**
* @description set default column order
*/
-export const defaultColumnOrder = [
+export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
'isPublic',
'timeStart',
'timeEnd',
@@ -25,7 +27,7 @@ export const defaultColumnOrder = [
/**
* @description set default hidden columns
*/
-export const defaultHiddenColumns = [
+export const defaultHiddenColumns: (keyof OntimeEvent)[] = [
'user0',
'user1',
'user2',
diff --git a/apps/client/src/features/cuesheet/store/CuesheetSettings.tsx b/apps/client/src/features/cuesheet/store/CuesheetSettings.tsx
new file mode 100644
index 000000000..0b840f5a5
--- /dev/null
+++ b/apps/client/src/features/cuesheet/store/CuesheetSettings.tsx
@@ -0,0 +1,65 @@
+import { create } from 'zustand';
+
+import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
+
+interface CuesheetSettings {
+ showSettings: boolean;
+ followSelected: boolean;
+ showPrevious: boolean;
+ showDelayBlock: boolean;
+ showDelayedTimes: boolean;
+
+ toggleSettings: (newValue?: boolean) => void;
+ toggleFollow: (newValue?: boolean) => void;
+ togglePreviousVisibility: (newValue?: boolean) => void;
+ toggleDelayVisibility: (newValue?: boolean) => void;
+ toggleDelayedTimes: (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',
+ DelayedTimes = 'ontime-cuesheet-show-delayed',
+}
+
+export const useCuesheetSettings = create()((set) => ({
+ showSettings: false,
+ followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
+ showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
+ showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
+ showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, 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 };
+ }),
+ 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 };
+ }),
+}));
diff --git a/apps/client/src/features/cuesheet/tableElements/EditableCell.tsx b/apps/client/src/features/cuesheet/tableElements/EditableCell.tsx
new file mode 100644
index 000000000..85fa55f0e
--- /dev/null
+++ b/apps/client/src/features/cuesheet/tableElements/EditableCell.tsx
@@ -0,0 +1,40 @@
+import { ChangeEvent, memo, useCallback, useEffect, useState } from 'react';
+
+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 onChange = useCallback((event: ChangeEvent) => setValue(event.target.value), []);
+
+ // We'll only update the external data when the input is blurred
+ const onBlur = useCallback(() => handleUpdate(value), [handleUpdate, value]);
+
+ // If the initialValue is changed external, sync it up with our state
+ useEffect(() => {
+ setValue(initialValue);
+ }, [initialValue]);
+
+ return (
+
+ );
+};
+
+export default memo(EditableCell);
diff --git a/apps/client/src/features/cuesheet/tableElements/PlaybackIcon.tsx b/apps/client/src/features/cuesheet/tableElements/PlaybackIcon.tsx
new file mode 100644
index 000000000..7813e94f4
--- /dev/null
+++ b/apps/client/src/features/cuesheet/tableElements/PlaybackIcon.tsx
@@ -0,0 +1,36 @@
+import { Tooltip } from '@chakra-ui/react';
+import { IoPause } from '@react-icons/all-files/io5/IoPause';
+import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
+import { IoStop } from '@react-icons/all-files/io5/IoStop';
+import { Playback } from 'ontime-types';
+
+import { tooltipDelayFast } from '../../../ontimeConfig';
+
+interface PlaybackIconProps {
+ state: Playback;
+}
+
+export default function PlaybackIcon(props: PlaybackIconProps) {
+ const { state } = props;
+
+ // if timer is Pause or Armed
+ let label = 'Timer Paused';
+ let Icon = IoPause;
+
+ if (state === Playback.Roll) {
+ label = 'Timer Rolling';
+ Icon = IoPlay;
+ } else if (state === Playback.Play) {
+ label = 'Timer Playing';
+ Icon = IoPlay;
+ } else if (state === Playback.Stop) {
+ label = 'Timer Stopped';
+ Icon = IoStop;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/client/src/features/cuesheet/tableElements/SortableCell.tsx b/apps/client/src/features/cuesheet/tableElements/SortableCell.tsx
new file mode 100644
index 000000000..f20535c21
--- /dev/null
+++ b/apps/client/src/features/cuesheet/tableElements/SortableCell.tsx
@@ -0,0 +1,48 @@
+import { CSSProperties, ReactNode } from 'react';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { Header } from '@tanstack/react-table';
+import { OntimeRundownEntry } from 'ontime-types';
+
+import { cx } from '../../../common/utils/styleUtils';
+
+import styles from '../Cuesheet.module.scss';
+
+interface SortableCellProps {
+ header: Header;
+ style: CSSProperties;
+ children: ReactNode;
+}
+
+export function SortableCell({ header, style, children }: SortableCellProps) {
+ const { column, colSpan } = header;
+
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: column.id,
+ });
+
+ // build drag styles
+ const dragStyle = {
+ ...style,
+ opacity: isDragging ? 0.5 : 1,
+ transform: CSS.Translate.toString(transform),
+ transition,
+ };
+
+ const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
+
+ return (
+
+
+ {children}
+
+
+ |
+ );
+}
diff --git a/apps/client/src/features/info/InfoLogger.tsx b/apps/client/src/features/info/InfoLogger.tsx
index 493eff34d..285717c5c 100644
--- a/apps/client/src/features/info/InfoLogger.tsx
+++ b/apps/client/src/features/info/InfoLogger.tsx
@@ -1,19 +1,11 @@
import { useCallback, useState } from 'react';
import { Button } from '@chakra-ui/react';
+import { LogOrigin } from 'ontime-types';
import { clearLogs, useLogData } from '../../common/stores/logger';
import style from './InfoLogger.module.scss';
-enum LogFilter {
- User = 'USER',
- Client = 'CLIENT',
- Server = 'SERVER',
- RX = 'RX',
- TX = 'TX',
- Playback = 'PLAYBACK',
-}
-
export default function InfoLogger() {
const { logs: logData } = useLogData();
@@ -24,35 +16,35 @@ export default function InfoLogger() {
const [showPlayback, setShowPlayback] = useState(true);
const [showUser, setShowUser] = useState(true);
- const matchers: LogFilter[] = [];
+ const matchers: LogOrigin[] = [];
if (showUser) {
- matchers.push(LogFilter.User);
+ matchers.push(LogOrigin.User);
}
if (showClient) {
- matchers.push(LogFilter.Client);
+ matchers.push(LogOrigin.Client);
}
if (showServer) {
- matchers.push(LogFilter.Server);
+ matchers.push(LogOrigin.Server);
}
if (showRx) {
- matchers.push(LogFilter.RX);
+ matchers.push(LogOrigin.Rx);
}
if (showTx) {
- matchers.push(LogFilter.TX);
+ matchers.push(LogOrigin.Tx);
}
if (showPlayback) {
- matchers.push(LogFilter.Playback);
+ matchers.push(LogOrigin.Playback);
}
const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match));
- const disableOthers = useCallback((toEnable: LogFilter) => {
- toEnable === LogFilter.User ? setShowUser(true) : setShowUser(false);
- toEnable === LogFilter.Client ? setShowClient(true) : setShowClient(false);
- toEnable === LogFilter.Server ? setShowServer(true) : setShowServer(false);
- toEnable === LogFilter.RX ? setShowRx(true) : setShowRx(false);
- toEnable === LogFilter.TX ? setShowTx(true) : setShowTx(false);
- toEnable === LogFilter.Playback ? setShowPlayback(true) : setShowPlayback(false);
+ const disableOthers = useCallback((toEnable: LogOrigin) => {
+ toEnable === LogOrigin.User ? setShowUser(true) : setShowUser(false);
+ toEnable === LogOrigin.Client ? setShowClient(true) : setShowClient(false);
+ toEnable === LogOrigin.Server ? setShowServer(true) : setShowServer(false);
+ toEnable === LogOrigin.Rx ? setShowRx(true) : setShowRx(false);
+ toEnable === LogOrigin.Tx ? setShowTx(true) : setShowTx(false);
+ toEnable === LogOrigin.Playback ? setShowPlayback(true) : setShowPlayback(false);
}, []);
return (
@@ -62,55 +54,55 @@ export default function InfoLogger() {
variant={showUser ? 'ontime-filled' : 'ontime-subtle'}
size='xs'
onClick={() => setShowUser((s) => !s)}
- onAuxClick={() => disableOthers(LogFilter.User)}
+ onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()}
>
- {LogFilter.User}
+ {LogOrigin.User}