Compare commits

...

78 Commits

Author SHA1 Message Date
google-labs-jules[bot] fbbbaa5922 fix: stabilize cuesheet table components to prevent focus and data loss
This commit addresses the issue where focusing cells or inputs in the cuesheet table was lost during re-renders.

Changes:
- Extracted Virtuoso components (Table, TableRow, TableHead, EmptyPlaceholder) into stable, memoized components outside the main CuesheetTable component.
- Used Virtuoso's context API to pass dynamic state to the extracted components.
- Stabilized the 'meta' object of TanStack Table by using a ref for the data, reducing unnecessary re-renders of cell components.
- Modified useReactiveTextInput to skip synchronizing the 'text' state with 'initialText' when the input is focused, preventing user input from being overwritten by external changes.

These changes ensure that focus is maintained even when the table re-renders due to concurrent edits or submitting changes, and typed content is preserved.

Co-authored-by: cpvalente <34649812+cpvalente@users.noreply.github.com>
2026-02-08 11:24:48 +00:00
Carlos Valente aecf9575b1 refactor: link to settings section 2026-02-08 11:20:09 +01:00
Carlos Valente 773229ce4c refactor: align inputs with group, refs #1857 2026-02-08 11:20:09 +01:00
Carlos Valente da02ecd113 feat: editor layout 2026-02-08 11:20:09 +01:00
Carlos Valente 967e92e2c8 feat: extend rundown shortcuts 2026-02-08 11:20:09 +01:00
Carlos Valente d7af73d9ed feat: unify rundown features 2026-02-08 11:20:09 +01:00
Carlos Valente c5045ad82e refactor: table mode and context 2026-02-08 11:20:09 +01:00
Carlos Valente 8c22968bc9 fix: tsconfig references for utility tests 2026-02-08 11:20:09 +01:00
Carlos Valente 8aad271ffe refactor: create utility for enums 2026-02-08 11:20:09 +01:00
Carlos Valente b28573455a feat: create reusable scroll area 2026-02-08 11:20:09 +01:00
Carlos Valente e25725ea8b refactor: expose actions through context 2026-02-08 11:20:09 +01:00
Carlos Valente 1f8065143a refactor: improve context menu performance
- extract menu from rundown tree
- lazy create actions
2026-02-08 11:20:09 +01:00
Carlos Valente 8e32314667 refactor: improve performance in rundown
- leverage compiler
- correct subscriptions from zustand
2026-02-08 11:20:09 +01:00
Carlos Valente 0de55e74a8 feat: table mode in editor 2026-02-08 11:20:09 +01:00
Carlos Valente e5c5ec18d3 fix: automation form validation 2026-02-08 11:20:09 +01:00
Carlos Valente f9f6cb74ad chore: update npm link 2026-02-01 18:03:56 +01:00
Alex Christoffer Rasmussen 026e24bf84 Feat: unify view search params (#1947)
* feat: main src in backstage view

* feat: main src in timeline view

* feat: main src in countdown view

* feat: main src in studio view

* feat: hide past events in countdown view

* notes can not be set as main src

* feat: show group title as secondary src

* chore: spelling

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-01-27 20:42:07 +01:00
Joel Wetzell ec1890c275 update docker actions and pin to commit hash 2026-01-26 14:10:09 +01:00
Joel Wetzell 046f1176eb add docker metadata action to setup tags 2026-01-26 14:10:09 +01:00
Alex Christoffer Rasmussen 04c5b67475 fix: missing demo logo (#1954) 2026-01-26 13:52:29 +01:00
google-labs-jules[bot] 7b5f797c95 refactor: move welcome modal logic to AppStateService
The logic for deciding whether to show the welcome modal has been moved from `app.ts` to `AppStateService.ts`. The `getShowWelcomeDialog` function now accepts a boolean indicating whether a restore point exists and returns `false` if it does. This keeps the business logic out of the main application file and in the relevant service.
2026-01-24 07:45:56 +01:00
google-labs-jules[bot] 14f9472250 fix: hide welcome modal on restore
The welcome modal is now hidden if the application is started from a restore point.
2026-01-24 07:45:56 +01:00
Alex Christoffer Rasmussen efede83271 Fix: automation (#1943)
* Fix(automation): Handles undefined values in conditions (#1933)

* Fix(automation): Handles undefined values in conditions

Addresses an issue where conditions with the 'not_equals' operator incorrectly evaluated undefined values. This change ensures that empty string comparisons correctly identify missing values in automation rules.

* Fixes automation "not equals" logic

Simplifies the 'not_equals' condition evaluation in automations by reusing the 'equals' condition, improving code readability and consistency.

Fixes #1932

* Test(automation): Expanding filter condition testing

Expanding test cases to cover various data types and edge case for each operators.
Unexpected behavior have been mark with a TO_DO.

* Fix(automation): Handles default filter case

- Ensures that their is a default scenario.
- Fixing Deepsource issue "No default cases in switch statements JS-0047"

* Test(automation): Remove abstraction in test

* move to test.each

* extract a isEquivalent function

* lowercase contains test

---------

Co-authored-by: Philippe Allard-Rousse <philrousse@gmail.com>
2026-01-21 16:07:21 +01:00
Carlos Valente 904db95f59 docs: update linux links 2026-01-16 07:52:56 +01:00
Carlos Valente 525999440a fix: inline elements should not wrap by default 2026-01-14 17:12:03 +01:00
Carlos Valente 6e5805977a refactor: hide logo on error 2026-01-11 17:43:53 +01:00
Carlos Valente eb897a2a23 refactor: set background colour from content 2026-01-11 17:43:53 +01:00
Carlos Valente 05d997a52d bump version to 4.3.1 2026-01-11 17:43:23 +01:00
Carlos Valente f8cde5d627 fix: restore process sets currentDay 2026-01-10 19:23:49 +01:00
Alex Christoffer Rasmussen 1b55254834 fix: Network Interface overflow (#1935) 2026-01-05 21:05:45 +01:00
Alex Christoffer Rasmussen 6436e3fda8 refactor: extract rundown switching to a single function in rundown.service (#1930) 2026-01-05 20:47:04 +01:00
Alex Christoffer Rasmussen 680273770c fix: crash-log not written on windows and add log entries to crash report
* fix: crashlog not written on windows

* feat: dump last 100 log entries to crash report
2026-01-01 14:59:21 +01:00
Carlos Valente 75e77de466 fix: prevent infinite loop in project data 2025-12-26 08:42:53 +01:00
Carlos Valente 75cbcd8e85 bump version to 4.3.0 2025-12-22 16:57:52 +01:00
Carlos Valente 701f24cece fix: automation form validation 2025-12-22 16:57:52 +01:00
Carlos Valente 166160dda9 chore: upgrade client dependencies 2025-12-22 16:57:52 +01:00
Carlos Valente 9c5116f197 refactor: handle prefix on login
- post-login redirect should account for prefix
- scope cookies to each path by prefix
2025-12-22 15:59:38 +01:00
Alex Christoffer Rasmussen deb1f043c7 fix: set not initialised custom field from api (#1926) 2025-12-22 15:27:06 +01:00
Carlos Valente cbba19a9b1 fix: user can quick start project from partial data 2025-12-21 20:25:14 +01:00
Carlos Valente a5daa76616 refactor: improve merge logic 2025-12-21 20:25:14 +01:00
Carlos Valente 08b8e73393 refactor: finish view directory migration 2025-12-21 13:32:12 +01:00
Carlos Valente 2f3cf9825a refactor(css): migrate use of deprecated import in messagecontrol 2025-12-21 13:32:12 +01:00
Carlos Valente b0f02e726d refactor(css): migrate use of deprecated import in cuesheet 2025-12-21 13:32:12 +01:00
Carlos Valente ac40292813 refactor(css): migrate use of deprecated import in operator 2025-12-21 13:32:12 +01:00
Carlos Valente 2ac00ee8cc refactor(css): migrate use of deprecated import in buttons 2025-12-21 13:32:12 +01:00
Carlos Valente 972f9e35fd refactor: create separation between editor wrapper and contents 2025-12-21 13:32:12 +01:00
Carlos Valente 1d7ee163a0 Revert "feat: display offline indicators in all views"
This reverts commit 71b2fa8e21.
2025-12-20 15:25:15 +01:00
Carlos Valente 1054ec0a5e refactor: expose missing types 2025-12-20 14:18:07 +01:00
Carlos Valente fc2e76db0a refactor: stabilise filtering function 2025-12-19 09:54:19 +01:00
Carlos Valente ef3d541eb3 refactor: simplify element lookup 2025-12-19 09:54:19 +01:00
Carlos Valente 8daf3313f2 refactor: simplify flat rundown 2025-12-19 09:54:19 +01:00
Carlos Valente 93c913ec5a refactor: remove placeholderData pattern 2025-12-19 09:54:19 +01:00
Carlos Valente 71b2fa8e21 feat: display offline indicators in all views 2025-12-19 08:51:54 +01:00
Carlos Valente f2b78e0f32 refactor: generate ids for new rundowns 2025-12-18 14:42:01 +01:00
Carlos Valente b70ef80320 refactor: patch does not load rundown 2025-12-18 14:42:01 +01:00
Carlos Valente 426d42ab63 refactor: add guards around trivial index checks 2025-12-18 14:42:01 +01:00
Carlos Valente 242cefd35c tooling: lax typechecking in tests 2025-12-18 14:42:01 +01:00
arc-alex 6beda1575a chore: marke change api call async 2025-12-16 21:56:39 +01:00
arc-alex ba75d52d29 chore: marking a functions as throwing 2025-12-16 21:56:39 +01:00
arc-alex 35a4361ae9 fix: expectedFinish flashing incorrect value when adding timer in overtime 2025-12-16 21:56:39 +01:00
arc-alex 77225383a4 fix: block uploading already existing file name 2025-12-16 21:56:39 +01:00
arc-alex f0e81c0cf0 fix: stop timer on rundown switch 2025-12-16 21:56:39 +01:00
Joel Wetzell 9355d7bf27 feat: add arm architectures to linux target 2025-12-15 20:03:57 +01:00
Carlos Valente cd39227af7 fix: prevent unsupported wakelock 2025-12-09 19:13:36 +01:00
Carlos Valente 14e21214af fix: subdue paused only for running timers 2025-12-09 19:13:36 +01:00
Carlos Valente 03184b0321 fix: prevent accessing undefined index 2025-12-09 19:13:36 +01:00
Carlos Valente cd5af769ab fix: undefined flatRundown 2025-12-09 19:13:36 +01:00
Carlos Valente d116670318 fix: safe copy to clipboard 2025-12-09 19:13:36 +01:00
Carlos Valente 9ba5ebd4bd bump version to 4.2.1 (#1896) 2025-11-28 16:26:53 +01:00
Carlos Valente 3f1f06f7c5 Improve Clone (#1897)
* refactor(rundown): optimise copy-paste performance

* chore: configure opt-in compiler

* refactor(rundown): stabilise frequently accessed data

* feat(clone): allow cloning any element

* remove paste above and cue increment from test

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-11-28 16:20:52 +01:00
arc-alex a78586d2fa allow count up to show negative value 2025-11-28 15:03:28 +01:00
arc-alex 8e93b0af25 fix: countToEnd should not override display options 2025-11-28 15:03:28 +01:00
Carlos Valente 9a5d50d933 fix(wakelock): check for feature support 2025-11-28 11:26:23 +01:00
Carlos Valente 1189a94bff refactor(wakelock): simplify logic into hook 2025-11-28 11:26:23 +01:00
Alex Christoffer Rasmussen f128f7acd2 fix: api change custom fields (#1888)
* fix: validate nested custom path

* chore: add test
2025-11-27 09:15:02 +01:00
Carlos Valente 80ec2d1186 refactor(pip): match timer formatting defaults 2025-11-26 20:10:01 +01:00
Carlos Valente 18e6f0d7c4 refactor(timer): allow multiline secondary text 2025-11-26 20:10:01 +01:00
Alex Christoffer Rasmussen 48f2511764 fix: custom fields not persisted (#1891) 2025-11-26 17:58:00 +01:00
320 changed files with 7451 additions and 2732 deletions
+15 -16
View File
@@ -37,32 +37,31 @@ jobs:
run: pnpm build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Setup Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/ontime
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=raw,value=nightly,enable=${{ github.event.release.prerelease == true }}
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+4 -1
View File
@@ -103,6 +103,9 @@ jobs:
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-linux.AppImage'
files: |
./apps/electron/dist/ontime-linux-x86_64.AppImage
./apps/electron/dist/ontime-linux-arm64.AppImage
./apps/electron/dist/ontime-linux-armv7l.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -40,6 +40,7 @@ override.css
# working stuff
**/TODO.md
**.local.**
# docker utils
ontime-db
+6 -4
View File
@@ -10,13 +10,15 @@
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">macOS (Apple Silicon)</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">macOS (Intel)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-x86_64.AppImage">Linux (Intel / AMD 64-bit)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-arm64.AppImage">Linux (ARM 64-bit, Raspberry Pi 4+)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-armv7l.AppImage">Linux (ARM 32-bit, older Raspberry Pi)</a>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
- Install from <a href="https://www.npmjs.com/package/@getontime/cli">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help?
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.2.0",
"version": "4.3.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+8 -8
View File
@@ -1,10 +1,10 @@
{
"name": "ontime-ui",
"version": "4.2.0",
"version": "4.3.1",
"private": true,
"type": "module",
"dependencies": {
"@base-ui-components/react": "1.0.0-beta.4",
"@base-ui/react": "1.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -17,21 +17,20 @@
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.12.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0",
"prismjs": "^1.30.0",
"react": "^19.1.1",
"react": "^19.2.3",
"react-colorful": "^5.6.1",
"react-dom": "^19.1.1",
"react-dom": "^19.2.3",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.62.0",
"react-icons": "5.5.0",
"react-qr-code": "^2.0.18",
"react-router": "^7.8.2",
"react-router": "^7.11.0",
"react-simple-code-editor": "^0.14.1",
"react-virtuoso": "^4.14.0",
"react-virtuoso": "^4.17.0",
"web-vitals": "^5.1.0",
"zustand": "^5.0.8"
"zustand": "^5.0.9"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -66,6 +65,7 @@
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0",
+1 -1
View File
@@ -1,5 +1,5 @@
import { BrowserRouter } from 'react-router';
import { Tooltip } from '@base-ui-components/react/tooltip';
import { Tooltip } from '@base-ui/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+31 -4
View File
@@ -21,7 +21,23 @@ export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
*/
export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
return res.data;
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
}
/**
@@ -75,7 +91,10 @@ export async function postAddEntry(
/**
* HTTP request to edit an entry
*/
export async function putEditEntry(rundownId: RundownId, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
export async function putEditEntry(
rundownId: RundownId,
data: Partial<OntimeEntry>,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
}
@@ -107,7 +126,11 @@ export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry
/**
* HTTP request to swap two events
*/
export async function requestEventSwap(rundownId: RundownId, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
export async function requestEventSwap(
rundownId: RundownId,
from: EntryId,
to: EntryId,
): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
}
@@ -121,8 +144,12 @@ export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId):
/**
* HTTP request for cloning an entry
*/
export async function postCloneEntry(rundownId: RundownId, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
export async function postCloneEntry(
rundownId: RundownId,
entryId: EntryId,
options?: { before?: EntryId; after?: EntryId },
): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
}
/**
@@ -1,140 +0,0 @@
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
@@ -1,5 +1,6 @@
@use '@/theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
@use './buttonVariants' as base;
@include base.button-variants;
.baseButton {
position: relative;
@@ -81,4 +82,3 @@
.fluid {
width: 100%;
}
@@ -1,5 +1,6 @@
@use '@/theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
@use './buttonVariants' as base;
@include base.button-variants;
.baseIconButton {
aspect-ratio: 1;
@@ -0,0 +1,144 @@
@use '@/theme/themeTokens' as *;
@use '@/theme/viewerDefs' as *;
@mixin button-variants {
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
.subtle-destructive {
background: $gray-1050;
color: $red-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
}
@@ -1,5 +1,5 @@
import { IoCheckmark } from 'react-icons/io5';
import { Checkbox as BaseCheckbox } from '@base-ui-components/react/checkbox';
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
import style from './Checkbox.module.scss';
@@ -5,10 +5,10 @@ import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog';
import Info from '../info/Info';
import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink';
import Modal from '../modal/Modal';
import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
@@ -57,7 +57,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
];
return (
<Modal
<Dialog
isOpen={isOpen}
onClose={onClose}
showCloseButton
@@ -98,7 +98,10 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)}
onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
disabled={enabledPresets.length === 0}
/>
</label>
@@ -1,7 +1,7 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark, IoCopy } from 'react-icons/io5';
import copyToClipboard from '../../utils/copyToClipboard';
import { copyToClipboard } from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
@@ -25,15 +25,19 @@ export default function CopyTag({
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = () => {
copyToClipboard(copyValue);
setCopied(true);
const handleClick = async () => {
try {
await copyToClipboard(copyValue);
setCopied(true);
// reset copied state
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
// reset copied state
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// ignore errors
}
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
};
return (
@@ -6,7 +6,7 @@
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(420px, 90vw);
min-width: min(600px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import IconButton from '../buttons/IconButton';
@@ -12,7 +12,7 @@ interface DialogProps {
showCloseButton?: boolean;
showBackdrop?: boolean;
bodyElements: ReactNode;
footerElements: ReactNode;
footerElements?: ReactNode;
onClose: () => void;
}
@@ -31,11 +31,12 @@
outline: 0;
cursor: default;
padding-block: 0.5rem;
padding-inline: 1rem 2rem;
padding-inline: 1rem;
display: flex;
gap: 0.5rem;
line-height: 1em;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
svg {
color: $gray-500;
@@ -69,6 +70,35 @@
}
}
.item .iconStrong {
color: $ui-white;
}
.content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
line-height: 1.2;
}
.description {
color: $gray-400;
font-size: calc(1rem - 2px);
line-height: 1.3;
}
.shortcut {
color: $gray-500;
font-size: calc(1rem - 4px);
letter-spacing: 0.02em;
}
.separator {
margin: 0.25rem 0.75rem;
height: 1px;
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
import { IconType } from 'react-icons';
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
import { Menu as BaseMenu } from '@base-ui/react/menu';
import style from './DropdownMenu.module.scss';
@@ -8,9 +8,11 @@ type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = {
type: 'item' | 'destructive';
label: string;
description?: string;
icon?: IconType;
disabled?: boolean;
onClick: () => void;
shortcut?: string;
};
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
@@ -31,9 +33,21 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
return <BaseMenu.Separator key={index} className={style.separator} />;
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled} data-type={item.type}>
{item.icon && <item.icon />}
{item.label}
<BaseMenu.Item
key={index}
className={style.item}
onClick={item.onClick}
disabled={item.disabled}
data-type={item.type}
>
<span className={style.content}>
<span className={style.label}>
{item.icon && <item.icon />}
{item.label}
</span>
{item.description && <span className={style.description}>{item.description}</span>}
</span>
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
</BaseMenu.Item>
);
})}
@@ -69,8 +83,14 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />}
{item.label}
<span className={style.content}>
<span className={style.label}>
{item.icon && <item.icon />}
{item.label}
</span>
{item.description && <span className={style.description}>{item.description}</span>}
</span>
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
</BaseMenu.Item>
);
})}
@@ -59,3 +59,10 @@
height: 0.75em;
}
}
.panel {
position: relative;
border-radius: $panel-border-radius;
background-color: $bg-container-l2;
padding: 1rem;
}
@@ -55,3 +55,7 @@ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
}
export function Panel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cx([style.panel, className])} {...props} />;
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover } from '@base-ui-components/react/popover';
import { Popover } from '@base-ui/react/popover';
import PopoverContents from '../../popover/Popover';
@@ -25,12 +25,15 @@ export default function useReactiveTextInput(
const isKeyboardSubmitting = useRef(false);
useEffect(() => {
const isFocused = document.activeElement === ref.current;
if (isFocused) return;
if (typeof initialText === 'undefined') {
setText('');
} else {
setText(initialText);
}
}, [initialText]);
}, [initialText, ref]);
/**
* @description Handles Input value change
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import IconButton from '../buttons/IconButton';
@@ -31,7 +31,7 @@ export default function Modal({
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
dismissible={false}
disablePointerDismissal
>
<BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
@@ -2,11 +2,11 @@ import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog';
import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost, supportsFullscreen } from '../../../externals';
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore';
@@ -69,7 +69,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{window.isSecureContext && (
{canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react';
import { Popover } from '@base-ui-components/react/popover';
import { Popover } from '@base-ui/react/popover';
import style from './Popover.module.scss';
@@ -1,5 +1,5 @@
import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import { Radio } from '@base-ui/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group';
import { cx } from '../../utils/styleUtils';
@@ -0,0 +1,56 @@
@use '../../../theme/ontimeColours' as *;
@use '../../../theme/ontimeStyles' as *;
// copied from index.scss
$track-color: $white-1;
$thumb-color: $white-20;
$thumb-color-hover: $white-60;
.root {
position: relative;
overflow: hidden;
}
.viewport {
height: 100%;
width: 100%;
overscroll-behavior: contain;
}
.scrollbar {
background: transparent;
opacity: 0;
transition: opacity 150ms;
&[data-orientation='vertical'] {
width: 6px;
}
&[data-orientation='horizontal'] {
height: 6px;
}
&[data-orientation='horizontal'][data-has-overflow-x] {
opacity: 1;
pointer-events: auto;
}
&[data-scrolling] {
transition-duration: 0ms;
}
&[data-hovering],
&[data-scrolling] {
opacity: 1;
pointer-events: auto;
}
}
.thumb {
background: $thumb-color;
border-radius: 2px;
&:hover {
background: $thumb-color-hover;
}
}
@@ -0,0 +1,38 @@
import { CSSProperties, PropsWithChildren, Ref } from 'react';
import { ScrollArea } from '@base-ui/react/scroll-area';
import { cx } from '../../utils/styleUtils';
import style from './ScrollArea.module.scss';
interface ScrollAreaProps {
className?: string;
viewportClassName?: string;
contentClassName?: string;
contentStyle?: CSSProperties;
ref?: Ref<HTMLDivElement>;
orientation?: 'vertical' | 'horizontal';
}
export default function StyledScrollArea({
className,
viewportClassName,
contentClassName,
contentStyle,
children,
ref,
orientation = 'vertical',
}: PropsWithChildren<ScrollAreaProps>) {
return (
<ScrollArea.Root className={cx([style.root, className])}>
<ScrollArea.Viewport ref={ref} className={cx([style.viewport, viewportClassName])}>
<ScrollArea.Content className={contentClassName} style={contentStyle}>
{children}
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar className={style.scrollbar} orientation={orientation}>
<ScrollArea.Thumb className={style.thumb} />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
);
}
@@ -1,6 +1,6 @@
import { IoCheckmark } from 'react-icons/io5';
import { LuChevronsUpDown } from 'react-icons/lu';
import { Select as BaseSelect } from '@base-ui-components/react/select';
import { Select as BaseSelect } from '@base-ui/react/select';
import { cx } from '../../utils/styleUtils';
@@ -1,4 +1,4 @@
import { Switch as BaseSwitch } from '@base-ui-components/react/switch';
import { Switch as BaseSwitch } from '@base-ui/react/switch';
import { cx } from '../../utils/styleUtils';
@@ -1,7 +1,11 @@
import { ReactNode } from 'react';
import { PropsWithChildren } from 'react';
import style from './Tag.module.scss';
export default function Tag({ children }: { children: ReactNode }) {
return <span className={style.tag}>{children}</span>;
interface TagProps {
className?: string;
}
export default function Tag({ className, children }: PropsWithChildren<TagProps>) {
return <span className={`${style.tag} ${className || ''}`}>{children}</span>;
}
@@ -16,6 +16,7 @@
.title-card__title {
color: var(--color-override, $viewer-color);
padding-right: 1em;
min-height: 1.2em;
}
.title-card__placeholder {
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react';
import { Tooltip as BaseTooltip } from '@base-ui-components/react/tooltip';
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip';
import style from './Tooltip.module.scss';
@@ -1,3 +1,5 @@
import { useRef } from 'react';
import { projectLogoPath } from '../../api/constants';
import './ViewLogo.scss';
@@ -7,13 +9,19 @@ interface ViewLogoProps {
className: string;
}
export default function ViewLogo(props: ViewLogoProps) {
const { name, className } = props;
export default function ViewLogo({ name, className }: ViewLogoProps) {
const imageRef = useRef<HTMLImageElement>(null);
const hideImage = () => {
if (!imageRef.current) return;
imageRef.current.style.display = 'none';
};
// we wrap the image in a div to help maintain the aspect ratio
return (
<div className={className}>
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
<img ref={imageRef} alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' onError={hideImage} />
</div>
);
}
@@ -1,7 +1,7 @@
import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { isStringBoolean } from '../../../views/common/viewUtils';
import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select';
@@ -160,7 +160,16 @@ function ControlledSelect({ id, initialValue, options }: ControlledSelectProps)
}, [initialValue]);
return (
<Select size='large' name={id} options={options} value={selected} onValueChange={(value) => setSelected(value)} />
<Select
size='large'
name={id}
options={options}
value={selected}
onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
/>
);
}
@@ -1,7 +1,7 @@
import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog';
import { Dialog } from '@base-ui/react/dialog';
import { OntimeView } from 'ontime-types';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
@@ -0,0 +1,24 @@
import { createContext, PropsWithChildren, useContext } from 'react';
import { useEntryActions } from '../hooks/useEntryAction';
type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
interface EntryActionsProviderProps extends PropsWithChildren {
actions: EntryActionsContextValue;
}
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
}
export function useEntryActionsContext(): EntryActionsContextValue {
const context = useContext(EntryActionsContext);
if (!context) {
throw new Error('useEntryActionsContext must be used within EntryActionsProvider');
}
return context;
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
@@ -8,8 +8,6 @@ import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
@@ -27,15 +25,15 @@ export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN,
queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId();
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
}
@@ -46,37 +44,20 @@ export function useRundownWithMetadata() {
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const { data: projectData } = useProjectData();
const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]);
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 || data.revision !== prevRevision) {
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
setFlatRundown(flatRundown);
setPrevRevision(data.revision);
const flatRundown = useMemo(() => {
if (data.revision === -1) {
return [];
}
}, [data.entries, data.flatOrder, data.revision, prevRevision]);
// TODO: should we have a project id field?
// TODO(v4): cleanup as part of load multiple rundowns
// invalidate current version if project changes
useEffect(() => {
if (projectData?.title !== loadedProject.current) {
setPrevRevision(-1);
loadedProject.current = projectData?.title ?? '';
}
}, [projectData]);
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId();
const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
@@ -84,6 +65,10 @@ export function useFlatRundownWithMetadata() {
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*
*/
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
@@ -100,11 +85,6 @@ export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boo
export function useEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown();
// track the specific entry we care about
const entry = useMemo(() => {
if (entryId === null) return null;
return rundown.entries[entryId];
}, [entryId, rundown.entries]);
return entry;
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
@@ -1,18 +1,24 @@
import { MouseEvent } from 'react';
import { MouseEvent, useCallback } from 'react';
import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu';
import { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu';
export const useContextMenu = <T extends HTMLElement>(options: DropdownMenuOption[]) => {
type ContextMenuOptions = () => DropdownMenuOption[];
export const useContextMenu = <T extends HTMLElement>(options: ContextMenuOptions) => {
const setContextMenu = useContextMenuStore((state) => state.setContextMenu);
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up
contextMenuEvent.preventDefault();
const localCreateContextMenu = useCallback(
(contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up
contextMenuEvent.preventDefault();
const { pageX, pageY } = contextMenuEvent;
return setContextMenu({ x: pageX, y: pageY }, options);
};
const { pageX, pageY } = contextMenuEvent;
const menuOptions = options();
return setContextMenu({ x: pageX, y: pageY }, menuOptions);
},
[options, setContextMenu],
);
return [localCreateContextMenu];
};
+40 -20
View File
@@ -1,7 +1,8 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
InsertOptions,
isOntimeEvent,
isOntimeGroup,
MaybeString,
@@ -173,7 +174,8 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId),
mutationFn: ([rundownId, entryId, options]: Parameters<typeof postCloneEntry>) =>
postCloneEntry(rundownId, entryId, options),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
@@ -182,14 +184,14 @@ export const useEntryActions = () => {
* Clone an entry
*/
const clone = useCallback(
async (entryId: EntryId) => {
async (entryId: EntryId, options?: InsertOptions) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await cloneEntryMutation([rundownId, entryId]);
await cloneEntryMutation([rundownId, entryId, options]);
} catch (error) {
logAxiosError('Error cloning entry', error);
}
@@ -838,22 +840,40 @@ export const useEntryActions = () => {
[getCurrentRundownData, swapEventsMutation],
);
return {
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
updateTimer,
};
return useMemo(
() => ({
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
updateTimer,
}),
[
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
updateTimer,
],
);
};
/**
+8 -28
View File
@@ -128,13 +128,9 @@ export const setAuxTimer = {
setDuration: (index: number, time: number) => sendSocket('auxtimer', { [index]: { duration: time } }),
};
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null,
}));
export const useSelectedEventId = createSelector((state: RuntimeStore) => state.eventNow?.id ?? null);
export const useCurrentGroupId = createSelector((state: RuntimeStore) => ({
currentGroupId: state.groupNow?.id ?? null,
}));
export const useCurrentGroupId = createSelector((state: RuntimeStore) => state.groupNow?.id ?? null);
export const setEventPlayback = {
loadEvent: (id: string) => sendSocket('load', { id }),
@@ -147,9 +143,7 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
export const useClock = createSelector((state: RuntimeStore) => ({
clock: state.clock,
}));
export const useClock = createSelector((state: RuntimeStore) => state.clock);
export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.eventFlag?.id ?? null,
@@ -173,28 +167,16 @@ export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
}));
export const usePing = createSelector((state: RuntimeStore) => ({
ping: state.ping,
}));
export const usePing = createSelector((state: RuntimeStore) => state.ping);
/** convert ping into a derived value which changes less often */
export const useIsOnline = createSelector((state: RuntimeStore) => ({
isOnline: state.ping > 0,
}));
export const useIsOnline = createSelector((state: RuntimeStore) => state.ping > 0);
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
offsetMode: state.offset.mode,
}));
export const useOffsetMode = createSelector((state: RuntimeStore) => state.offset.mode);
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload);
export const usePlayback = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
});
return useRuntimeStore(featureSelector);
};
export const usePlayback = createSelector((state: RuntimeStore) => state.timer.playback);
/* ======================= Overview data subscriptions ======================= */
@@ -204,9 +186,7 @@ export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedEnd: state.rundown.plannedEnd,
}));
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => ({
expectedEnd: state.offset.expectedRundownEnd,
}));
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => state.offset.expectedRundownEnd);
export const useProgressOverview = createSelector((state: RuntimeStore) => ({
numEvents: state.rundown.numEvents,
@@ -2,10 +2,13 @@ import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: string | null;
setEntryCopyId: (eventId: string | null) => void;
entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
};
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null,
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }),
entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
}));
@@ -1,68 +0,0 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../clone';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEntry.Event,
flag: false,
title: 'title',
cue: 'cue',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
parent: 'test',
linkStart: false,
countToEnd: false,
endAction: EndAction.None,
skip: false,
colour: 'F00',
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
triggers: [],
custom: {
lighting: '3',
} as EntryCustomFields,
};
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned.triggers).not.toBe(original.triggers);
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
flag: original.flag,
title: original.title,
note: original.note,
timeStart: original.timeStart,
duration: original.duration,
timeEnd: original.timeEnd,
timerType: original.timerType,
timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd,
linkStart: original.linkStart,
endAction: original.endAction,
skip: original.skip,
colour: original.colour,
revision: 0,
delay: original.delay,
dayOffset: original.dayOffset,
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
});
});
});
-36
View File
@@ -1,36 +0,0 @@
import { OntimeEvent, SupportedEntry } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
* @param {OntimeEvent} event
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return {
type: SupportedEntry.Event,
flag: event.flag,
title: event.title,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart,
endAction: event.endAction,
skip: event.skip,
colour: event.colour,
parent: event.parent,
revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
triggers: structuredClone(event.triggers),
custom: structuredClone(event.custom),
};
};
@@ -1,4 +1,18 @@
// we need to this as a promise because safari
export default async function copyToClipboard(text: string) {
setTimeout(async () => await navigator.clipboard?.writeText(text));
/**
* copy text to clipboard
* @throws if not supported or permission denied
*/
export async function copyToClipboard(text: string) {
await navigator.clipboard?.writeText(text);
}
/**
* Copy to clipboard but safely ignore errors
*/
export async function safeCopyToClipboard(text: string): Promise<void> {
try {
await copyToClipboard(text);
} catch {
// Silently ignore errors
}
}
@@ -212,6 +212,11 @@ $inner-padding: 1rem;
&.apart {
justify-content: space-between;
}
&.wrap {
flex-wrap: wrap;
row-gap: 0.5rem;
}
}
@keyframes animloader {
@@ -107,7 +107,7 @@ export function BlockQuote({ children }: { children: ReactNode }) {
return <blockquote className={style.blockquote}>{children}</blockquote>;
}
export function Error({ children, className }: { children: ReactNode } & React.JSX.IntrinsicElements['div']) {
export function Error({ children, className }: PropsWithChildren & React.JSX.IntrinsicElements['div']) {
return <div className={cx([style.fieldError, className])}>{children}</div>;
}
@@ -131,16 +131,25 @@ type InlineProps<C extends AllowedInlineTags> = {
as?: C;
relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end' | 'apart';
className?: string;
};
wrap?: 'wrap' | 'nowrap';
} & Omit<React.JSX.IntrinsicElements[C], 'align'>;
export function InlineElements<C extends AllowedInlineTags = 'div'>({
children,
as,
relation = 'component',
align = 'start',
wrap = 'nowrap',
className,
...elementProps
}: PropsWithChildren<InlineProps<C>>) {
const Element = as ?? 'div';
return <Element className={cx([style.inlineElements, style[relation], style[align], className])}>{children}</Element>;
return (
<Element
{...(elementProps as HTMLAttributes<HTMLElement>)}
className={cx([style.inlineElements, style[relation], style[align], style[wrap], className])}
>
{children}
</Element>
);
}
@@ -230,10 +230,18 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div key={key} className={style.filterSection}>
<label>
Runtime data source
<Select
value={watch(`filters.${index}.field`)}
onValueChange={(value) => setValue(`filters.${index}.field`, value, { shouldDirty: true })}
options={fieldList.map(({ value, label }) => ({ value, label }))}
<Select<string | null>
// need to normalize '' to null for the Select to show the placeholder
value={watch(`filters.${index}.field`) || null}
onValueChange={(value) => {
if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true });
}}
options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field'
/>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
@@ -242,11 +250,14 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
Matching condition
<Select
value={watch(`filters.${index}.operator`)}
onValueChange={(value) =>
setValue(`filters.${index}.operator`, value as 'equals' | 'not_equals' | 'contains', {
shouldDirty: true,
})
}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(
`filters.${index}.operator`,
value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
{ shouldDirty: true },
);
}}
options={[
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
@@ -282,7 +293,8 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div className={style.innerColumn}>
<h3>Outputs</h3>
<Info>
Automation outputs can be used to send data from Ontime to external software.
Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself.
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
</Info>
@@ -44,8 +44,9 @@ export default function OntimeActionForm({
<label>
Action
<Select
onValueChange={(value) => {
handleSetAction(value as OntimeActionKey);
onValueChange={(value: OntimeActionKey | null) => {
if (value === null) return;
handleSetAction(value);
}}
value={watch(`outputs.${index}.action`)}
options={[
@@ -97,13 +98,13 @@ export default function OntimeActionForm({
Visibility
<Select
onValueChange={(value) => {
// we need to translate the undefined value to 'untouched'
const translatedValue = value === 'untouched' ? undefined : (value as boolean | undefined);
// we need to translate the null to undefined so it becomes 'untouched'
const translatedValue = value === null ? undefined : value;
setValue(`outputs.${index}.visible`, translatedValue, { shouldDirty: true });
}}
value={watch(`outputs.${index}.visible`) === undefined ? 'untouched' : watch(`outputs.${index}.visible`)}
value={watch(`outputs.${index}.visible`)}
options={[
{ value: 'untouched', label: 'Untouched' },
{ value: null, label: 'Untouched' },
{ value: true, label: 'Show' },
{ value: false, label: 'Hide' },
]}
@@ -116,23 +117,31 @@ export default function OntimeActionForm({
{selectedAction === 'message-secondary' && (
<label>
Timer secondary source
<Select
<Select<SecondarySource | 'null' | null>
onValueChange={(value) => {
setValue(`outputs.${index}.secondarySource`, value as SecondarySource, { shouldDirty: true });
// null -> no selection
if (value === null) return;
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`)}
options={[
{ value: null, label: 'Select secondary source', disabled: true },
{ value: null, label: 'Select secondary source' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'external', label: 'External' },
{ value: 'null', label: 'None' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
)}
<div className={style.test}>{children}</div>
</div>
);
@@ -106,7 +106,10 @@ export default function TriggerForm({
Lifecycle trigger
<Select
value={watch('trigger')}
onValueChange={(value) => setValue('trigger', value as TimerLifeCycle, { shouldDirty: true })}
onValueChange={(value) => {
if (value === null) return;
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
}}
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
aria-label='Lifecycle trigger'
/>
@@ -116,7 +119,10 @@ export default function TriggerForm({
Automation title
<Select
value={watch('automationId')}
onValueChange={(value) => setValue('automationId', value, { shouldDirty: true })}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue('automationId', value, { shouldDirty: true });
}}
options={automationSelect}
aria-label='Automation title'
/>
@@ -27,6 +27,7 @@ export function isAutomation(automation: AutomationDTO | Automation): automation
}
const staticSelectProperties = [
{ value: null, label: 'Select field' },
{ value: 'eventNow.id', label: 'ID' },
{ value: 'eventNow.title', label: 'Title' },
{ value: 'eventNow.cue', label: 'Cue' },
@@ -42,7 +43,7 @@ const staticNextSelectProperties = [
];
type SelectableField = {
value: string; // string encodes path in runtime state object
value: string | null; // string encodes path in runtime state object
label: string;
};
@@ -142,7 +142,10 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
options={targetOptions}
{...register('target', { required: 'Target is required' })}
value={watch('target')}
onValueChange={(value) => setValue('target', value, { shouldDirty: true })}
onValueChange={(value: OntimeViewPresettable | null) => {
if (value === null) return;
setValue('target', value, { shouldDirty: true });
}}
/>
</div>
<div>
@@ -51,7 +51,10 @@ export default function RundownDefaultSettings() {
/>
<Select
value={defaultTimeStrategy}
onValueChange={(value) => setTimeStrategy(value as TimeStrategy)}
onValueChange={(value: TimeStrategy | null) => {
if (value === null) return;
setTimeStrategy(value);
}}
options={[
{ value: TimeStrategy.LockDuration, label: 'Duration' },
{ value: TimeStrategy.LockEnd, label: 'End Time' },
@@ -73,7 +76,10 @@ export default function RundownDefaultSettings() {
<Panel.Field title='Timer type' description='Default type of timer for new events' />
<Select
value={defaultTimerType}
onValueChange={(value) => setDefaultTimerType(value as TimerType)}
onValueChange={(value: TimerType | null) => {
if (value === null) return;
setDefaultTimerType(value);
}}
options={[
{ value: TimerType.CountDown, label: 'Count down' },
{ value: TimerType.CountUp, label: 'Count up' },
@@ -86,7 +92,10 @@ export default function RundownDefaultSettings() {
<Panel.Field title='End Action' description='Default end action for new events' />
<Select
value={defaultEndAction}
onValueChange={(value) => setDefaultEndAction(value as EndAction)}
onValueChange={(value: EndAction | null) => {
if (value === null) return;
setDefaultEndAction(value);
}}
options={[
{ value: EndAction.None, label: 'None' },
{ value: EndAction.LoadNext, label: 'Load next' },
@@ -162,9 +162,10 @@ export default function ImportMapForm({
<Select
id={importName as string}
value={watch(label as keyof NamedImportMap) as string}
onValueChange={(value: string) =>
setValue(label as keyof NamedImportMap, value, { shouldDirty: true })
}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(label as keyof NamedImportMap, value, { shouldDirty: true });
}}
options={worksheetNames?.map((name) => ({ value: name, label: name })) || []}
/>
</td>
@@ -14,7 +14,7 @@ export default function InfoNif() {
const handleClick = (address: string) => openLink(address);
return (
<Panel.InlineElements>
<Panel.InlineElements wrap='wrap'>
{data.networkInterfaces.map((nif) => {
// interfaces outside localhost wont have access
if (nif.name === 'localhost' && !isLocalhost) return null;
@@ -34,7 +34,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
}
function OntimeCloudStats() {
const { ping } = usePing();
const ping = usePing();
/**
* Send immediate ping request, and keep sending on an interval
@@ -153,7 +153,10 @@ export default function GeneralSettings() {
/>
<Select
value={watch('timeFormat')}
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })}
onValueChange={(value: '12' | '24' | null) => {
if (value === null) return;
setValue('timeFormat', value, { shouldDirty: true });
}}
defaultValue='24'
options={[
{ value: '12', label: '12 hours 11:00:10 PM' },
@@ -169,7 +172,10 @@ export default function GeneralSettings() {
/>
<Select
value={watch('language')}
onValueChange={(value) => setValue('language', value, { shouldDirty: true })}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue('language', value, { shouldDirty: true });
}}
disabled={disableInputs}
defaultValue='en'
options={[
@@ -33,7 +33,6 @@ export default function ProjectData() {
setValue,
} = useForm({
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
@@ -75,7 +75,10 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
/>
<Select
value={watch('settings.timeFormat')}
onValueChange={(value: '12' | '24') => setValue('settings.timeFormat', value, { shouldDirty: true })}
onValueChange={(value: '12' | '24' | null) => {
if (value === null) return;
setValue('settings.timeFormat', value, { shouldDirty: true });
}}
defaultValue='24'
options={[
{ value: '12', label: '12 hours 11:00:10 PM' },
@@ -91,7 +94,10 @@ export default function QuickStart({ isOpen, onClose }: QuickStartProps) {
/>
<Select
value={watch('settings.language')}
onValueChange={(value: string) => setValue('settings.language', value, { shouldDirty: true })}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue('settings.language', value, { shouldDirty: true });
}}
defaultValue='en'
options={[
{ value: 'en', label: 'English' },
@@ -90,7 +90,10 @@ const staticOptions = [
},
] as const;
export type SettingsOptionId = (typeof staticOptions)[number]['id'];
// a child of navigation or a child of secondary navigation
export type SettingsOptionId =
| (typeof staticOptions)[number]['id']
| Extract<(typeof staticOptions)[number], { secondary: object }>['secondary'][number]['id'];
export function useAppSettingsMenu() {
const { data } = useAppVersion();
@@ -1,5 +1,3 @@
@import "./MessageControl.module.scss";
.inputItems {
display: grid;
grid-template-columns: 1fr auto;
@@ -8,5 +6,10 @@
}
.label {
@extend .label;
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
}
}
@@ -42,7 +42,7 @@ export default function InputRow(props: PropsWithChildren<InputRowProps>) {
};
return (
<div className={style.inputRow}>
<div>
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
{label}
</label>
@@ -0,0 +1,11 @@
.growPanel {
flex: 1;
}
.contentLayout {
display: flex;
flex-direction: column;
gap: $section-spacing;
color: $ui-white;
padding-top: $panel-top-padding;
}
@@ -1,34 +1,32 @@
import { memo } from 'react';
import { CornerExtract } from '../../../common/components/editor-utils/EditorUtils';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../../externals';
import MessageControl from './MessageControl';
import style from '../../../views/editor/Editor.module.scss';
import style from './MessageControlExport.module.scss';
export default memo(MessageControlExport);
function MessageControlExport() {
const isExtracted = window.location.pathname.includes('/messagecontrol');
const classes = cx([style.content, style.contentColumnLayout]);
return (
<ProtectRoute permission='editor'>
<div className={style.messages} data-testid='panel-messages-control'>
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
<Editor.Panel className={style.growPanel} data-testid='panel-messages-control'>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={classes}>
<div className={style.contentLayout}>
<ErrorBoundary>
<MessageControl />
</ErrorBoundary>
</div>
</div>
</Editor.Panel>
</ProtectRoute>
);
}
@@ -1,18 +1,3 @@
.label {
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
}
}
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 3fr 2fr;
}
.preview {
background-color: $ui-black;
display: grid;
@@ -21,21 +6,6 @@
position: relative;
}
.options {
display: flex;
flex-direction: column;
gap: $element-spacing;
}
.eventStatus {
position: absolute;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.mainContent {
font-size: 1rem;
font-weight: 600;
@@ -61,9 +31,13 @@
display: none;
}
.timerIndicators {
.eventStatus {
position: absolute;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.statusIcon {
@@ -73,3 +47,4 @@
color: $active-indicator;
}
}
@@ -10,7 +10,7 @@ import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
import style from './MessageControl.module.scss';
import style from './TimerPreview.module.scss';
const secondarySourceLabels: Record<string, string> = {
aux1: 'Aux 1',
@@ -0,0 +1,12 @@
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 3fr 2fr;
}
.options {
display: flex;
flex-direction: column;
gap: $element-spacing;
}
@@ -8,7 +8,7 @@ import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket
import TimerPreview from './TimerPreview';
import style from './MessageControl.module.scss';
import style from './TimerViewControl.module.scss';
export default function TimerControlsPreview() {
const { blackout, blink } = useTimerViewControl();
@@ -71,12 +71,13 @@ function SecondarySourceControl() {
{ value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Secondary message' },
]}
onValueChange={(value) => {
onValueChange={(value: SecondarySource | null) => {
if (value === null) return;
// we can only update the remote if it is enabled
if (secondarySource !== null) {
setMessage.timerSecondarySource(value as SecondarySource);
setMessage.timerSecondarySource(value);
}
setValue(value as SecondarySource);
setValue(value);
}}
/>
<Button
@@ -0,0 +1,3 @@
.content {
padding-top: $panel-top-padding;
}
@@ -1,6 +1,6 @@
import { memo } from 'react';
import { CornerExtract } from '../../../common/components/editor-utils/EditorUtils';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
@@ -9,7 +9,7 @@ import { getIsNavigationLocked } from '../../../externals';
import PlaybackControl from './PlaybackControl';
import style from '../../../views/editor/Editor.module.scss';
import style from './TimerControlExport.module.scss';
export default memo(TimerControlExport);
function TimerControlExport() {
@@ -17,8 +17,8 @@ function TimerControlExport() {
return (
<ProtectRoute permission='editor'>
<div className={style.playback} data-testid='panel-timer-control'>
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('timercontrol', event)} />}
<Editor.Panel data-testid='panel-timer-control'>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('timercontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.content}>
@@ -26,7 +26,7 @@ function TimerControlExport() {
<PlaybackControl />
</ErrorBoundary>
</div>
</div>
</Editor.Panel>
</ProtectRoute>
);
}
@@ -41,5 +41,4 @@
padding-inline: 0.5em;
outline: none;
}
}
@@ -1,9 +1,8 @@
import { useMemo } from 'react';
import { IoPause, IoPlay, IoPlaySkipBack, IoPlaySkipForward, IoReload, IoStop } from 'react-icons/io5';
import { Playback, TimerPhase } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { setPlayback } from '../../../../common/hooks/useSocket';
import { getPlaybackControlState } from '../playbackControl.utils';
import TapButton from '../tap-button/TapButton';
import style from './PlaybackButtons.module.scss';
@@ -15,44 +14,32 @@ interface PlaybackButtonsProps {
timerPhase: TimerPhase;
}
export default function PlaybackButtons(props: PlaybackButtonsProps) {
const { playback, numEvents, selectedEventIndex, timerPhase } = props;
const isRolling = playback === Playback.Roll;
const isPlaying = playback === Playback.Play;
const isPaused = playback === Playback.Pause;
const isArmed = playback === Playback.Armed;
const isFirst = selectedEventIndex === 0;
const isLast = selectedEventIndex === numEvents - 1;
const noEvents = numEvents === 0;
const disableGo = isRolling || noEvents;
const disableNext = isRolling || noEvents || isLast;
const disablePrev = isRolling || noEvents || isFirst;
const playbackCan = validatePlayback(playback, timerPhase);
const disableStart = !playbackCan.start;
const disablePause = !playbackCan.pause;
const disableRoll = !playbackCan.roll || noEvents;
const disableStop = !playbackCan.stop;
const disableReload = !playbackCan.reload;
const [goModeAction, goModeText] = useMemo(() => {
if (isArmed) {
return [setPlayback.start, 'Start'];
} else if (isLast) {
return [setPlayback.stop, 'Finish'];
} else if (selectedEventIndex === null) {
return [setPlayback.startNext, 'Start'];
}
return [setPlayback.startNext, 'Next'];
}, [isArmed, isLast, selectedEventIndex]);
export default function PlaybackButtons({ playback, numEvents, selectedEventIndex, timerPhase }: PlaybackButtonsProps) {
const {
isPlaying,
isPaused,
isRolling,
disableGo,
disableNext,
disablePrev,
disableStart,
disablePause,
disableRoll,
disableStop,
disableReload,
goAction,
goLabel,
} = getPlaybackControlState({
playback,
numEvents,
selectedEventIndex,
timerPhase,
});
return (
<div className={style.buttonContainer}>
<TapButton disabled={disableGo} onClick={goModeAction} aspect='fill' className={style.go}>
{goModeText}
<TapButton disabled={disableGo} onClick={goAction} aspect='fill' className={style.go}>
{goLabel}
</TapButton>
<div className={style.playbackContainer}>
<TapButton onClick={setPlayback.start} disabled={disableStart} theme={Playback.Play} active={isPlaying}>
@@ -8,6 +8,13 @@
gap: $element-inner-spacing;
}
.timerDisplay {
grid-area: timer;
max-width: 18.75rem;
min-width: 5em;
text-align: center;
}
// ---------> INDICATORS
.indicators {
@@ -42,7 +42,7 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
<div className={style.indicatorNegative} data-active={isOvertime} />
<Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} />
</div>
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} phase={timer.phase} />
<div className={style.status}>
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
@@ -0,0 +1,95 @@
import { Playback, TimerPhase } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { setPlayback } from '../../../common/hooks/useSocket';
export interface PlaybackControlInput {
playback: Playback;
numEvents: number;
selectedEventIndex: number | null;
timerPhase: TimerPhase;
}
export interface PlaybackControlState {
// Playback states
isPlaying: boolean;
isPaused: boolean;
isRolling: boolean;
isArmed: boolean;
isStopped: boolean;
// Position states
isFirst: boolean;
isLast: boolean;
noEvents: boolean;
// Disable flags
disableGo: boolean;
disableNext: boolean;
disablePrev: boolean;
disableStart: boolean;
disablePause: boolean;
disableRoll: boolean;
disableStop: boolean;
disableReload: boolean;
disableAddTime: boolean;
// Go button configuration
goAction: () => void;
goLabel: 'Start' | 'Next' | 'Finish';
}
/**
* Centralized playback control state calculator.
* Consolidates all playback logic, disable states, and derived values in one place.
*/
export function getPlaybackControlState({
playback,
numEvents,
selectedEventIndex,
timerPhase,
}: PlaybackControlInput): PlaybackControlState {
const isFirst = selectedEventIndex === 0;
const isLast = selectedEventIndex === numEvents - 1;
const noEvents = numEvents === 0;
const isRolling = playback === Playback.Roll;
const playbackCan = validatePlayback(playback, timerPhase);
const { action: goAction, label: goLabel } = getGoAction(playback, selectedEventIndex, isLast);
return {
isPlaying: playback === Playback.Play,
isPaused: playback === Playback.Pause,
isRolling,
isArmed: playback === Playback.Armed,
isStopped: playback === Playback.Stop,
isFirst,
isLast,
noEvents,
disableGo: isRolling || noEvents,
disableNext: isRolling || noEvents || isLast,
disablePrev: isRolling || noEvents || isFirst,
disableStart: !playbackCan.start,
disablePause: !playbackCan.pause,
disableRoll: !playbackCan.roll || noEvents,
disableStop: !playbackCan.stop,
disableReload: !playbackCan.reload,
disableAddTime: playback !== Playback.Play && playback !== Playback.Pause,
goAction,
goLabel,
};
}
/**
* Determines the action and label for the "Go" button based on playback state.
*/
function getGoAction(
playback: Playback,
selectedEventIndex: number | null,
isLast: boolean,
): { action: () => void; label: 'Start' | 'Next' | 'Finish' } {
if (playback === Playback.Armed) return { action: setPlayback.start, label: 'Start' };
if (isLast) return { action: setPlayback.stop, label: 'Finish' };
if (selectedEventIndex === null) return { action: setPlayback.startNext, label: 'Start' };
return { action: setPlayback.startNext, label: 'Next' };
}
@@ -1,23 +1,34 @@
@use '@/theme/viewerDefs' as *;
.timer {
grid-area: timer;
white-space: nowrap;
max-width: 18.75rem;
min-width: 5em;
color: $timer-color;
line-height: 0.9em;
text-align: center;
line-height: 0.9;
letter-spacing: 0.1em;
font-weight: 600;
font-size: 3.5rem;
&.finished {
color: $timer-finished-color;
&[data-phase='default'] {
color: $timer-color;
}
&.muted {
&[data-phase='warning'] {
color: $warning-orange;
}
&[data-phase='danger'] {
color: $error-red;
}
&[data-phase='overtime'] {
color: $playback-negative;
}
&[data-phase='pending'] {
color: $ontime-roll;
}
&[data-phase='none'] {
color: $muted-gray;
}
}
@@ -1,4 +1,4 @@
import { MaybeNumber } from 'ontime-types';
import { MaybeNumber, TimerPhase } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { cx, timerPlaceholder } from '../../../../common/utils/styleUtils';
@@ -7,19 +7,20 @@ import style from './TimerDisplay.module.scss';
interface TimerDisplayProps {
time: MaybeNumber;
phase: TimerPhase;
className?: string;
}
/**
* Displays time in ms in formatted timetag
* Used in editor
*/
export default function TimerDisplay(props: TimerDisplayProps) {
const { time } = props;
export default function TimerDisplay({ time, phase, className }: TimerDisplayProps) {
const display = millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
const isNegative = (time ?? 0) < 0;
const display =
time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
const classes = cx([style.timer, isNegative ? style.finished : null, time === null && style.muted]);
return <div className={classes}>{display}</div>;
return (
<div className={cx([style.timer, className])} data-phase={phase}>
{display}
</div>
);
}
@@ -0,0 +1,146 @@
@use '../../../../theme/ontimeColours' as *;
@use '../../../../theme/ontimeStyles' as *;
$element-height: 4.5rem; // matches go button height in PlaybackButtons
.container {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
gap: 1rem;
padding: $panel-gap $section-spacing;
background: $bg-container-l2;
border-radius: $panel-border-radius;
}
.itemGroup {
display: flex;
align-items: center;
gap: $element-spacing;
flex-shrink: 0;
}
.goButton,
.iconButtonWithLabel,
.iconButton {
width: $element-height;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
svg {
font-size: 1.25rem;
}
}
.goButton {
height: $element-height;
min-width: 8rem;
line-height: 1.1;
}
.goLabel {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.2;
}
.shortcutHint {
font-size: calc(1rem - 4px);
opacity: 0.6;
}
.addSection {
display: contents;
}
.separator {
width: 1px;
height: 1.5rem;
background: $white-10;
margin-inline: 0.25rem;
}
.timerSection {
display: flex;
justify-content: center;
min-width: 0;
gap: 0.5rem;
}
.negativeIndicator {
font-size: 2.25rem;
font-weight: 700;
line-height: 1;
color: $playback-negative;
opacity: 0;
&[data-active='true'] {
opacity: 1;
}
}
// -----> Aux timer section (right)
.auxSection {
justify-self: end;
display: flex;
align-items: center;
gap: $element-inner-spacing;
flex-shrink: 0;
}
.auxControls {
display: flex;
gap: $element-inner-spacing;
}
.auxTimeInput,
.auxTimeDisplay {
width: 6.5rem;
min-width: 6.5rem;
max-width: 6.5rem;
height: $element-height;
text-align: center;
background: $gray-1050;
border-radius: $component-border-radius-md;
color: $ui-white;
}
.auxTimeDisplay {
display: grid;
place-content: center;
font-size: 1rem;
font-weight: 400;
color: $gray-200;
font-variant-numeric: tabular-nums;
letter-spacing: 0.5px;
padding-inline: 0.5em;
outline: none;
}
// hardcoded value where the timer is over the buttons
@media (max-width: 1048px) {
.container {
grid-template-columns: 1fr 1fr;
}
.timerSection {
justify-self: end;
}
.auxSection {
display: none;
}
}
// hardcoded value where the timer is over the buttons
@media (max-width: 728px) {
.addSection {
display: none;
}
}
@@ -0,0 +1,184 @@
import { IoAdd, IoArrowDown, IoArrowUp, IoPause, IoPlay, IoPlaySkipForward, IoRemove, IoStop } from 'react-icons/io5';
import { useHotkeys, useLocalStorage } from '@mantine/hooks';
import { Playback, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import {
setAuxTimer,
setPlayback,
useAuxTimerControl,
useAuxTimerTime,
usePlaybackControl,
useTimer,
} from '../../../../common/hooks/useSocket';
import { enDash } from '../../../../common/utils/styleUtils';
import { formatDuration } from '../../../../common/utils/time';
import { getPlaybackControlState } from '../playbackControl.utils';
import TapButton from '../tap-button/TapButton';
import TimerDisplay from '../timer-display/TimerDisplay';
import style from './TrackingPlaybackBar.module.scss';
export default function TrackingPlaybackBar() {
const timer = useTimer();
const { playback, numEvents, selectedEventIndex } = usePlaybackControl();
const { playback: auxPlayback, direction: auxDirection } = useAuxTimerControl(1);
const auxTime = useAuxTimerTime(1);
const [addTimeInMs] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 });
const { disableGo, disableNext, disableAddTime, isPlaying, goAction, goLabel } = getPlaybackControlState({
playback,
numEvents,
selectedEventIndex,
timerPhase: timer.phase,
});
const disableAddTimeWithAmount = disableAddTime || addTimeInMs === 0;
const handleAddTime = (direction: 'add' | 'remove') => {
if (disableAddTimeWithAmount) return;
if (direction === 'add') {
setPlayback.addTime(addTimeInMs);
} else {
setPlayback.addTime(-1 * addTimeInMs);
}
};
const handleAuxPlayPause = () => {
if (auxPlayback === SimplePlayback.Start) {
setAuxTimer.pause(1);
} else {
setAuxTimer.start(1);
}
};
const handleAuxStop = () => {
setAuxTimer.stop(1);
};
const handleAuxDirectionToggle = () => {
const newDirection =
auxDirection === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
setAuxTimer.setDirection(1, newDirection);
};
const handleAuxTimeChange = (_field: string, value: string) => {
const newTimeInMs = parseUserTime(value);
setAuxTimer.setDuration(1, newTimeInMs);
};
useHotkeys([
['Space', () => !disableGo && goAction(), { preventDefault: true }],
['N', () => !disableNext && setPlayback.next(), { preventDefault: true }],
['Escape', () => playback !== Playback.Stop && setPlayback.stop(), { preventDefault: true }],
]);
const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime;
const displayTime = isWaiting ? timer.secondaryTimer : timer.current;
const addTimeLabel = formatDuration(addTimeInMs);
return (
<div className={style.container}>
<div className={style.itemGroup}>
<TapButton
onClick={goAction}
disabled={disableGo}
theme={Playback.Play}
active={isPlaying}
className={style.goButton}
>
<span className={style.goLabel}>{goLabel}</span>
<span className={style.shortcutHint}>[space]</span>
</TapButton>
<TapButton
onClick={setPlayback.next}
disabled={disableNext}
className={style.iconButtonWithLabel}
aspect='square'
>
<IoPlaySkipForward />
<span className={style.shortcutHint}>[n]</span>
</TapButton>
<TapButton
onClick={setPlayback.stop}
disabled={playback === Playback.Stop}
className={style.iconButtonWithLabel}
aspect='square'
>
<IoStop />
<span className={style.shortcutHint}>[esc]</span>
</TapButton>
<div className={style.addSection}>
<div className={style.separator} />
<TapButton
onClick={() => handleAddTime('remove')}
disabled={disableAddTimeWithAmount}
className={style.iconButtonWithLabel}
aspect='square'
>
<IoRemove />
{addTimeLabel}
</TapButton>
<TapButton
onClick={() => handleAddTime('add')}
disabled={disableAddTimeWithAmount}
className={style.iconButtonWithLabel}
aspect='square'
>
<IoAdd />
{addTimeLabel}
</TapButton>
</div>
</div>
<div className={style.timerSection}>
<span className={style.negativeIndicator} data-active={isOvertime}>
{enDash}
</span>
<TimerDisplay time={displayTime} phase={timer.phase} />
</div>
<div className={style.auxSection}>
<TapButton onClick={handleAuxPlayPause} className={style.iconButton} theme={Playback.Play} aspect='square'>
{auxPlayback === SimplePlayback.Start ? <IoPause /> : <IoPlay />}
</TapButton>
<TapButton
onClick={handleAuxStop}
disabled={auxPlayback === SimplePlayback.Stop}
className={style.iconButton}
theme={Playback.Stop}
aspect='square'
>
<IoStop />
</TapButton>
{auxPlayback !== SimplePlayback.Stop ? (
<div className={style.auxTimeDisplay}>{millisToString(auxTime)}</div>
) : (
<TimeInput
name='aux1-tracking'
submitHandler={handleAuxTimeChange}
time={auxTime}
className={style.auxTimeInput}
/>
)}
<TapButton
onClick={handleAuxDirectionToggle}
disabled={auxPlayback !== SimplePlayback.Stop}
className={style.iconButton}
aspect='square'
>
{auxDirection === SimpleDirection.CountDown ? <IoArrowDown /> : <IoArrowUp />}
</TapButton>
</div>
</div>
);
}
@@ -1,84 +1,10 @@
import { use, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
/** @url https://developer.mozilla.org/en-US/docs/Web/API/WakeLock */
export default function KeepAwake() {
const { keepAwake } = useKeepAwakeOptions();
const [wakeLockSentinel, setWakeLockSentinel] = useState<WakeLockSentinel | null>(null);
const removeLock = () => {
if (wakeLockSentinel) wakeLockSentinel.release().finally(() => setWakeLockSentinel(null));
};
const acquireLock = () => {
if (!wakeLockSentinel || wakeLockSentinel.released) {
setWakeLockSentinel(null);
navigator.wakeLock
.request('screen')
.then((sentinel) => {
setWakeLockSentinel(sentinel);
})
.catch(console.error);
}
};
useEffect(() => {
const controller = new AbortController();
if (keepAwake) {
acquireLock();
document.addEventListener(
'visibilitychange',
() => {
if (wakeLockSentinel !== null && document.visibilityState === 'visible') {
acquireLock();
}
},
{ signal: controller.signal },
);
} else {
removeLock();
}
return () => {
controller.abort();
removeLock();
};
}, [keepAwake]);
return <></>;
}
const keepAwakeKey = 'keep-awake';
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
// Helper to get value from either source, prioritizing defaultValues
return defaultValues?.has(keepAwakeKey) || searchParams.has(keepAwakeKey);
}
import { useWakelock } from './useWakeLock';
/**
* Hook exposes the keep awake options
* Composes the wakelock hook to contain re-renders
*/
export function useKeepAwakeOptions() {
const [searchParams, setSearchParams] = useSearchParams();
const maybePreset = use(PresetContext);
export default function KeepAwake() {
useWakelock();
const keepAwake = useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
const toggleKeepAwake = useCallback(() => {
setSearchParams((searchParams) => {
if (keepAwake) {
searchParams.delete(keepAwakeKey);
} else {
searchParams.set(keepAwakeKey, '1');
}
return searchParams;
});
}, [keepAwake]);
return { keepAwake, toggleKeepAwake };
return null;
}
@@ -0,0 +1,96 @@
import { use, useCallback, useEffect, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
// wakelock is only available in secure contexts
// however, that is covered by the navigator check
export const canUseWakeLock = typeof window !== 'undefined' && 'wakeLock' in navigator;
/** @url https://developer.mozilla.org/en-US/docs/Web/API/WakeLock */
export function useWakelock() {
const { keepAwake } = useKeepAwakeOptions();
const wakeLockSentinelRef = useRef<WakeLockSentinel | null>(null);
useEffect(() => {
const removeLock = () => {
if (wakeLockSentinelRef.current) {
wakeLockSentinelRef.current.release().finally(() => {
wakeLockSentinelRef.current = null;
});
}
};
const acquireLock = () => {
const sentinel = wakeLockSentinelRef.current;
if (!sentinel || sentinel.released) {
wakeLockSentinelRef.current = null;
if (!canUseWakeLock) {
return;
}
navigator.wakeLock
.request('screen')
.then((sentinel) => {
wakeLockSentinelRef.current = sentinel;
})
.catch(console.error);
}
};
const controller = new AbortController();
if (keepAwake) {
acquireLock();
document.addEventListener(
'visibilitychange',
() => {
if (wakeLockSentinelRef.current !== null && document.visibilityState === 'visible') {
acquireLock();
}
},
{ signal: controller.signal },
);
} else {
removeLock();
}
return () => {
controller.abort();
removeLock();
};
}, [keepAwake]);
}
const keepAwakeKey = 'keep-awake';
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
// Helper to get value from either source, prioritizing defaultValues
return defaultValues?.has(keepAwakeKey) || searchParams.has(keepAwakeKey);
}
/**
* Hook exposes the keep awake options
*/
export function useKeepAwakeOptions() {
const [searchParams, setSearchParams] = useSearchParams();
const maybePreset = use(PresetContext);
const keepAwake = useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
const toggleKeepAwake = useCallback(() => {
setSearchParams((searchParams) => {
if (keepAwake) {
searchParams.delete(keepAwakeKey);
} else {
searchParams.set(keepAwakeKey, '1');
}
return searchParams;
});
}, [keepAwake, setSearchParams]);
return { keepAwake, toggleKeepAwake };
}
@@ -12,7 +12,7 @@ import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals';
import Loader from '../../views/common/loader/Loader';
import EditModal from './edit-modal/EditModal';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton';
import OperatorEvent from './operator-event/OperatorEvent';
import OperatorGroup from './operator-group/OperatorGroup';
@@ -43,7 +43,7 @@ export default function OperatorLoader() {
}
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const { selectedEventId } = useSelectedEventId();
const selectedEventId = useSelectedEventId();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -118,7 +118,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
return (
<div className={style.operatorContainer} data-testid='operator-view'>
<ViewParamsEditor target={OntimeView.Operator} viewOptions={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
{editEvent && <CustomFieldEditModal event={editEvent} onClose={() => setEditEvent(null)} />}
<StatusBar />
@@ -173,7 +173,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId].groupId === entry.id : false;
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
@@ -1,6 +1,6 @@
import { Fragment, useRef, useState } from 'react';
import { IoClose } from 'react-icons/io5';
import { Dialog } from '@base-ui-components/react/dialog';
import { Dialog } from '@base-ui/react/dialog';
import { OntimeEvent } from 'ontime-types';
import Button from '../../../common/components/buttons/Button';
@@ -9,14 +9,14 @@ import Textarea from '../../../common/components/input/textarea/Textarea';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { EditEvent } from '../operator.types';
import style from './EditModal.module.scss';
import style from './CustomFieldEditModal.module.scss';
interface EditModalProps {
interface CustomFieldEditModalProps {
event: EditEvent;
onClose: () => void;
}
export default function EditModal(props: EditModalProps) {
export default function CustomFieldEditModal(props: CustomFieldEditModalProps) {
const { event, onClose } = props;
const { updateEntry } = useEntryActions();
@@ -1,5 +1,4 @@
@use '@/theme/viewerDefs' as *;
@import '../Operator.module.scss';
.event {
opacity: 1;
@@ -5,8 +5,8 @@ import DelayIndicator from '../../../common/components/delay-indicator/DelayIndi
import { useLongPress } from '../../../common/hooks/useLongPress';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime, useTimeUntilExpectedStart } from '../../../common/utils/time';
import RunningTime from '../../viewers/common/running-time/RunningTime';
import SuperscriptPeriod from '../../viewers/common/superscript-time/SuperscriptPeriod';
import RunningTime from '../../../views/common/running-time/RunningTime';
import SuperscriptPeriod from '../../../views/common/superscript-time/SuperscriptPeriod';
import type { EditEvent, Subscribed } from '../operator.types';
import style from './OperatorEvent.module.scss';
@@ -10,7 +10,7 @@ import {
makeOptionsFromCustomFields,
} from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../viewers/common/viewUtils';
import { isStringBoolean } from '../../views/common/viewUtils';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, [
@@ -1,6 +1,6 @@
import { CustomFields, MaybeString, OntimeEvent } from 'ontime-types';
import { getPropertyValue } from '../viewers/common/viewUtils';
import { getPropertyValue } from '../../views/common/viewUtils';
import type { Subscribed } from './operator.types';
@@ -2,7 +2,13 @@ import { memo, PropsWithChildren } from 'react';
import { useIsMobileScreen } from '../../common/hooks/useIsMobileScreen';
import { ClockOverview, MetadataTimes, OffsetOverview, StartTimes, TimerOverview } from './composite/TimeElements';
import {
ClockOverview,
MetadataTimes,
OffsetOverview,
StartTimesRuntime,
TimerOverview,
} from './composite/TimeElements';
import TitleOverview from './composite/TitleOverview';
import { OverviewWrapper } from './OverviewWrapper';
@@ -10,8 +16,10 @@ export default memo(CuesheetOverview);
function CuesheetOverview({ children }: PropsWithChildren) {
const isMobileScreen = useIsMobileScreen();
if (isMobileScreen) return <CuesheetMobile>{children}</CuesheetMobile>;
else return <CuesheetDesktop>{children}</CuesheetDesktop>;
if (isMobileScreen) {
return <CuesheetMobile>{children}</CuesheetMobile>;
}
return <CuesheetDesktop>{children}</CuesheetDesktop>;
}
function CuesheetMobile({ children }: PropsWithChildren) {
@@ -27,7 +35,7 @@ function CuesheetDesktop({ children }: PropsWithChildren) {
return (
<OverviewWrapper navElements={children}>
<TitleOverview />
<StartTimes shouldFormat />
<StartTimesRuntime shouldFormat />
<TimerOverview />
<OffsetOverview />
<MetadataTimes />
@@ -1,19 +1,73 @@
import { memo, PropsWithChildren } from 'react';
import { ClockOverview, MetadataTimes, OffsetOverview, ProgressOverview, StartTimes } from './composite/TimeElements';
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
import {
ClockOverview,
MetadataTimes,
OffsetOverview,
PlanningStats,
ProgressOverview,
StartTimesPlanning,
StartTimesRuntime,
} from './composite/TimeElements';
import TitleOverview from './composite/TitleOverview';
import { OverviewWrapper } from './OverviewWrapper';
import style from './Overview.module.scss';
export default memo(EditorOverview);
function EditorOverview({ children }: PropsWithChildren) {
const { layoutMode } = useEditorLayout();
return (
<OverviewWrapper navElements={children}>
<TitleOverview />
<StartTimes />
<ProgressOverview />
<OffsetOverview />
<MetadataTimes />
<ClockOverview />
{layoutMode === EditorLayoutMode.PLANNING && <OverviewPlanning />}
{layoutMode === EditorLayoutMode.TRACKING && <OverviewTracking />}
{layoutMode === EditorLayoutMode.CONTROL && <OverviewControl />}
</OverviewWrapper>
);
}
function OverviewPlanning() {
return (
<>
<div className={style.inline}>
<TitleOverview />
<StartTimesPlanning />
<PlanningStats />
</div>
<ClockOverview />
</>
);
}
function OverviewTracking() {
return (
<>
<div className={style.inline}>
<StartTimesRuntime />
<ProgressOverview />
<OffsetOverview />
</div>
<MetadataTimes />
<ClockOverview />
</>
);
}
function OverviewControl() {
return (
<>
<TitleOverview />
<div className={style.inline}>
<StartTimesRuntime />
<ProgressOverview />
<OffsetOverview />
</div>
<MetadataTimes />
<ClockOverview />
</>
);
}
@@ -29,11 +29,22 @@
}
.info {
flex: 1;
padding-left: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
overflow-x: auto;
overflow-y: hidden;
width: max-content;
height: 100%;
}
.infoScroll {
flex: 1;
min-width: 0;
height: 100%;
}
.inline {
display: flex;
align-items: center;
gap: 1rem;
}
@@ -1,6 +1,7 @@
import { PropsWithChildren, ReactNode } from 'react';
import { ErrorBoundary } from '@sentry/react';
import ScrollArea from '../../common/components/scroll-area/ScrollArea';
import { useIsOnline } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils';
@@ -11,12 +12,20 @@ interface OverviewWrapperProps {
}
export function OverviewWrapper({ navElements, children }: PropsWithChildren<OverviewWrapperProps>) {
const { isOnline } = useIsOnline();
const isOnline = useIsOnline();
return (
<div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary>
<div className={style.nav}>{navElements}</div>
<div className={style.info}>{children}</div>
<ScrollArea
className={style.infoScroll}
contentClassName={style.info}
contentStyle={{ minWidth: '100%' }}
orientation='horizontal'
>
{children}
</ScrollArea>
</ErrorBoundary>
</div>
);
@@ -14,6 +14,13 @@
gap: 0.5rem;
}
.row2 {
display: grid;
grid-template-columns: 3rem 9rem;
align-items: center;
gap: 0.5rem;
}
.metadataRow {
display: grid;
grid-template-columns: minmax(3rem, 10rem) 8rem 8rem;
@@ -27,8 +27,8 @@ import {
import { useEntry } from '../../../common/hooks-query/useRundown';
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import SuperscriptPeriod from '../../viewers/common/superscript-time/SuperscriptPeriod';
import { formatDuration, formatTime } from '../../../common/utils/time';
import SuperscriptPeriod from '../../../views/common/superscript-time/SuperscriptPeriod';
import { calculateEndAndDaySpan, formatDueTime } from '../overview.utils';
import { OverUnder, TimeColumn, WrappedInTimeColumn } from './TimeLayout';
@@ -39,30 +39,22 @@ interface OverviewTimeElementsProps {
shouldFormat?: boolean;
}
export function StartTimes({ shouldFormat }: OverviewTimeElementsProps) {
const timeFormatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' };
function formatTimeValue(time: number | null, shouldFormat: boolean | undefined): string {
if (time === null) return timerPlaceholder;
if (shouldFormat) return formatTime(time, timeFormatOptions);
return millisToString(time, { fallback: timerPlaceholder });
}
export function StartTimesRuntime({ shouldFormat }: OverviewTimeElementsProps) {
const { plannedEnd, plannedStart, actualStart } = useStartTimesOverview();
const formatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' };
const plannedStartText = (() => {
if (plannedStart === null) return timerPlaceholder;
if (shouldFormat) return formatTime(plannedStart, formatOptions);
return millisToString(plannedStart, { fallback: timerPlaceholder });
})();
const actualStartText = (() => {
if (actualStart === null) return timerPlaceholder;
if (shouldFormat) return formatTime(actualStart, formatOptions);
return millisToString(actualStart, { fallback: timerPlaceholder });
})();
const plannedStartText = formatTimeValue(plannedStart, shouldFormat);
const actualStartText = formatTimeValue(actualStart, shouldFormat);
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const plannedEndText = (() => {
if (maybePlannedEnd === null) return timerPlaceholder;
if (shouldFormat) return formatTime(maybePlannedEnd, formatOptions);
return millisToString(maybePlannedEnd, { fallback: timerPlaceholder });
})();
const plannedEndText = formatTimeValue(maybePlannedEnd, shouldFormat);
const multipleDays = maybePlannedDaySpan > 0;
const plannedEndTooltip = multipleDays
@@ -122,19 +114,68 @@ export function StartTimes({ shouldFormat }: OverviewTimeElementsProps) {
);
}
export function StartTimesPlanning({ shouldFormat }: OverviewTimeElementsProps) {
const { plannedEnd, plannedStart } = useStartTimesOverview();
const plannedStartText = formatTimeValue(plannedStart, shouldFormat);
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const plannedEndText = formatTimeValue(maybePlannedEnd, shouldFormat);
const multipleDays = maybePlannedDaySpan > 0;
const plannedEndTooltip = multipleDays
? `Planned end time (rundown spans over ${maybePlannedDaySpan + 1} days)`
: 'Planned end time';
return (
<div className={style.column}>
<div className={style.row2}>
<span className={style.label}>Start</span>
<Tooltip
text='Planned start time'
render={
<div className={style.labelledElement}>
<TbCalendarPin className={style.icon} />
<SuperscriptPeriod
className={cx([style.time, plannedStart === null && style.muted])}
time={plannedStartText}
/>
</div>
}
/>
</div>
<div className={style.row2}>
<span className={style.label}>End</span>
<Tooltip
text={plannedEndTooltip}
render={
<div className={style.labelledElement}>
<TbCalendarPin className={style.icon} />
<SuperscriptPeriod
className={cx([style.time, plannedEnd === null && style.muted])}
time={plannedEndText}
/>
{multipleDays && (
<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />
)}
</div>
}
/>
</div>
</div>
);
}
/**
* Shows the expected end for the rundown
* Extracted to improve performance as this is a ticking value
*/
function RundownExpectedEnd({ shouldFormat }: OverviewTimeElementsProps) {
const { expectedEnd } = useRundownExpectedEnd();
const expectedEnd = useRundownExpectedEnd();
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const maybeExpectedEndText = (() => {
if (maybeExpectedEnd === null) return timerPlaceholder;
if (shouldFormat) return formatTime(maybeExpectedEnd, { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' });
return millisToString(maybeExpectedEnd, { fallback: timerPlaceholder });
})();
const maybeExpectedEndText = formatTimeValue(maybeExpectedEnd, shouldFormat);
const multipleDays = maybeExpectedEnd !== null && maybeExpectedDaySpan > 0;
const tooltip = multipleDays
@@ -169,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView();
const { currentGroupId } = useCurrentGroupId();
const currentGroupId = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null;
const active = isPlaybackActive(playback);
@@ -298,7 +339,7 @@ export function OffsetOverview() {
}
export function ClockOverview({ shouldFormat, className }: OverviewTimeElementsProps & { className?: string }) {
const { clock } = useClock();
const clock = useClock();
const formattedClock = shouldFormat ? formatTime(clock) : millisToString(clock);
return (
@@ -316,11 +357,27 @@ export function TimerOverview({ className }: { className?: string }) {
const isWaiting = timer.phase === TimerPhase.Pending;
const title = isWaiting ? 'Count to start' : 'Running timer';
const display = millisToString(isWaiting ? timer.secondaryTimer : timer.current, { fallback: timerPlaceholder });
const timerState = (() => {
function getTimerState(): 'waiting' | 'muted' | 'active' {
if (isWaiting) return 'waiting';
if (timer.current === null) return 'muted';
return 'active';
})();
}
return <TimeColumn label={title} value={display} state={timerState} className={className} />;
return <TimeColumn label={title} value={display} state={getTimerState()} className={className} />;
}
export function PlanningStats() {
const { numEvents } = useProgressOverview();
const { plannedEnd, plannedStart } = useStartTimesOverview();
const hasTimes = plannedStart !== null && plannedEnd !== null;
const formattedDuration = hasTimes ? formatDuration(plannedEnd - plannedStart) : timerPlaceholder;
return (
<>
<TimeColumn label='Total duration' value={formattedDuration} />
<TimeColumn label='Events' value={String(numEvents)} />
</>
);
}
@@ -4,9 +4,14 @@
}
.rundownContainer {
margin-top: 1rem;
overflow-y: scroll;
height: 100%;
padding-top: 1rem;
flex: 1 1 auto;
min-height: 0;
:is([data-target='small-device']) & {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
.list {
@@ -18,6 +23,7 @@
:is([data-target='small-device']) & {
padding-inline: 0;
overflow-x: auto;
min-width: max-content;
}
}
+274 -532
View File
@@ -1,51 +1,33 @@
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import { type HTMLProps, forwardRef, Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TbFlagFilled } from 'react-icons/tb';
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { closestCenter, DndContext } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys, useSessionStorage } from '@mantine/hooks';
import {
type EntryId,
type MaybeString,
type Rundown,
type Rundown as RundownType,
isOntimeEvent,
isOntimeGroup,
OntimeEntry,
Playback,
SupportedEntry,
} from 'ontime-types';
import {
getFirstNormal,
getLastNormal,
getNextGroupNormal,
getNextNormal,
getPreviousGroupNormal,
getPreviousNormal,
reorderArray,
} from 'ontime-utils';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/clone';
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import { AppMode } from '../../ontimeConfig';
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
import { useRundownCommands } from './hooks/useRundownCommands';
import { useRundownDnd } from './hooks/useRundownDnd';
import { useRundownKeyboard } from './hooks/useRundownKeyboard';
import RundownGroup from './rundown-group/RundownGroup';
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
import { canDrop, makeSortableList } from './rundown.utils';
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEditorFollowMode } from './useEditorFollowMode';
import { useEventSelection } from './useEventSelection';
import style from './Rundown.module.scss';
@@ -53,545 +35,305 @@ import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps {
data: Rundown;
entries: RundownType['entries'];
id: RundownType['id'];
order: RundownType['order'];
flatOrder: RundownType['flatOrder'];
rundownMetadata: RundownMetadataObject;
featureData: {
playback: Playback;
selectedEventId: EntryId | null;
nextEventId: EntryId | null;
};
}
export default function Rundown({ data, rundownMetadata }: RundownProps) {
const { order, entries, id } = data;
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor();
export default function Rundown({ order, flatOrder, entries, id, rundownMetadata, featureData }: RundownProps) {
// invoke the compiler for the component
'use memo';
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
const [metadata, setMetadata] = useState(rundownMetadata);
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown
key: `rundown.${id}-editor-collapsed-groups`,
defaultValue: [],
});
const { addEntry, deleteEntry, move, reorderEntry } = useEntryActions();
const { entryCopyId, setEntryCopyId } = useEntryCopy();
// cursor
const [editorMode] = useSessionStorage<AppMode>({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
const { clearSelectedEvents, setSelectedEvents, cursor } = useEventSelection();
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({
followRef: cursorRef,
scrollRef,
doFollow: true,
followTrigger: editorMode === AppMode.Edit ? cursor : featureData?.selectedEventId,
});
// DND KIT
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
const deleteAtCursor = useCallback(
(cursor: string | null) => {
if (!cursor) return;
const { entry, index } = getPreviousNormal(entries, order, cursor);
deleteEntry([cursor]);
if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
}
},
[entries, order, deleteEntry, setSelectedEvents],
);
const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId;
if (copyId === null) {
// we cant clone without selection
return;
}
const cloneEntry = entries[copyId];
if (cloneEntry?.type === SupportedEntry.Event) {
//if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry);
addEntry(newEvent, { after: adjustedCursor ?? undefined });
}
},
[addEntry, order, entries],
);
const [metadata, setMetadata] = useState<RundownMetadataObject>(rundownMetadata);
/**
* Add a new item referring to an existing one
* We need to keep a copy of the events we receive to
* workaround issues with dnd-kit optimistic updates
*/
const insertAtId = useCallback(
(patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: MaybeString, above = false) => {
addEntry(patch, {
after: id && !above ? id : undefined,
before: id && above ? id : undefined,
lastEventId: !above && id ? id : undefined,
});
},
[addEntry],
);
const selectGroup = useCallback(
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 1) {
return;
}
let newCursor = cursor;
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (isOntimeGroup(selected)) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
return;
}
newCursor = selected?.id ?? null;
}
if (newCursor === null) {
return;
}
// otherwise we select the next or previous
const selected =
direction === 'up'
? getPreviousGroupNormal(entries, order, newCursor)
: getNextGroupNormal(entries, order, newCursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
}
},
[order, entries, setSelectedEvents],
);
const selectEntry = useCallback(
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 1) {
return;
}
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (selected !== null) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
}
return;
}
// otherwise we select the next or previous
const selected =
direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
}
},
[order, entries, setSelectedEvents],
);
/**
* Checks whether a group is collapsed
*/
const getIsCollapsed = useCallback(
(groupId: EntryId): boolean => {
return Boolean(collapsedGroups.find((id) => id === groupId));
},
[collapsedGroups],
);
/**
* Handles logic for collapsing groups
*/
const handleCollapseGroup = useCallback(
(collapsed: boolean, groupId: EntryId) => {
setCollapsedGroups((prev) => {
const isCollapsed = getIsCollapsed(groupId);
if (collapsed && !isCollapsed) {
const newSet = new Set(prev).add(groupId);
return [...newSet];
}
if (!collapsed && isCollapsed) {
return [...prev].filter((id) => id !== groupId);
}
return prev;
});
},
[getIsCollapsed, setCollapsedGroups],
);
const moveEntry = useCallback(
async (cursor: EntryId | null, direction: 'up' | 'down') => {
if (cursor == null) {
return;
}
const movedIntoGroupId = await move(cursor, direction);
// if we are moving into a group, we need to make sure it is expanded
if (movedIntoGroupId) {
handleCollapseGroup(false, movedIntoGroupId);
}
},
[handleCollapseGroup, move],
);
// shortcuts
useHotkeys([
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
['alt + shift + ArrowDown', () => selectGroup(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
['alt + shift + ArrowUp', () => selectGroup(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
['Escape', () => clearSelectedEvents(), { preventDefault: true, usePhysicalKeys: true }],
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
[
'alt + E',
() => insertAtId({ type: SupportedEntry.Event }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + E',
() => insertAtId({ type: SupportedEntry.Event }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + G',
() => insertAtId({ type: SupportedEntry.Group }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + G',
() => insertAtId({ type: SupportedEntry.Group }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + D',
() => insertAtId({ type: SupportedEntry.Delay }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + D',
() => insertAtId({ type: SupportedEntry.Delay }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + M',
() => insertAtId({ type: SupportedEntry.Milestone }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + M',
() => insertAtId({ type: SupportedEntry.Milestone }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
[
'mod + shift + V',
() => insertCopyAtId(cursor, entryCopyId, true),
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
]);
// we copy the state from the store here
// to workaround async updates on the drag mutations
useEffect(() => {
setSortableData(makeSortableList(order, entries));
setMetadata(rundownMetadata);
}, [order, entries, rundownMetadata]);
// in run mode, we follow the playback selection and open groups as needed
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
const entryActions = useEntryActionsContext();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
// cursor
const { editorMode } = useEditorFollowMode();
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const cursor = useEventSelection((state) => state.cursor);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
/**
* Handles logic for collapsing groups
*/
const handleCollapseGroup = useCallback(
(collapsed: boolean, groupId: EntryId | undefined) => {
if (collapsed) {
collapseGroup(groupId);
} else {
expandGroup(groupId);
}
},
[collapseGroup, expandGroup],
);
// Commands layer - business logic
const commands = useRundownCommands({
entries,
flatOrder,
entryActions,
selectEntry,
handleCollapseGroup,
});
// Keyboard shortcuts
useRundownKeyboard({
cursor,
commands,
clearSelectedEvents,
setEntryCopyId,
});
// DND handlers
const dnd = useRundownDnd({
entries,
sortableData,
setSortableData,
getIsCollapsed,
handleCollapseGroup,
entryActions,
});
// Filter visible data to exclude collapsed items
// DND-kit (SortableContext) needs the full sortableData for drag calculations
// Virtuoso only renders visibleData to avoid null items that reserve space
const visibleData = useMemo(() => {
return filterVisibleEntries(sortableData, entries, getIsCollapsed);
}, [sortableData, entries, getIsCollapsed]);
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
useEffect(() => {
if (editorMode !== AppMode.Run || !featureData?.selectedEventId) {
return;
}
const index = order.findIndex((id) => id === featureData.selectedEventId);
// @ts-expect-error -- but we safely check if the parent property exists
const maybeParent = entries[featureData.selectedEventId]?.parent;
if (maybeParent) {
// open the group
setCollapsedGroups((prev) => [...prev].filter((id) => id !== maybeParent));
}
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
}, [editorMode, entries, featureData.selectedEventId, order, setCollapsedGroups, setSelectedEvents]);
/**
* On drag end, we reorder the events
*/
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over?.id || active.id === over.id) {
return;
}
if (!active.data.current || !over.data.current) {
return;
}
const fromIndex: number = active.data.current.sortable.index;
const toIndex: number = over.data.current.sortable.index;
let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
let destinationId = over.id as EntryId;
const isDraggingGroup = active.data.current?.type === SupportedEntry.Group;
// prevent dropping a group inside another
if (
isDraggingGroup &&
!canDrop(over.data.current.type, over.data.current.parent, placement, getIsCollapsed(destinationId))
) {
return;
}
/**
* We need to specially handle the end-group
* Dragging before a end-group will add the entry to the end of the group
* Dragging after a end-group will add the event after the group itself
* Dragging to the top of a group either place before first entry or if no entries do insert
*/
if (destinationId.startsWith('end-')) {
destinationId = destinationId.replace('end-', '');
// if we are moving before the end, we use the insert operation
if (placement === 'before') {
placement = 'insert';
setScrollHandler((entryId) => {
if (!virtuosoRef.current || dnd.isDraggingRef.current) {
return;
}
} else {
const group = data.entries[destinationId];
// if dragging into a group
if (isOntimeGroup(group) && placement === 'after') {
if (isDraggingGroup) {
// ... and the dragged entry is a group, we know that the group is collapsed, because of the safe check canDrop from before
// so we can safely push the dragged event after the group
destinationId = group.id;
} else if (group.entries.length === 0) {
// ... and the group is entry, we insert
destinationId = group.id;
placement = 'insert';
} else {
// otherwise we add it to before the first group child
destinationId = group.entries[0];
placement = 'before';
}
const index = visibleData.indexOf(entryId);
if (index === -1) {
return;
}
}
// keep copy of the current state in case we need to revert
const currentEntries = structuredClone(sortableData);
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
virtuosoRef.current.scrollToIndex({
index,
align: 'start',
behavior: 'smooth',
offset: -100, // show the previous entry for context
});
});
reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
setSortableData(currentEntries);
});
};
/**
* When we drag a group, we force collapse it
* This avoids strange scenarios like dropping a group inside itself
*/
const collapseDraggedGroups = (event: DragStartEvent) => {
const isGroup = event.active.data.current?.type === SupportedEntry.Group;
if (isGroup) {
handleCollapseGroup(true, event.active.id as EntryId);
}
};
return () => {
setScrollHandler(null);
};
}, [visibleData, dnd.isDraggingRef, setScrollHandler]);
/**
* When we drag over a group, we expand it if it is collapsed
*/
const expandOverGroup = (event: DragOverEvent) => {
// if we are dragging a group, the drop operation is invalid so we dont expand
if (event.active.data.current?.type === 'group') {
// Keep selected cursor visible by expanding its parent group in any mode.
// Finder can select entries while layout is in Run mode, where we do not follow cursor scroll.
useEffect(() => {
if (!cursor) {
return;
}
if (event.over?.data.current?.type !== 'group') {
const entry = entries[cursor];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
}, [cursor, entries, expandGroup]);
// Auto-follow behavior in Edit mode: follow the user's cursor
useEffect(() => {
if (dnd.isDraggingRef.current || editorMode !== AppMode.Edit || !cursor) {
return;
}
const groupId = event.over?.id as EntryId;
const isCollapsed = getIsCollapsed(groupId);
if (isCollapsed) {
handleCollapseGroup(false, groupId);
scrollToEntry(cursor);
}, [editorMode, cursor, scrollToEntry, dnd.isDraggingRef]);
// Auto-follow behavior in Run mode: follow the currently playing event
useEffect(() => {
if (dnd.isDraggingRef.current || editorMode !== AppMode.Run || !featureData?.selectedEventId) {
return;
}
};
// Open parent group if the target is inside a collapsed group
const entry = entries[featureData.selectedEventId];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
scrollToEntry(featureData.selectedEventId);
}, [editorMode, featureData?.selectedEventId, entries, expandGroup, scrollToEntry, dnd.isDraggingRef]);
// gather presentation options
const isEditMode = editorMode === AppMode.Edit;
// gather rundown wide data
const lastEntryId = order.at(-1);
// Extract primitive values from featureData to avoid unnecessary callback recreations
const nextEventId = featureData?.nextEventId;
const playback = featureData?.playback;
// Virtuoso item renderer
const itemContent = useCallback(
(index: number, entryId: EntryId) => {
// Handle end-group pseudo entries
const isEndGroup = entryId.startsWith('end-');
if (isEndGroup) {
const parentId = entryId.split('end-')[1];
const parentMetadata = metadata[parentId];
return (
<Fragment key={entryId}>
{isEditMode && parentMetadata?.groupEntries === 0 && (
<QuickAddButtons
previousEventId={null}
parentGroup={parentId}
backgroundColor={parentMetadata?.groupColour}
/>
)}
<RundownGroupEnd key={entryId} id={entryId} colour={parentMetadata?.groupColour} />
</Fragment>
);
}
const entry = entries[entryId];
const entryMetadata = metadata[entryId];
if (!entry || !entryMetadata) {
return null;
}
const isNext = nextEventId === entry.id;
const hasCursor = entry.id === cursor;
const isGroup = isOntimeGroup(entry);
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
const isFirst = index === 0;
const isLast = entryId === lastEntryId;
const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null;
const parentIdForAfter = entryMetadata.groupId;
const collapsed = isGroup ? getIsCollapsed(entry.id) : false;
return (
<Fragment key={entry.id}>
{/* QuickAddInline before the entry - edit mode only, if there is a cursor, if it is not the first entry */}
{isEditMode && hasCursor && !isFirst && (
<QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
)}
{isGroup ? (
<RundownGroup data={entry} hasCursor={hasCursor} collapsed={collapsed} onCollapse={handleCollapseGroup} />
) : (
<div
className={style.entryWrapper}
data-testid={`entry-${entryMetadata.eventIndex}`}
style={groupColour ? { '--user-bg': groupColour } : {}}
>
{isOntimeEvent(entry) && (
<div className={style.entryIndex}>
{entry.flag && <TbFlagFilled className={style.flag} />}
<div className={style.index}>{entryMetadata.eventIndex}</div>
</div>
)}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
isPast={entryMetadata.isPast}
eventIndex={entryMetadata.eventIndex}
data={entry}
loaded={entryMetadata.isLoaded}
hasCursor={hasCursor}
isNext={isNext}
isNextDay={entryMetadata.isNextDay}
playback={entryMetadata.isLoaded ? playback : undefined}
isRolling={playback === Playback.Roll}
totalGap={entryMetadata.totalGap}
isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
/>
</div>
</div>
)}
{/* QuickAddInline after the entry - edit mode only, if there is a cursor, if it is not the last entry */}
{isEditMode && hasCursor && !isLast && (
<QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
)}
</Fragment>
);
},
[entries, metadata, getIsCollapsed, isEditMode, cursor, nextEventId, playback, lastEntryId, handleCollapseGroup],
);
if (sortableData.length < 1) {
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
return <RundownEmpty handleAddNew={(type: SupportedEntry) => entryActions.addEntry({ type })} />;
}
// 1. gather presentation options
const isEditMode = editorMode === AppMode.Edit;
return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext
onDragEnd={handleOnDragEnd}
onDragStart={collapseDraggedGroups}
onDragOver={expandOverGroup}
sensors={sensors}
onDragEnd={dnd.handleOnDragEnd}
onDragStart={dnd.collapseDraggedGroups}
onDragOver={dnd.expandOverGroup}
sensors={dnd.sensors}
collisionDetection={closestCenter}
>
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
<div className={style.list}>
{isEditMode && <QuickAddButtons previousEventId={null} parentGroup={null} />}
{sortableData.map((entryId, index) => {
// the entry might be a pseudo end-group which does not generate metadata and should not be processed
if (entryId.startsWith('end-')) {
const parentId = entryId.split('end-')[1];
const isGroupCollapsed = getIsCollapsed(parentId);
const parentMetadata = metadata[parentId];
if (isGroupCollapsed) {
return null;
}
// if the previous element is selected, it will have its own QuickAddInline
// we use thisId instead of previousEntryId because the end-group does not process
// and it does not cause the reassignment of the iteration id to the previous entry
return (
<Fragment key={entryId}>
{isEditMode && parentMetadata?.groupEntries === 0 && (
<QuickAddButtons
previousEventId={null}
parentGroup={parentId}
backgroundColor={parentMetadata?.groupColour}
/>
)}
<RundownGroupEnd key={entryId} id={entryId} colour={parentMetadata?.groupColour} />
</Fragment>
);
}
// we iterate through a stateful copy of order to make the dnd operations smoother
// this means that this can be out of sync with order until the useEffect runs
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = entries[entryId];
const entryMetadata = metadata[entryId];
if (!entry || !entryMetadata) return null;
// if the entry has a parent, and it is collapsed, render nothing
if (
entry.type !== SupportedEntry.Group &&
entryMetadata.groupId !== null &&
getIsCollapsed(entryMetadata.groupId)
) {
return null;
}
const isNext = featureData?.nextEventId === entry.id;
const hasCursor = entry.id === cursor;
/**
* Outside a group, the value will be undefined
* If the colour is empty string ''
* ie: we are inside a group, but there is no defined colour
* we default to $gray-500 #9d9d9d
*/
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
const isFirst = index === 0;
const isLast = entryId === order.at(-1);
/**
* We need to provide the parent ID for the QuickAdd components
* This should be different depending on whether we are adding before or after an element
* - when adding before, we need to avoid a group referencing itself as the parent
* - when adding after, we can use the group ID directly to insert at the top of the group
*/
const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null;
const parentIdForAfter = entryMetadata.groupId;
return (
<Fragment key={entry.id}>
{/**
* Before the entry
* - edit mode only
* - if there is a cursor
* - if it is not the first entry (the buttons would be there)
*/}
{isEditMode && hasCursor && !isFirst && (
<QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
)}
{isOntimeGroup(entry) ? (
<RundownGroup
data={entry}
hasCursor={hasCursor}
collapsed={getIsCollapsed(entry.id)}
onCollapse={handleCollapseGroup}
<Virtuoso
ref={virtuosoRef}
data={visibleData}
computeItemKey={(_index, entryId) => entryId}
itemContent={itemContent}
increaseViewportBy={{ top: 200, bottom: 400 }}
style={{ height: '100%' }}
components={{
Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
Footer: () => (
<>
{isEditMode && (
<QuickAddButtons
previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey]?.thisId}
parentGroup={null}
/>
) : (
<div
className={style.entryWrapper}
data-testid={`entry-${entryMetadata.eventIndex}`}
style={groupColour ? { '--user-bg': groupColour } : {}}
>
{isOntimeEvent(entry) && (
<div className={style.entryIndex}>
{entry.flag && <TbFlagFilled className={style.flag} />}
<div className={style.index}>{entryMetadata.eventIndex}</div>
</div>
)}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
isPast={entryMetadata.isPast}
eventIndex={entryMetadata.eventIndex}
data={entry}
loaded={entryMetadata.isLoaded}
hasCursor={hasCursor}
isNext={isNext}
isNextDay={entryMetadata.isNextDay}
playback={entryMetadata.isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
totalGap={entryMetadata.totalGap}
isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
/>
</div>
</div>
)}
{/**
* After the entry
* - edit mode only
* - if there is a cursor
* - if it is not the last entry (the buttons would be there)
* - if the entry is not the group header
*/}
{isEditMode && hasCursor && !isLast && (
<QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
)}
</Fragment>
);
})}
{isEditMode && (
<QuickAddButtons
previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey].thisId}
parentGroup={null}
/>
)}
<div className={style.spacer} />
</div>
<div className={style.spacer} />
</>
),
List: VirtuosoListComponent,
}}
/>
</SortableContext>
</DndContext>
</div>
);
}
// Virtuoso components - extracted to prevent recreation on every render
const VirtuosoListComponent = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(({ children, ...props }, ref) => (
<div ref={ref} {...props} className={style.list}>
{children}
</div>
));
VirtuosoListComponent.displayName = 'VirtuosoListComponent';
@@ -1,16 +1,4 @@
import {
isOntimeDelay,
isOntimeEvent,
isOntimeMilestone,
OntimeEntry,
OntimeEvent,
Playback,
SupportedEntry,
} from 'ontime-types';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { cloneEvent } from '../../common/utils/clone';
import { isOntimeDelay, isOntimeEvent, isOntimeMilestone, OntimeEntry, Playback, SupportedEntry } from 'ontime-types';
import RundownDelay from './rundown-delay/RundownDelay';
import RundownEvent from './rundown-event/RundownEvent';
@@ -44,12 +32,7 @@ export default function RundownEntry({
totalGap,
isLinkedToLoaded,
}: RundownEntryProps) {
const { addEntry } = useEntryActions();
const createCloneEvent = useMemoisedFn(() => {
const newEvent = cloneEvent(data as OntimeEvent);
addEntry(newEvent, { after: data.id });
});
'use memo';
if (isOntimeEvent(data)) {
return (
@@ -83,7 +66,6 @@ export default function RundownEntry({
dayOffset={data.dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
createCloneEvent={createCloneEvent}
hasTriggers={data.triggers.length > 0}
/>
);
@@ -1,8 +1,7 @@
@use '../../views/editor/EditorMixin' as editor;
.rundownExport {
height: 100%;
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
min-width: 0;
&.extracted {
.list {
@@ -22,10 +21,19 @@
height: 100%;
}
.rundownRoot {
height: calc(100% - 1rem);
width: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.list {
@include editor.panel;
display: flex;
height: inherit;
padding-inline: 0;
padding-bottom: 0;
box-shadow: $box-shadow-right;
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
@@ -1,20 +1,29 @@
import { memo } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { CornerExtract } from '../../common/components/editor-utils/EditorUtils';
import * as Editor from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import { AppMode } from '../../ontimeConfig';
import EntryEditModal from '../../views/cuesheet/cuesheet-edit-modal/EntryEditModal';
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
import FinderPlacement from './placements/FinderPlacement';
import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu';
import RundownWrapper from './RundownWrapper';
import RundownHeader from './rundown-header/RundownHeader';
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
import RundownTable from './rundown-table/RundownTable';
import { RundownViewMode } from './rundown.options';
import RundownList from './RundownList';
import { useEditorFollowMode } from './useEditorFollowMode';
import style from './RundownExport.module.scss';
@@ -22,59 +31,88 @@ export default memo(RundownExport);
function RundownExport() {
const isExtracted = window.location.pathname.includes('/rundown');
const [editorMode] = useSessionStorage({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
const { editorMode } = useEditorFollowMode();
const { layoutMode } = useEditorLayout();
const [viewMode, setViewMode] = useSessionStorage<RundownViewMode>({
key: 'rundown-view-mode',
defaultValue: RundownViewMode.List,
});
const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) {
return (
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
data-target='small-device'
data-testid='panel-rundown'
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownContextMenu>
<RundownWrapper isSmallDevice />
</RundownContextMenu>
</ErrorBoundary>
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
data-target='small-device'
data-testid='panel-rundown'
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
</div>
</div>
</ProtectRoute>
</ProtectRoute>
</EntryActionsProvider>
);
}
const hideSideBar = isExtracted && editorMode === 'run';
const hideSideBar =
layoutMode === EditorLayoutMode.TRACKING ||
(isExtracted && editorMode === AppMode.Run) ||
viewMode === RundownViewMode.Table;
return (
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<div className={style.list}>
<ErrorBoundary>
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownContextMenu>
<RundownWrapper />
</RundownContextMenu>
</ErrorBoundary>
</div>
{!hideSideBar && (
<div className={style.side}>
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<Editor.Panel className={style.list}>
<ErrorBoundary>
<RundownEntryEditor />
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
)}
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div>
</div>
</ProtectRoute>
</ProtectRoute>
</EntryActionsProvider>
);
}
interface RundownRootProps {
isSmallDevice?: boolean;
isExtracted?: boolean;
viewMode: RundownViewMode;
setViewMode: (mode: RundownViewMode) => void;
}
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
return (
<div className={style.rundownRoot}>
{isSmallDevice ? (
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
) : (
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal />}
</div>
);
}

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