Compare commits

...

49 Commits

Author SHA1 Message Date
Carlos Valente c3a7cd6a39 bump version to 4.4.2 2026-02-16 06:58:08 +01:00
Carlos Valente 39e6c15adf fix: hide now and next cards if there is no content 2026-02-16 06:58:08 +01:00
Carlos Valente aec6673b07 refactor: improve connection handling
- only invalidate caches on reconnect
- add jitter to avoid overwhelming server and allow cache to stabilise
- prevent multiple reconnect timers
2026-02-14 12:08:04 +01:00
Carlos Valente 9e8cb1c755 refactor: improve request cancellation and timeout handling 2026-02-14 12:08:04 +01:00
Carlos Valente cdf02f7c3f bump version to 4.4.1 2026-02-14 12:07:45 +01:00
Carlos Valente 77779a4648 refactor: debounce writing to disk 2026-02-14 09:30:24 +01:00
Carlos Valente e16ec3f388 refactor: prevent suspending page on first add 2026-02-14 08:56:16 +01:00
Carlos Valente 63cfc9ec19 refactor: optimistic add elements in UI 2026-02-14 08:56:16 +01:00
Carlos Valente 4827117163 refactor: extract entry creation logic 2026-02-14 08:56:16 +01:00
Carlos Valente 5777ef3532 refactor: extract parent resolution 2026-02-14 08:56:16 +01:00
Carlos Valente 80a3b04493 refactor: export rundown placement logic 2026-02-14 08:56:16 +01:00
Carlos Valente 7c5fdd3b19 refactor: remove unused exports 2026-02-14 08:56:16 +01:00
Carlos Valente 31fd8e5200 fix: prevent infinite render in settings forms 2026-02-13 21:12:29 +01:00
Carlos Valente b6a01a52e2 fix: table re-renders no longer lose cell focus 2026-02-08 17:41:05 +01:00
Carlos Valente f9563fca5f bump to version 4.4.0 2026-02-08 17:40:05 +01:00
Carlos Valente af1da58718 fix: update data in background rundowns 2026-02-08 17:39:53 +01:00
Carlos Valente 0f83ceaf01 fix: maintain lock on param changes 2026-02-08 17:17:25 +01: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
251 changed files with 6319 additions and 2188 deletions
+15 -16
View File
@@ -37,32 +37,31 @@ jobs:
run: pnpm build:docker run: pnpm build:docker
- name: Docker Login - name: Docker Login
uses: docker/login-action@v2.1.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx - 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 - name: Build and push stable release
if: github.event.release.prerelease == false uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v4.0.0
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry # Push is a shorthand for --output=type=registry
push: true push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- 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
+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> <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-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-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 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 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 ... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a> - 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> - Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help? ## Need help?
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.3.0", "version": "4.4.2",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.3.0", "version": "4.4.2",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+3
View File
@@ -3,6 +3,9 @@ import { Tooltip } from '@base-ui/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// apply global axios config defaults
import './common/api/axios.config';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay'; import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
import { AppContextProvider } from './common/context/AppContext'; import { AppContextProvider } from './common/context/AppContext';
+5 -4
View File
@@ -4,14 +4,15 @@ import { TranslationObject } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants'; import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
import type { RequestOptions } from './requestOptions';
const assetsPath = `${apiEntryUrl}/assets`; const assetsPath = `${apiEntryUrl}/assets`;
/** /**
* HTTP request to get css contents * HTTP request to get css contents
*/ */
export async function getCSSContents(): Promise<string> { export async function getCSSContents(options?: RequestOptions): Promise<string> {
const res = await axios.get(`${assetsPath}/css`); const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal });
return res.data; return res.data;
} }
@@ -35,8 +36,8 @@ export async function restoreCSSContents(): Promise<string> {
/** /**
* HTTP request to get user translation * HTTP request to get user translation
*/ */
export async function getUserTranslation(): Promise<TranslationObject> { export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL); const res = await axios.get(customTranslationsURL, { signal: options?.signal });
return res.data; return res.data;
} }
+3 -2
View File
@@ -9,14 +9,15 @@ import type {
} from 'ontime-types'; } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const automationsPath = `${apiEntryUrl}/automations`; const automationsPath = `${apiEntryUrl}/automations`;
/** /**
* HTTP request to get the automations settings * HTTP request to get the automations settings
*/ */
export async function getAutomationSettings(): Promise<AutomationSettings> { export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> {
const res = await axios.get(automationsPath); const res = await axios.get(automationsPath, { signal: options?.signal });
return res.data; return res.data;
} }
+4 -1
View File
@@ -1,5 +1,8 @@
import axios from 'axios'; import axios from 'axios';
import { axiosConfig } from './requestTimeouts';
axios.defaults.validateStatus = (status) => { axios.defaults.validateStatus = (status) => {
return (status >= 200 && status < 300) || status === 304; return status >= 200 && status < 300;
}; };
axios.defaults.timeout = axiosConfig.shortTimeout;
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types'; import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const customFieldsPath = `${apiEntryUrl}/custom-fields`; const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/** /**
* Requests list of known custom fields * Requests list of known custom fields
*/ */
export async function getCustomFields(): Promise<CustomFields> { export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> {
const res = await axios.get(customFieldsPath); const res = await axios.get(customFieldsPath, { signal: options?.signal });
return res.data; return res.data;
} }
+9 -3
View File
@@ -2,6 +2,8 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
const dbPath = `${apiEntryUrl}/db`; const dbPath = `${apiEntryUrl}/db`;
@@ -10,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB * HTTP request to the current DB
*/ */
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> { export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download`, { filename }); return axios.post(`${dbPath}/download`, { filename }, { timeout: axiosConfig.longTimeout });
} }
/** /**
@@ -37,6 +39,7 @@ export async function uploadProjectFile(file: File): Promise<MessageResponse> {
const formData = new FormData(); const formData = new FormData();
formData.append('project', file); formData.append('project', file);
const response = await axios.post(`${dbPath}/upload`, formData, { const response = await axios.post(`${dbPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
@@ -76,8 +79,11 @@ export async function quickProject(data: QuickStartData): Promise<MessageRespons
/** /**
* HTTP request to get the list of available project files * HTTP request to get the list of available project files
*/ */
export async function getProjects(): Promise<ProjectFileListResponse> { export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`); const res = await axios.get(`${dbPath}/all`, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data; return res.data;
} }
+22 -6
View File
@@ -3,6 +3,8 @@ import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { downloadBlob } from './utils'; import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`; const excelPath = `${apiEntryUrl}/excel`;
@@ -11,10 +13,12 @@ const excelPath = `${apiEntryUrl}/excel`;
* upload Excel file to server * upload Excel file to server
* @return string - file ID op the uploaded file * @return string - file ID op the uploaded file
*/ */
export async function upload(file: File): Promise<string[]> { export async function upload(file: File, requestOptions?: RequestOptions): Promise<string[]> {
const formData = new FormData(); const formData = new FormData();
formData.append('excel', file); formData.append('excel', file);
const response = await axios.post(`${excelPath}/upload`, formData, { const response = await axios.post(`${excelPath}/upload`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
@@ -27,19 +31,31 @@ type PreviewSpreadsheetResponse = {
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary; summary: RundownSummary;
}; };
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> { export async function importRundownPreview(
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, { options: ImportMap,
options, requestOptions?: RequestOptions,
}); ): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
`${excelPath}/preview`,
{
options,
},
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data; return response.data;
} }
/** /**
* Downloads a xlsx representation of the rundown from the server * Downloads a xlsx representation of the rundown from the server
*/ */
export async function downloadAsExcel(rundownId: string, fileName?: string) { export async function downloadAsExcel(rundownId: string, fileName?: string, requestOptions?: RequestOptions) {
try { try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, { const response = await axios.get(`${excelPath}/${rundownId}/export`, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
responseType: 'blob', responseType: 'blob',
}); });
+4 -2
View File
@@ -2,6 +2,8 @@ import axios from 'axios';
import { apiRepoLatest } from '../../externals'; import { apiRepoLatest } from '../../externals';
import type { RequestOptions } from './requestOptions';
export type HasUpdate = { export type HasUpdate = {
url: string; url: string;
version: string; version: string;
@@ -10,8 +12,8 @@ export type HasUpdate = {
/** /**
* HTTP request to get the latest version and url from github * HTTP request to get the latest version and url from github
*/ */
export async function getLatestVersion(): Promise<HasUpdate> { export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest); const res = await axios.get(apiRepoLatest, { signal: options?.signal });
return { return {
url: res.data.html_url as string, url: res.data.html_url as string,
version: res.data.tag_name as string, version: res.data.tag_name as string,
+8 -2
View File
@@ -2,14 +2,19 @@ import axios, { AxiosResponse } from 'axios';
import { ProjectData, ProjectLogoResponse } from 'ontime-types'; import { ProjectData, ProjectLogoResponse } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const projectPath = `${apiEntryUrl}/project`; const projectPath = `${apiEntryUrl}/project`;
/** /**
* HTTP request to fetch project data * HTTP request to fetch project data
*/ */
export async function getProjectData(): Promise<ProjectData> { export async function getProjectData(options?: RequestOptions): Promise<ProjectData> {
const res = await axios.get(projectPath); const res = await axios.get(projectPath, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data; return res.data;
} }
@@ -27,6 +32,7 @@ export async function uploadProjectLogo(file: File): Promise<AxiosResponse<Proje
const formData = new FormData(); const formData = new FormData();
formData.append('image', file); formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, { const response = await axios.post(`${projectPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
+3 -2
View File
@@ -4,14 +4,15 @@ import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants'; import { apiEntryUrl, REPORT } from './constants';
import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`; export const reportUrl = `${apiEntryUrl}/report`;
/** /**
* HTTP request to fetch all reports * HTTP request to fetch all reports
*/ */
export async function fetchReport(): Promise<OntimeReport> { export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
const res = await axios.get(reportUrl); const res = await axios.get(reportUrl, { signal: options?.signal });
return res.data; return res.data;
} }
@@ -0,0 +1,4 @@
export type RequestOptions = {
signal?: AbortSignal;
timeout?: number;
};
@@ -0,0 +1,8 @@
/**
* Keep a short global timeout for regular API requests, but allow
* longer windows for file transfer and heavy import/export operations.
*/
export const axiosConfig = {
shortTimeout: 20 * 1000, // 20 seconds
longTimeout: 3 * 60 * 1000, // 3 minutes
} as const;
+5 -4
View File
@@ -2,6 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types'; import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
type RundownId = string; type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`; const rundownPath = `${apiEntryUrl}/rundowns`;
@@ -11,16 +12,16 @@ const rundownPath = `${apiEntryUrl}/rundowns`;
/** /**
* HTTP request to fetch a list of existing rundowns * HTTP request to fetch a list of existing rundowns
*/ */
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> { export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath); const res = await axios.get(rundownPath, { signal: options?.signal });
return res.data; return res.data;
} }
/** /**
* HTTP request to fetch all entries in the currently loaded rundown * HTTP request to fetch all entries in the currently loaded rundown
*/ */
export async function fetchCurrentRundown(): Promise<Rundown> { export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`); const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal });
if (!isValidRundown(res.data)) { if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload'); throw new Error('Invalid rundown payload');
} }
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types'; import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`; const sessionPath = `${apiEntryUrl}/session`;
/** /**
* HTTP request to retrieve application info * HTTP request to retrieve application info
*/ */
export async function getInfo(): Promise<GetInfo> { export async function getInfo(options?: RequestOptions): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`); const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal });
return res.data; return res.data;
} }
+3 -2
View File
@@ -2,14 +2,15 @@ import axios, { AxiosResponse } from 'axios';
import { Settings } from 'ontime-types'; import { Settings } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const settingsPath = `${apiEntryUrl}/settings`; const settingsPath = `${apiEntryUrl}/settings`;
/** /**
* HTTP request to retrieve application settings * HTTP request to retrieve application settings
*/ */
export async function getSettings(): Promise<Settings> { export async function getSettings(options?: RequestOptions): Promise<Settings> {
const res = await axios.get(settingsPath); const res = await axios.get(settingsPath, { signal: options?.signal });
return res.data; return res.data;
} }
+36 -5
View File
@@ -3,6 +3,8 @@ import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ont
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const sheetsPath = `${apiEntryUrl}/sheets`; const sheetsPath = `${apiEntryUrl}/sheets`;
@@ -23,6 +25,7 @@ export const verifyAuthenticationStatus = async (): Promise<{
export const requestConnection = async ( export const requestConnection = async (
file: File, file: File,
sheetId: string, sheetId: string,
requestOptions?: RequestOptions,
): Promise<{ ): Promise<{
verification_url: string; verification_url: string;
user_code: string; user_code: string;
@@ -31,6 +34,8 @@ export const requestConnection = async (
formData.append('client_secret', file); formData.append('client_secret', file);
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, { const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
@@ -53,24 +58,50 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic
export const previewRundown = async ( export const previewRundown = async (
sheetId: string, sheetId: string,
options: ImportMap, options: ImportMap,
requestOptions?: RequestOptions,
): Promise<{ ): Promise<{
rundown: Rundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
summary: RundownSummary; summary: RundownSummary;
}> => { }> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); const response = await axios.post(
`${sheetsPath}/${sheetId}/read`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data; return response.data;
}; };
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => { export const getWorksheetNames = async (sheetId: string, requestOptions?: RequestOptions): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`); const response: AxiosResponse<string[]> = await axios.post(
`${sheetsPath}/${sheetId}/worksheets`,
undefined,
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data; return response.data;
}; };
/** /**
* HTTP request to upload the rundown to a google sheet * HTTP request to upload the rundown to a google sheet
*/ */
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => { export const uploadRundown = async (
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options }); sheetId: string,
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<void> => {
const response = await axios.post(
`${sheetsPath}/${sheetId}/write`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data; return response.data;
}; };
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { URLPreset } from 'ontime-types'; import { URLPreset } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const urlPresetsPath = `${apiEntryUrl}/url-presets`; const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/** /**
* HTTP request to retrieve all presets * HTTP request to retrieve all presets
*/ */
export async function getUrlPresets(): Promise<URLPreset[]> { export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath); const res = await axios.get(urlPresetsPath, { signal: options?.signal });
return res.data; return res.data;
} }
+3 -2
View File
@@ -2,6 +2,7 @@ import axios from 'axios';
import type { ViewSettings } from 'ontime-types'; import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const viewSettingsPath = `${apiEntryUrl}/view-settings`; const viewSettingsPath = `${apiEntryUrl}/view-settings`;
@@ -9,8 +10,8 @@ const viewSettingsPath = `${apiEntryUrl}/view-settings`;
* HTTP request to fetch view settings * HTTP request to fetch view settings
* @returns * @returns
*/ */
export async function getViewSettings(): Promise<ViewSettings> { export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath); const res = await axios.get(viewSettingsPath, { signal: options?.signal });
return res.data; return res.data;
} }
@@ -31,11 +31,12 @@
outline: 0; outline: 0;
cursor: default; cursor: default;
padding-block: 0.5rem; padding-block: 0.5rem;
padding-inline: 1rem 2rem; padding-inline: 1rem;
display: flex; display: flex;
gap: 0.5rem; justify-content: space-between;
line-height: 1em; align-items: flex-start;
gap: 1rem;
svg { svg {
color: $gray-500; 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 { .separator {
margin: 0.25rem 0.75rem; margin: 0.25rem 0.75rem;
height: 1px; height: 1px;
@@ -8,9 +8,11 @@ type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = { type DropdownMenuItem = {
type: 'item' | 'destructive'; type: 'item' | 'destructive';
label: string; label: string;
description?: string;
icon?: IconType; icon?: IconType;
disabled?: boolean; disabled?: boolean;
onClick: () => void; onClick: () => void;
shortcut?: string;
}; };
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem; export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
@@ -38,8 +40,14 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
disabled={item.disabled} disabled={item.disabled}
data-type={item.type} data-type={item.type}
> >
{item.icon && <item.icon />} <span className={style.content}>
{item.label} <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> </BaseMenu.Item>
); );
})} })}
@@ -75,8 +83,14 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
} }
return ( return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}> <BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />} <span className={style.content}>
{item.label} <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> </BaseMenu.Item>
); );
})} })}
@@ -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,7 +1,11 @@
import { ReactNode } from 'react'; import { PropsWithChildren } from 'react';
import style from './Tag.module.scss'; import style from './Tag.module.scss';
export default function Tag({ children }: { children: ReactNode }) { interface TagProps {
return <span className={style.tag}>{children}</span>; 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 { .title-card__title {
color: var(--color-override, $viewer-color); color: var(--color-override, $viewer-color);
padding-right: 1em; padding-right: 1em;
min-height: 1.2em;
} }
.title-card__placeholder { .title-card__placeholder {
@@ -1,3 +1,5 @@
import { useRef } from 'react';
import { projectLogoPath } from '../../api/constants'; import { projectLogoPath } from '../../api/constants';
import './ViewLogo.scss'; import './ViewLogo.scss';
@@ -7,13 +9,19 @@ interface ViewLogoProps {
className: string; className: string;
} }
export default function ViewLogo(props: ViewLogoProps) { export default function ViewLogo({ name, className }: ViewLogoProps) {
const { name, className } = props; 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 // we wrap the image in a div to help maintain the aspect ratio
return ( return (
<div className={className}> <div className={className}>
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' /> <img ref={imageRef} alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' onError={hideImage} />
</div> </div>
); );
} }
@@ -11,7 +11,7 @@ import IconButton from '../buttons/IconButton';
import Info from '../info/Info'; import Info from '../info/Info';
import { ViewOption } from './viewParams.types'; import { ViewOption } from './viewParams.types';
import { getURLSearchParamsFromObj } from './viewParams.utils'; import { getPreservedSearchParams, getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store'; import { useViewParamsEditorStore } from './viewParamsEditor.store';
import { ViewParamsPresets } from './ViewParamsPresets'; import { ViewParamsPresets } from './ViewParamsPresets';
import ViewParamsSection from './ViewParamsSection'; import ViewParamsSection from './ViewParamsSection';
@@ -25,17 +25,19 @@ interface EditFormDrawerProps {
export default memo(ViewParamsEditor); export default memo(ViewParamsEditor);
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) { function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
const handleClose = () => { const handleClose = () => {
close(); close();
}; };
const resetParams = () => { const resetParams = () => {
setSearchParams(); setSearchParams(getPreservedParams());
}; };
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => { const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -43,6 +45,10 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
const preservedParams = getPreservedParams();
preservedParams.forEach((value, key) => {
newSearchParams.append(key, value);
});
setSearchParams(newSearchParams); setSearchParams(newSearchParams);
if (isSmallScreen) { if (isSmallScreen) {
@@ -191,3 +191,26 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
return newSearchParams; return newSearchParams;
} }
/**
* Extracts search params that are not managed by the view params editor
* @param currentParams - The current URL search params
* @param paramFields - The view options that define the managed parameters
* @returns A new URLSearchParams object with preserved params
*/
export function getPreservedSearchParams(currentParams: URLSearchParams, paramFields: ViewOption[]) {
const managedParamIds = new Set<string>();
paramFields.forEach((section) => {
section.options.forEach((option) => {
managedParamIds.add(option.id);
});
});
const preserved = new URLSearchParams();
currentParams.forEach((value, key) => {
if (!managedParamIds.has(key)) {
preserved.append(key, value);
}
});
return preserved;
}
@@ -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;
}
@@ -17,7 +17,7 @@ export default function useAppVersion() {
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: APP_VERSION, queryKey: APP_VERSION,
queryFn: getLatestVersion, queryFn: ({ signal }) => getLatestVersion({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
@@ -8,12 +8,9 @@ import { automationPlaceholderSettings } from '../models/AutomationSettings';
export default function useAutomationSettings() { export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: AUTOMATION, queryKey: AUTOMATION,
queryFn: getAutomationSettings, queryFn: ({ signal }) => getAutomationSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch }; return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
@@ -10,12 +10,9 @@ const placeholder: CustomFields = {};
export default function useCustomFields() { export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS, queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields, queryFn: ({ signal }) => getCustomFields({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
return { data: data ?? placeholder, status, isFetching, isError, refetch }; return { data: data ?? placeholder, status, isFetching, isError, refetch };
@@ -8,7 +8,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
export function useCustomTranslation() { export function useCustomTranslation() {
const { data, status, refetch } = useQuery({ const { data, status, refetch } = useQuery({
queryKey: TRANSLATION, queryKey: TRANSLATION,
queryFn: getUserTranslation, queryFn: ({ signal }) => getUserTranslation({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
@@ -9,12 +9,9 @@ import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() { export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({ const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO, queryKey: APP_INFO,
queryFn: getInfo, queryFn: ({ signal }) => getInfo({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching }; return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
@@ -8,7 +8,7 @@ import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() { export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA, queryKey: PROJECT_DATA,
queryFn: getProjectData, queryFn: ({ signal }) => getProjectData({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
@@ -14,12 +14,9 @@ const placeholderProjectList: ProjectFileListResponse = {
function useProjectList() { function useProjectList() {
const { data, status, refetch } = useQuery({ const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST, queryKey: PROJECT_LIST,
queryFn: getProjects, queryFn: ({ signal }) => getProjects({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
}); });
return { data: data ?? placeholderProjectList, status, refetch }; return { data: data ?? placeholderProjectList, status, refetch };
} }
@@ -11,7 +11,7 @@ import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList
export function useProjectRundowns() { export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({ const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS, queryKey: PROJECT_RUNDOWNS,
queryFn: fetchProjectRundownList, queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
@@ -8,11 +8,8 @@ import { fetchReport } from '../api/report';
export default function useReport() { export default function useReport() {
const { data, refetch } = useQuery<OntimeReport>({ const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT, queryKey: REPORT,
queryFn: fetchReport, queryFn: ({ signal }) => fetchReport({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
networkMode: 'always',
staleTime: MILLIS_PER_HOUR, staleTime: MILLIS_PER_HOUR,
}); });
@@ -24,7 +24,7 @@ const cachedRundownPlaceholder: Rundown = {
export default function useRundown() { export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchCurrentRundown, queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
@@ -33,7 +33,7 @@ export default function useRundown() {
export function useRundownWithMetadata() { export function useRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata }; return { data, status, rundownMetadata };
} }
@@ -57,7 +57,7 @@ export function useFlatRundown() {
export function useFlatRundownWithMetadata() { export function useFlatRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status }; return { data: rundownWithMetadata, status };
@@ -8,7 +8,7 @@ import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() { export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS, queryKey: APP_SETTINGS,
queryFn: getSettings, queryFn: ({ signal }) => getSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
select: (data) => { select: (data) => {
const unobfuscated = { ...data }; const unobfuscated = { ...data };
@@ -13,7 +13,7 @@ interface FetchProps {
export default function useUrlPresets({ skip = false }: FetchProps = {}) { export default function useUrlPresets({ skip = false }: FetchProps = {}) {
const { data, status, isError, refetch } = useQuery({ const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS, queryKey: URL_PRESETS,
queryFn: getUrlPresets, queryFn: ({ signal }) => getUrlPresets({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
enabled: !skip, enabled: !skip,
@@ -9,7 +9,7 @@ import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() { export default function useViewSettings() {
const { data, status } = useQuery({ const { data, status } = useQuery({
queryKey: VIEW_SETTINGS, queryKey: VIEW_SETTINGS,
queryFn: getViewSettings, queryFn: ({ signal }) => getViewSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR, staleTime: MILLIS_PER_HOUR,
}); });
@@ -1,18 +1,24 @@
import { MouseEvent } from 'react'; import { MouseEvent, useCallback } from 'react';
import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu'; import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu';
import { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu'; 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 setContextMenu = useContextMenuStore((state) => state.setContextMenu);
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => { const localCreateContextMenu = useCallback(
// prevent browser default context menu from showing up (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
contextMenuEvent.preventDefault(); // prevent browser default context menu from showing up
contextMenuEvent.preventDefault();
const { pageX, pageY } = contextMenuEvent; const { pageX, pageY } = contextMenuEvent;
return setContextMenu({ x: pageX, y: pageY }, options); const menuOptions = options();
}; return setContextMenu({ x: pageX, y: pageY }, menuOptions);
},
[options, setContextMenu],
);
return [localCreateContextMenu]; return [localCreateContextMenu];
}; };
+146 -31
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
@@ -6,15 +6,31 @@ import {
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
MaybeString, MaybeString,
OntimeDelay,
OntimeEntry, OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeGroup,
OntimeMilestone,
PatchWithId,
Rundown, Rundown,
SupportedEntry, SupportedEntry,
TimeField, TimeField,
TimeStrategy, TimeStrategy,
TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils'; import {
addToRundown,
createDelay,
createEvent,
createGroup,
createMilestone,
dayInMs,
generateId,
getInsertAfterId,
MILLIS_PER_SECOND,
parseUserTime,
resolveInsertParent,
swapEventData,
} from 'ontime-utils';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils'; import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
@@ -85,9 +101,64 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const { mutateAsync: addEntryMutation } = useMutation({ const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry), mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }), onMutate: async ([_rundownId, entry]) => {
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), await queryClient.cancelQueries({ queryKey: RUNDOWN });
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) {
const optimisticEntry = createOptimisticEntry(entry);
const parentId = resolveInsertParent(previousData, entry);
const maybeParent = parentId ? previousData.entries[parentId] : null;
const parent = maybeParent && isOntimeGroup(maybeParent) ? maybeParent : null;
const afterId = getInsertAfterId(previousData, parent, entry.after, entry.before);
// create a mutable copy — addToRundown mutates in place using immutable array operations
const newRundown: Rundown = {
...previousData,
entries: { ...previousData.entries },
revision: -1,
};
// copy the parent group so we don't mutate the original
if (parent && parentId) {
newRundown.entries[parentId] = { ...parent, entries: [...parent.entries] };
}
addToRundown(
newRundown,
optimisticEntry,
afterId,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
);
queryClient.setQueryData<Rundown>(RUNDOWN, newRundown);
}
return { previousData };
},
onSuccess: (response) => {
if (!response.data) return;
const serverEntry = response.data;
const currentData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (currentData) {
queryClient.setQueryData<Rundown>(RUNDOWN, {
...currentData,
entries: { ...currentData.entries, [serverEntry.id]: serverEntry },
});
}
},
onError: (_error, _variables, context) => {
if (context?.previousData) {
queryClient.setQueryData<Rundown>(RUNDOWN, context.previousData);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
}); });
/** /**
@@ -102,7 +173,15 @@ export const useEntryActions = () => {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
const newEntry: TransientEventPayload = { ...entry, id: generateId() }; const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types
if (options?.after) {
newEntry.after = options.after;
}
if (options?.before) {
newEntry.before = options.before;
}
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) { if (isOntimeEvent(newEntry)) {
@@ -142,14 +221,6 @@ export const useEntryActions = () => {
} }
} }
// handle adding options that concern all event type
if (options?.after) {
(newEntry as TransientEventPayload).after = options.after;
}
if (options?.before) {
(newEntry as TransientEventPayload).before = options.before;
}
try { try {
await addEntryMutation([rundownId, newEntry]); await addEntryMutation([rundownId, newEntry]);
} catch (error) { } catch (error) {
@@ -840,22 +911,40 @@ export const useEntryActions = () => {
[getCurrentRundownData, swapEventsMutation], [getCurrentRundownData, swapEventsMutation],
); );
return { return useMemo(
addEntry, () => ({
applyDelay, addEntry,
batchUpdateEvents, applyDelay,
clone, batchUpdateEvents,
deleteEntry, clone,
deleteAllEntries, deleteEntry,
ungroup, deleteAllEntries,
getEntryById, ungroup,
groupEntries, getEntryById,
move, groupEntries,
reorderEntry, move,
swapEvents, reorderEntry,
updateEntry, swapEvents,
updateTimer, updateEntry,
}; updateTimer,
}),
[
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
updateTimer,
],
);
}; };
/** /**
@@ -887,3 +976,29 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
return { entries, order, flatOrder }; return { entries, order, flatOrder };
} }
/**
* Utility to create an optimistic entry for immediate cache insertion
*/
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id;
let parent: EntryId | null = null;
if ('parent' in entryData && entryData.parent) {
parent = entryData.parent;
}
switch (entryData.type) {
case SupportedEntry.Event: {
return createEvent({ ...entryData, id, parent }) as OntimeEvent;
}
case SupportedEntry.Delay:
return createDelay({ id, duration: (entryData as Partial<OntimeDelay>).duration ?? 0, parent });
case SupportedEntry.Group:
return createGroup({ ...(entryData as Partial<OntimeGroup>), id });
case SupportedEntry.Milestone:
return createMilestone({ ...(entryData as Partial<OntimeMilestone>), id, parent });
default:
throw new Error('Unknown entry type');
}
}
+8 -28
View File
@@ -128,13 +128,9 @@ export const setAuxTimer = {
setDuration: (index: number, time: number) => sendSocket('auxtimer', { [index]: { duration: time } }), setDuration: (index: number, time: number) => sendSocket('auxtimer', { [index]: { duration: time } }),
}; };
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({ export const useSelectedEventId = createSelector((state: RuntimeStore) => state.eventNow?.id ?? null);
selectedEventId: state.eventNow?.id ?? null,
}));
export const useCurrentGroupId = createSelector((state: RuntimeStore) => ({ export const useCurrentGroupId = createSelector((state: RuntimeStore) => state.groupNow?.id ?? null);
currentGroupId: state.groupNow?.id ?? null,
}));
export const setEventPlayback = { export const setEventPlayback = {
loadEvent: (id: string) => sendSocket('load', { id }), loadEvent: (id: string) => sendSocket('load', { id }),
@@ -147,9 +143,7 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer, ...state.timer,
})); }));
export const useClock = createSelector((state: RuntimeStore) => ({ export const useClock = createSelector((state: RuntimeStore) => state.clock);
clock: state.clock,
}));
export const useNextFlag = createSelector((state: RuntimeStore) => ({ export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.eventFlag?.id ?? null, id: state.eventFlag?.id ?? null,
@@ -173,28 +167,16 @@ export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
})); }));
export const usePing = createSelector((state: RuntimeStore) => ({ export const usePing = createSelector((state: RuntimeStore) => state.ping);
ping: state.ping,
}));
/** convert ping into a derived value which changes less often */ /** convert ping into a derived value which changes less often */
export const useIsOnline = createSelector((state: RuntimeStore) => ({ export const useIsOnline = createSelector((state: RuntimeStore) => state.ping > 0);
isOnline: state.ping > 0,
}));
export const useOffsetMode = createSelector((state: RuntimeStore) => ({ export const useOffsetMode = createSelector((state: RuntimeStore) => state.offset.mode);
offsetMode: state.offset.mode,
}));
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload); export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload);
export const usePlayback = () => { export const usePlayback = createSelector((state: RuntimeStore) => state.timer.playback);
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
});
return useRuntimeStore(featureSelector);
};
/* ======================= Overview data subscriptions ======================= */ /* ======================= Overview data subscriptions ======================= */
@@ -204,9 +186,7 @@ export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedEnd: state.rundown.plannedEnd, plannedEnd: state.rundown.plannedEnd,
})); }));
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => ({ export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => state.offset.expectedRundownEnd);
expectedEnd: state.offset.expectedRundownEnd,
}));
export const useProgressOverview = createSelector((state: RuntimeStore) => ({ export const useProgressOverview = createSelector((state: RuntimeStore) => ({
numEvents: state.rundown.numEvents, numEvents: state.rundown.numEvents,
+10 -3
View File
@@ -1,4 +1,5 @@
import { QueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { isOntimeCloud } from '../externals'; import { isOntimeCloud } from '../externals';
@@ -8,10 +9,15 @@ export const ontimeQueryClient = new QueryClient({
queries: { queries: {
gcTime: 10 * MILLIS_PER_MINUTE, gcTime: 10 * MILLIS_PER_MINUTE,
// staleTime: MILLIS_PER_HOUR, //TODO: when all routes have implemented refetch signal from server, we can the assume that the data is not stale until we get the signal // staleTime: MILLIS_PER_HOUR, //TODO: when all routes have implemented refetch signal from server, we can the assume that the data is not stale until we get the signal
networkMode: 'always', networkMode: isOntimeCloud ? 'online' : 'always',
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: 5, retry: (failureCount, error) => {
retryDelay: (attempt) => attempt * 2500, if (axios.isCancel(error)) {
return false;
}
return failureCount < 5;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
}, },
mutations: { mutations: {
/** /**
@@ -21,6 +27,7 @@ export const ontimeQueryClient = new QueryClient({
* - use 'online' for clients that are connected to the cloud * - use 'online' for clients that are connected to the cloud
*/ */
networkMode: isOntimeCloud ? 'online' : 'always', networkMode: isOntimeCloud ? 'online' : 'always',
retry: 0,
}, },
}, },
}); });
@@ -2,10 +2,13 @@ import { create } from 'zustand';
type EntryCopyStore = { type EntryCopyStore = {
entryCopyId: string | null; 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) => ({ export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null, entryCopyId: null,
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }), entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
})); }));
+31 -7
View File
@@ -38,7 +38,13 @@ import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
let websocket: WebSocket | null = null; let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000; const socketConfig = {
reconnectBaseInterval: 1000, // 1 second
reconnectMaxInterval: 30000, // 30 seconds
reconnectMinInterval: 500, // 0.5 seconds
reconnectJitter: 0.25,
offlineAttemptsThreshold: 2, // when we consider the client disconnected
} as const;
export let hasConnected = false; export let hasConnected = false;
export let reconnectAttempts = 0; export let reconnectAttempts = 0;
@@ -49,7 +55,11 @@ export const connectSocket = () => {
const preferredClientName = getClientName(); const preferredClientName = getClientName();
websocket.onopen = () => { websocket.onopen = () => {
clearTimeout(reconnectTimeout as NodeJS.Timeout); const isReconnect = hasConnected;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
hasConnected = true; hasConnected = true;
reconnectAttempts = 0; reconnectAttempts = 0;
@@ -59,24 +69,38 @@ export const connectSocket = () => {
path: window.location.pathname + window.location.search, path: window.location.pathname + window.location.search,
name: preferredClientName, name: preferredClientName,
}); });
invalidateAllCaches(); // assume all data to be stale after a reconnect
if (isReconnect) {
invalidateAllCaches();
}
setOnlineStatus(true); setOnlineStatus(true);
}; };
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
const exponentialDelay = Math.min(
socketConfig.reconnectBaseInterval * 2 ** reconnectAttempts,
socketConfig.reconnectMaxInterval,
);
const jitterOffset = exponentialDelay * socketConfig.reconnectJitter * (Math.random() * 2 - 1);
const delay = Math.max(socketConfig.reconnectMinInterval, Math.round(exponentialDelay + jitterOffset));
// we decide to allows reconnect
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) { reconnectTimeout = null;
if (reconnectAttempts > socketConfig.offlineAttemptsThreshold) {
setOnlineStatus(false); setOnlineStatus(false);
} }
console.warn('WebSocket: attempting reconnect'); console.warn(`WebSocket: reconnecting now (#${reconnectAttempts + 1}, waited ${delay}ms)`);
if (websocket && websocket.readyState === WebSocket.CLOSED) { if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1; reconnectAttempts += 1;
connectSocket(); connectSocket();
} }
}, reconnectInterval); }, delay);
}; };
websocket.onerror = (error) => { websocket.onerror = (error) => {
@@ -212,6 +212,11 @@ $inner-padding: 1rem;
&.apart { &.apart {
justify-content: space-between; justify-content: space-between;
} }
&.wrap {
flex-wrap: wrap;
row-gap: 0.5rem;
}
} }
@keyframes animloader { @keyframes animloader {
@@ -107,7 +107,7 @@ export function BlockQuote({ children }: { children: ReactNode }) {
return <blockquote className={style.blockquote}>{children}</blockquote>; 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>; return <div className={cx([style.fieldError, className])}>{children}</div>;
} }
@@ -131,16 +131,25 @@ type InlineProps<C extends AllowedInlineTags> = {
as?: C; as?: C;
relation?: 'inner' | 'component' | 'section'; relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end' | 'apart'; align?: 'start' | 'end' | 'apart';
className?: string; wrap?: 'wrap' | 'nowrap';
}; } & Omit<React.JSX.IntrinsicElements[C], 'align'>;
export function InlineElements<C extends AllowedInlineTags = 'div'>({ export function InlineElements<C extends AllowedInlineTags = 'div'>({
children, children,
as, as,
relation = 'component', relation = 'component',
align = 'start', align = 'start',
wrap = 'nowrap',
className, className,
...elementProps
}: PropsWithChildren<InlineProps<C>>) { }: PropsWithChildren<InlineProps<C>>) {
const Element = as ?? 'div'; 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>
);
} }
@@ -130,7 +130,7 @@ export default function OntimeActionForm({
}} }}
value={watch(`outputs.${index}.secondarySource`)} value={watch(`outputs.${index}.secondarySource`)}
options={[ options={[
{ value: null, label: 'Select secondary source', disabled: true }, { value: null, label: 'Select secondary source' },
{ value: 'aux1', label: 'Auxiliary timer 1' }, { value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' }, { value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' }, { value: 'aux3', label: 'Auxiliary timer 3' },
@@ -14,7 +14,7 @@ export default function InfoNif() {
const handleClick = (address: string) => openLink(address); const handleClick = (address: string) => openLink(address);
return ( return (
<Panel.InlineElements> <Panel.InlineElements wrap='wrap'>
{data.networkInterfaces.map((nif) => { {data.networkInterfaces.map((nif) => {
// interfaces outside localhost wont have access // interfaces outside localhost wont have access
if (nif.name === 'localhost' && !isLocalhost) return null; if (nif.name === 'localhost' && !isLocalhost) return null;
@@ -34,7 +34,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
} }
function OntimeCloudStats() { function OntimeCloudStats() {
const { ping } = usePing(); const ping = usePing();
/** /**
* Send immediate ping request, and keep sending on an interval * Send immediate ping request, and keep sending on an interval
@@ -32,7 +32,6 @@ export default function GeneralSettings() {
} = useForm<Settings>({ } = useForm<Settings>({
mode: 'onChange', mode: 'onChange',
defaultValues: data, defaultValues: data,
values: data,
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
@@ -33,7 +33,6 @@ export default function ProjectData() {
setValue, setValue,
} = useForm({ } = useForm({
defaultValues: data, defaultValues: data,
values: data,
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
@@ -31,7 +31,6 @@ export default function ViewSettings() {
formState: { isSubmitting, isDirty, errors }, formState: { isSubmitting, isDirty, errors },
} = useForm<ViewSettingsType>({ } = useForm<ViewSettingsType>({
defaultValues: data, defaultValues: data,
values: data,
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
@@ -1,4 +1,4 @@
import { lazy, useEffect, useRef, useState } from 'react'; import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../../common/api/assets'; import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../../common/api/assets';
import Button from '../../../../../common/components/buttons/Button'; import Button from '../../../../../common/components/buttons/Button';
@@ -81,7 +81,9 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
showCloseButton showCloseButton
showBackdrop showBackdrop
bodyElements={ bodyElements={
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} /> <Suspense fallback={null}>
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
</Suspense>
} }
footerElements={ footerElements={
<div className={style.column}> <div className={style.column}>
@@ -90,7 +90,10 @@ const staticOptions = [
}, },
] as const; ] 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() { export function useAppSettingsMenu() {
const { data } = useAppVersion(); const { data } = useAppVersion();
@@ -41,5 +41,4 @@
padding-inline: 0.5em; padding-inline: 0.5em;
outline: none; outline: none;
}
}
@@ -1,9 +1,8 @@
import { useMemo } from 'react';
import { IoPause, IoPlay, IoPlaySkipBack, IoPlaySkipForward, IoReload, IoStop } from 'react-icons/io5'; import { IoPause, IoPlay, IoPlaySkipBack, IoPlaySkipForward, IoReload, IoStop } from 'react-icons/io5';
import { Playback, TimerPhase } from 'ontime-types'; import { Playback, TimerPhase } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { setPlayback } from '../../../../common/hooks/useSocket'; import { setPlayback } from '../../../../common/hooks/useSocket';
import { getPlaybackControlState } from '../playbackControl.utils';
import TapButton from '../tap-button/TapButton'; import TapButton from '../tap-button/TapButton';
import style from './PlaybackButtons.module.scss'; import style from './PlaybackButtons.module.scss';
@@ -15,44 +14,32 @@ interface PlaybackButtonsProps {
timerPhase: TimerPhase; timerPhase: TimerPhase;
} }
export default function PlaybackButtons(props: PlaybackButtonsProps) { export default function PlaybackButtons({ playback, numEvents, selectedEventIndex, timerPhase }: PlaybackButtonsProps) {
const { playback, numEvents, selectedEventIndex, timerPhase } = props; const {
isPlaying,
const isRolling = playback === Playback.Roll; isPaused,
const isPlaying = playback === Playback.Play; isRolling,
const isPaused = playback === Playback.Pause; disableGo,
const isArmed = playback === Playback.Armed; disableNext,
disablePrev,
const isFirst = selectedEventIndex === 0; disableStart,
const isLast = selectedEventIndex === numEvents - 1; disablePause,
const noEvents = numEvents === 0; disableRoll,
disableStop,
const disableGo = isRolling || noEvents; disableReload,
const disableNext = isRolling || noEvents || isLast; goAction,
const disablePrev = isRolling || noEvents || isFirst; goLabel,
} = getPlaybackControlState({
const playbackCan = validatePlayback(playback, timerPhase); playback,
const disableStart = !playbackCan.start; numEvents,
const disablePause = !playbackCan.pause; selectedEventIndex,
const disableRoll = !playbackCan.roll || noEvents; timerPhase,
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]);
return ( return (
<div className={style.buttonContainer}> <div className={style.buttonContainer}>
<TapButton disabled={disableGo} onClick={goModeAction} aspect='fill' className={style.go}> <TapButton disabled={disableGo} onClick={goAction} aspect='fill' className={style.go}>
{goModeText} {goLabel}
</TapButton> </TapButton>
<div className={style.playbackContainer}> <div className={style.playbackContainer}>
<TapButton onClick={setPlayback.start} disabled={disableStart} theme={Playback.Play} active={isPlaying}> <TapButton onClick={setPlayback.start} disabled={disableStart} theme={Playback.Play} active={isPlaying}>
@@ -8,6 +8,13 @@
gap: $element-inner-spacing; gap: $element-inner-spacing;
} }
.timerDisplay {
grid-area: timer;
max-width: 18.75rem;
min-width: 5em;
text-align: center;
}
// ---------> INDICATORS // ---------> INDICATORS
.indicators { .indicators {
@@ -42,7 +42,7 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
<div className={style.indicatorNegative} data-active={isOvertime} /> <div className={style.indicatorNegative} data-active={isOvertime} />
<Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} /> <Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} />
</div> </div>
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} /> <TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} phase={timer.phase} />
<div className={style.status}> <div className={style.status}>
{isWaiting ? ( {isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span> <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 *; @use '@/theme/viewerDefs' as *;
.timer { .timer {
grid-area: timer;
white-space: nowrap; white-space: nowrap;
max-width: 18.75rem;
min-width: 5em;
color: $timer-color; line-height: 0.9;
line-height: 0.9em;
text-align: center;
letter-spacing: 0.1em; letter-spacing: 0.1em;
font-weight: 600; font-weight: 600;
font-size: 3.5rem; font-size: 3.5rem;
&.finished { &[data-phase='default'] {
color: $timer-finished-color; 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; color: $muted-gray;
} }
} }
@@ -1,4 +1,4 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber, TimerPhase } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { cx, timerPlaceholder } from '../../../../common/utils/styleUtils'; import { cx, timerPlaceholder } from '../../../../common/utils/styleUtils';
@@ -7,19 +7,20 @@ import style from './TimerDisplay.module.scss';
interface TimerDisplayProps { interface TimerDisplayProps {
time: MaybeNumber; time: MaybeNumber;
phase: TimerPhase;
className?: string;
} }
/** /**
* Displays time in ms in formatted timetag * Displays time in ms in formatted timetag
* Used in editor * Used in editor
*/ */
export default function TimerDisplay(props: TimerDisplayProps) { export default function TimerDisplay({ time, phase, className }: TimerDisplayProps) {
const { time } = props; const display = millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
const isNegative = (time ?? 0) < 0; return (
const display = <div className={cx([style.timer, className])} data-phase={phase}>
time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', ''); {display}
const classes = cx([style.timer, isNegative ? style.finished : null, time === null && style.muted]); </div>
);
return <div className={classes}>{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>
);
}
@@ -12,7 +12,7 @@ import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals'; import { isTouchDevice } from '../../externals';
import Loader from '../../views/common/loader/Loader'; 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 FollowButton from './follow-button/FollowButton';
import OperatorEvent from './operator-event/OperatorEvent'; import OperatorEvent from './operator-event/OperatorEvent';
import OperatorGroup from './operator-group/OperatorGroup'; import OperatorGroup from './operator-group/OperatorGroup';
@@ -43,7 +43,7 @@ export default function OperatorLoader() {
} }
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) { function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const { selectedEventId } = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions(); const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false); const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -118,7 +118,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
return ( return (
<div className={style.operatorContainer} data-testid='operator-view'> <div className={style.operatorContainer} data-testid='operator-view'>
<ViewParamsEditor target={OntimeView.Operator} viewOptions={operatorOptions} /> <ViewParamsEditor target={OntimeView.Operator} viewOptions={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />} {editEvent && <CustomFieldEditModal event={editEvent} onClose={() => setEditEvent(null)} />}
<StatusBar /> <StatusBar />
@@ -9,14 +9,14 @@ import Textarea from '../../../common/components/input/textarea/Textarea';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { EditEvent } from '../operator.types'; import { EditEvent } from '../operator.types';
import style from './EditModal.module.scss'; import style from './CustomFieldEditModal.module.scss';
interface EditModalProps { interface CustomFieldEditModalProps {
event: EditEvent; event: EditEvent;
onClose: () => void; onClose: () => void;
} }
export default function EditModal(props: EditModalProps) { export default function CustomFieldEditModal(props: CustomFieldEditModalProps) {
const { event, onClose } = props; const { event, onClose } = props;
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActions();
@@ -2,7 +2,13 @@ import { memo, PropsWithChildren } from 'react';
import { useIsMobileScreen } from '../../common/hooks/useIsMobileScreen'; 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 TitleOverview from './composite/TitleOverview';
import { OverviewWrapper } from './OverviewWrapper'; import { OverviewWrapper } from './OverviewWrapper';
@@ -10,8 +16,10 @@ export default memo(CuesheetOverview);
function CuesheetOverview({ children }: PropsWithChildren) { function CuesheetOverview({ children }: PropsWithChildren) {
const isMobileScreen = useIsMobileScreen(); const isMobileScreen = useIsMobileScreen();
if (isMobileScreen) return <CuesheetMobile>{children}</CuesheetMobile>; if (isMobileScreen) {
else return <CuesheetDesktop>{children}</CuesheetDesktop>; return <CuesheetMobile>{children}</CuesheetMobile>;
}
return <CuesheetDesktop>{children}</CuesheetDesktop>;
} }
function CuesheetMobile({ children }: PropsWithChildren) { function CuesheetMobile({ children }: PropsWithChildren) {
@@ -27,7 +35,7 @@ function CuesheetDesktop({ children }: PropsWithChildren) {
return ( return (
<OverviewWrapper navElements={children}> <OverviewWrapper navElements={children}>
<TitleOverview /> <TitleOverview />
<StartTimes shouldFormat /> <StartTimesRuntime shouldFormat />
<TimerOverview /> <TimerOverview />
<OffsetOverview /> <OffsetOverview />
<MetadataTimes /> <MetadataTimes />
@@ -1,19 +1,73 @@
import { memo, PropsWithChildren } from 'react'; 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 TitleOverview from './composite/TitleOverview';
import { OverviewWrapper } from './OverviewWrapper'; import { OverviewWrapper } from './OverviewWrapper';
import style from './Overview.module.scss';
export default memo(EditorOverview); export default memo(EditorOverview);
function EditorOverview({ children }: PropsWithChildren) { function EditorOverview({ children }: PropsWithChildren) {
const { layoutMode } = useEditorLayout();
return ( return (
<OverviewWrapper navElements={children}> <OverviewWrapper navElements={children}>
<TitleOverview /> {layoutMode === EditorLayoutMode.PLANNING && <OverviewPlanning />}
<StartTimes /> {layoutMode === EditorLayoutMode.TRACKING && <OverviewTracking />}
<ProgressOverview /> {layoutMode === EditorLayoutMode.CONTROL && <OverviewControl />}
<OffsetOverview />
<MetadataTimes />
<ClockOverview />
</OverviewWrapper> </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 { .info {
flex: 1;
padding-left: 1rem; padding-left: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
overflow-x: auto; width: max-content;
overflow-y: hidden; 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 { PropsWithChildren, ReactNode } from 'react';
import { ErrorBoundary } from '@sentry/react'; import { ErrorBoundary } from '@sentry/react';
import ScrollArea from '../../common/components/scroll-area/ScrollArea';
import { useIsOnline } from '../../common/hooks/useSocket'; import { useIsOnline } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
@@ -11,12 +12,20 @@ interface OverviewWrapperProps {
} }
export function OverviewWrapper({ navElements, children }: PropsWithChildren<OverviewWrapperProps>) { export function OverviewWrapper({ navElements, children }: PropsWithChildren<OverviewWrapperProps>) {
const { isOnline } = useIsOnline(); const isOnline = useIsOnline();
return ( return (
<div className={cx([style.overview, !isOnline && style.isOffline])}> <div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary> <ErrorBoundary>
<div className={style.nav}>{navElements}</div> <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> </ErrorBoundary>
</div> </div>
); );
@@ -14,6 +14,13 @@
gap: 0.5rem; gap: 0.5rem;
} }
.row2 {
display: grid;
grid-template-columns: 3rem 9rem;
align-items: center;
gap: 0.5rem;
}
.metadataRow { .metadataRow {
display: grid; display: grid;
grid-template-columns: minmax(3rem, 10rem) 8rem 8rem; grid-template-columns: minmax(3rem, 10rem) 8rem 8rem;
@@ -27,7 +27,7 @@ import {
import { useEntry } from '../../../common/hooks-query/useRundown'; import { useEntry } from '../../../common/hooks-query/useRundown';
import { getOffsetState, getOffsetText } from '../../../common/utils/offset'; import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time'; import { formatDuration, formatTime } from '../../../common/utils/time';
import SuperscriptPeriod from '../../../views/common/superscript-time/SuperscriptPeriod'; import SuperscriptPeriod from '../../../views/common/superscript-time/SuperscriptPeriod';
import { calculateEndAndDaySpan, formatDueTime } from '../overview.utils'; import { calculateEndAndDaySpan, formatDueTime } from '../overview.utils';
@@ -39,30 +39,22 @@ interface OverviewTimeElementsProps {
shouldFormat?: boolean; 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 { plannedEnd, plannedStart, actualStart } = useStartTimesOverview();
const formatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' }; const plannedStartText = formatTimeValue(plannedStart, shouldFormat);
const actualStartText = formatTimeValue(actualStart, shouldFormat);
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 [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const plannedEndText = formatTimeValue(maybePlannedEnd, shouldFormat);
const plannedEndText = (() => {
if (maybePlannedEnd === null) return timerPlaceholder;
if (shouldFormat) return formatTime(maybePlannedEnd, formatOptions);
return millisToString(maybePlannedEnd, { fallback: timerPlaceholder });
})();
const multipleDays = maybePlannedDaySpan > 0; const multipleDays = maybePlannedDaySpan > 0;
const plannedEndTooltip = multipleDays 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 * Shows the expected end for the rundown
* Extracted to improve performance as this is a ticking value * Extracted to improve performance as this is a ticking value
*/ */
function RundownExpectedEnd({ shouldFormat }: OverviewTimeElementsProps) { function RundownExpectedEnd({ shouldFormat }: OverviewTimeElementsProps) {
const { expectedEnd } = useRundownExpectedEnd(); const expectedEnd = useRundownExpectedEnd();
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]); const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const maybeExpectedEndText = (() => { const maybeExpectedEndText = formatTimeValue(maybeExpectedEnd, shouldFormat);
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 multipleDays = maybeExpectedEnd !== null && maybeExpectedDaySpan > 0; const multipleDays = maybeExpectedEnd !== null && maybeExpectedDaySpan > 0;
const tooltip = multipleDays const tooltip = multipleDays
@@ -169,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() { function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView(); const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView();
const { currentGroupId } = useCurrentGroupId(); const currentGroupId = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null; const group = useEntry(currentGroupId) as OntimeGroup | null;
const active = isPlaybackActive(playback); const active = isPlaybackActive(playback);
@@ -298,7 +339,7 @@ export function OffsetOverview() {
} }
export function ClockOverview({ shouldFormat, className }: OverviewTimeElementsProps & { className?: string }) { export function ClockOverview({ shouldFormat, className }: OverviewTimeElementsProps & { className?: string }) {
const { clock } = useClock(); const clock = useClock();
const formattedClock = shouldFormat ? formatTime(clock) : millisToString(clock); const formattedClock = shouldFormat ? formatTime(clock) : millisToString(clock);
return ( return (
@@ -316,11 +357,27 @@ export function TimerOverview({ className }: { className?: string }) {
const isWaiting = timer.phase === TimerPhase.Pending; const isWaiting = timer.phase === TimerPhase.Pending;
const title = isWaiting ? 'Count to start' : 'Running timer'; const title = isWaiting ? 'Count to start' : 'Running timer';
const display = millisToString(isWaiting ? timer.secondaryTimer : timer.current, { fallback: timerPlaceholder }); const display = millisToString(isWaiting ? timer.secondaryTimer : timer.current, { fallback: timerPlaceholder });
const timerState = (() => {
function getTimerState(): 'waiting' | 'muted' | 'active' {
if (isWaiting) return 'waiting'; if (isWaiting) return 'waiting';
if (timer.current === null) return 'muted'; if (timer.current === null) return 'muted';
return 'active'; 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 { .rundownContainer {
margin-top: 1rem; padding-top: 1rem;
overflow-y: scroll; flex: 1 1 auto;
height: 100%; min-height: 0;
:is([data-target='small-device']) & {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
} }
.list { .list {
@@ -18,6 +23,7 @@
:is([data-target='small-device']) & { :is([data-target='small-device']) & {
padding-inline: 0; padding-inline: 0;
overflow-x: auto; overflow-x: auto;
min-width: max-content;
} }
} }
+270 -542
View File
@@ -1,452 +1,191 @@
import { Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type HTMLProps, forwardRef, Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TbFlagFilled } from 'react-icons/tb'; import { TbFlagFilled } from 'react-icons/tb';
import { import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
closestCenter, import { closestCenter, DndContext } from '@dnd-kit/core';
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys, useSessionStorage } from '@mantine/hooks';
import { import {
type EntryId, type EntryId,
type MaybeString, type Rundown as RundownType,
type Rundown,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
OntimeEntry,
Playback, Playback,
SupportedEntry, SupportedEntry,
} from 'ontime-types'; } from 'ontime-types';
import {
getFirstNormal,
getLastNormal,
getNextGroupNormal,
getNextNormal,
getPreviousGroupNormal,
getPreviousNormal,
reorderArray,
} from 'ontime-utils';
import { useEntryActions } from '../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata'; 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 QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline'; 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 RundownGroup from './rundown-group/RundownGroup';
import RundownGroupEnd from './rundown-group/RundownGroupEnd'; import RundownGroupEnd from './rundown-group/RundownGroupEnd';
import { canDrop, makeSortableList } from './rundown.utils'; import { filterVisibleEntries, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty'; import RundownEmpty from './RundownEmpty';
import RundownEntry from './RundownEntry';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEditorFollowMode } from './useEditorFollowMode';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
import style from './Rundown.module.scss'; import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps { interface RundownProps {
data: Rundown; entries: RundownType['entries'];
id: RundownType['id'];
order: RundownType['order'];
flatOrder: RundownType['flatOrder'];
rundownMetadata: RundownMetadataObject; rundownMetadata: RundownMetadataObject;
featureData: {
playback: Playback;
selectedEventId: EntryId | null;
nextEventId: EntryId | null;
};
} }
export default function Rundown({ data, rundownMetadata }: RundownProps) { export default function Rundown({ order, flatOrder, entries, id, rundownMetadata, featureData }: RundownProps) {
// invoke the compiler for the component
'use memo'; 'use memo';
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();
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries)); const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
const [metadata, setMetadata] = useState(rundownMetadata); const [metadata, setMetadata] = useState<RundownMetadataObject>(rundownMetadata);
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown
key: `rundown.${id}-editor-collapsed-groups`,
defaultValue: [],
});
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
const { addEntry, clone, deleteEntry, move, reorderEntry } = useEntryActions();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
// cursor
const [editorMode] = useSessionStorage<AppMode>({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const cursor = useEventSelection((state) => state.cursor);
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, above = false) => {
// lazily get the value from the store
const { entryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection
return;
}
let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId];
const refElement = atId ? entries[atId] : undefined;
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
normalisedAtId = refElement.parent;
}
clone(entryCopyId, {
after: above ? undefined : normalisedAtId ?? undefined,
// if we don't have a cursor add the new event on top
before: above ? normalisedAtId ?? undefined : undefined,
});
},
[entries, clone],
);
/** /**
* 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 collapsedGroupSet.has(groupId);
},
[collapsedGroupSet],
);
/**
* 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)],
['mod + shift + V', () => insertCopyAtId(cursor, 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(() => { useEffect(() => {
setSortableData(makeSortableList(order, entries)); setSortableData(makeSortableList(order, entries));
setMetadata(rundownMetadata); setMetadata(rundownMetadata);
}, [order, entries, 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(() => { useEffect(() => {
if (editorMode !== AppMode.Run || !featureData?.selectedEventId) { setScrollHandler((entryId) => {
return; if (!virtuosoRef.current || dnd.isDraggingRef.current) {
} 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';
} }
} else { const index = visibleData.indexOf(entryId);
const group = data.entries[destinationId]; if (index === -1) {
// if dragging into a group return;
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';
}
} }
} virtuosoRef.current.scrollToIndex({
index,
// keep copy of the current state in case we need to revert align: 'start',
const currentEntries = [...sortableData]; behavior: 'smooth',
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates offset: -100, // show the previous entry for context
setSortableData((currentEntries) => { });
return reorderArray(currentEntries, fromIndex, toIndex);
}); });
reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
setSortableData(currentEntries);
});
};
/** return () => {
* When we drag a group, we force collapse it setScrollHandler(null);
* This avoids strange scenarios like dropping a group inside itself };
*/ }, [visibleData, dnd.isDraggingRef, setScrollHandler]);
const collapseDraggedGroups = (event: DragStartEvent) => {
const isGroup = event.active.data.current?.type === SupportedEntry.Group;
if (isGroup) {
handleCollapseGroup(true, event.active.id as EntryId);
}
};
/** // Keep selected cursor visible by expanding its parent group in any mode.
* When we drag over a group, we expand it if it is collapsed // Finder can select entries while layout is in Run mode, where we do not follow cursor scroll.
*/ useEffect(() => {
const expandOverGroup = (event: DragOverEvent) => { if (!cursor) {
// if we are dragging a group, the drop operation is invalid so we dont expand
if (event.active.data.current?.type === 'group') {
return; 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; return;
} }
const groupId = event.over?.id as EntryId;
const isCollapsed = getIsCollapsed(groupId);
if (isCollapsed) {
handleCollapseGroup(false, groupId);
}
};
if (sortableData.length < 1) { scrollToEntry(cursor);
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />; }, [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 // gather presentation options
const isEditMode = editorMode === AppMode.Edit; const isEditMode = editorMode === AppMode.Edit;
@@ -454,157 +193,146 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
// gather rundown wide data // gather rundown wide data
const lastEntryId = order.at(-1); 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) => entryActions.addEntry({ type })} />;
}
return ( return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'> <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext <DndContext
onDragEnd={handleOnDragEnd} onDragEnd={dnd.handleOnDragEnd}
onDragStart={collapseDraggedGroups} onDragStart={dnd.collapseDraggedGroups}
onDragOver={expandOverGroup} onDragOver={dnd.expandOverGroup}
sensors={sensors} sensors={dnd.sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
> >
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}> <SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
<div className={style.list}> <Virtuoso
{isEditMode && <QuickAddButtons previousEventId={null} parentGroup={null} />} ref={virtuosoRef}
{sortableData.map((entryId, index) => { data={visibleData}
// the entry might be a pseudo end-group which does not generate metadata and should not be processed computeItemKey={(_index, entryId) => entryId}
if (entryId.startsWith('end-')) { itemContent={itemContent}
const parentId = entryId.split('end-')[1]; increaseViewportBy={{ top: 200, bottom: 400 }}
const isGroupCollapsed = getIsCollapsed(parentId); style={{ height: '100%' }}
const parentMetadata = metadata[parentId]; components={{
Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
if (isGroupCollapsed) { Footer: () => (
return null; <>
} {isEditMode && (
<QuickAddButtons
// if the previous element is selected, it will have its own QuickAddInline previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey]?.thisId}
// we use thisId instead of previousEntryId because the end-group does not process parentGroup={null}
// 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 === lastEntryId;
/**
* 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}
/> />
) : (
<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>
)} )}
{/** <div className={style.spacer} />
* After the entry </>
* - edit mode only ),
* - if there is a cursor List: VirtuosoListComponent,
* - 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>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</div> </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';
@@ -32,6 +32,8 @@ export default function RundownEntry({
totalGap, totalGap,
isLinkedToLoaded, isLinkedToLoaded,
}: RundownEntryProps) { }: RundownEntryProps) {
'use memo';
if (isOntimeEvent(data)) { if (isOntimeEvent(data)) {
return ( return (
<RundownEvent <RundownEvent
@@ -1,6 +1,7 @@
.rundownExport { .rundownExport {
height: 100%; 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 { &.extracted {
.list { .list {
@@ -20,10 +21,19 @@
height: 100%; height: 100%;
} }
.rundownRoot {
height: calc(100% - 1rem);
width: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.list { .list {
display: flex; display: flex;
height: inherit; height: inherit;
padding-inline: 0; padding-inline: 0;
padding-bottom: 0;
box-shadow: $box-shadow-right; box-shadow: $box-shadow-right;
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */ flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
@@ -5,16 +5,25 @@ import * as Editor from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; 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 { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../externals'; 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 RundownEntryEditor from './entry-editor/RundownEntryEditor';
import FinderPlacement from './placements/FinderPlacement'; import FinderPlacement from './placements/FinderPlacement';
import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu'; 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'; import style from './RundownExport.module.scss';
@@ -22,59 +31,88 @@ export default memo(RundownExport);
function RundownExport() { function RundownExport() {
const isExtracted = window.location.pathname.includes('/rundown'); const isExtracted = window.location.pathname.includes('/rundown');
const [editorMode] = useSessionStorage({ const { editorMode } = useEditorFollowMode();
key: sessionKeys.editorMode, const { layoutMode } = useEditorLayout();
defaultValue: AppMode.Edit, const [viewMode, setViewMode] = useSessionStorage<RundownViewMode>({
key: 'rundown-view-mode',
defaultValue: RundownViewMode.List,
}); });
const isSmallDevice = useIsSmallDevice(); const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) { if (isSmallDevice && isExtracted) {
return ( return (
<ProtectRoute permission='editor'> <EntryActionsProvider actions={entryActions}>
<div <ProtectRoute permission='editor'>
className={cx([style.rundownExport, style.extracted])} <div
data-target='small-device' className={cx([style.rundownExport, style.extracted])}
data-testid='panel-rundown' data-target='small-device'
> data-testid='panel-rundown'
<FinderPlacement /> >
<ViewNavigationMenu suppressSettings /> <FinderPlacement />
<div className={style.rundown}> <ViewNavigationMenu suppressSettings />
<ErrorBoundary> <div className={style.rundown}>
<RundownContextMenu> <ErrorBoundary>
<RundownWrapper isSmallDevice /> <RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
</RundownContextMenu> <RundownContextMenu />
</ErrorBoundary> </ErrorBoundary>
</div>
</div> </div>
</div> </ProtectRoute>
</ProtectRoute> </EntryActionsProvider>
); );
} }
const hideSideBar = isExtracted && editorMode === 'run'; const hideSideBar =
layoutMode === EditorLayoutMode.TRACKING ||
(isExtracted && editorMode === AppMode.Run) ||
viewMode === RundownViewMode.Table;
return ( return (
<ProtectRoute permission='editor'> <EntryActionsProvider actions={entryActions}>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'> <ProtectRoute permission='editor'>
<FinderPlacement /> <div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />} <FinderPlacement />
<div className={style.rundown}> {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<Editor.Panel className={style.list}> <div className={style.rundown}>
<ErrorBoundary> <Editor.Panel className={style.list}>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownContextMenu>
<RundownWrapper />
</RundownContextMenu>
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary> <ErrorBoundary>
<RundownEntryEditor /> {!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary> </ErrorBoundary>
</div> </Editor.Panel>
)} {!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</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>
); );
} }
@@ -0,0 +1,30 @@
import { memo } from 'react';
import Empty from '../../common/components/state/Empty';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import Rundown from './Rundown';
export default memo(RundownList);
function RundownList() {
const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const isLoading = status !== 'success' || !data || !rundownMetadata;
if (isLoading) {
return <Empty text='Connecting to server' />;
}
return (
<Rundown
order={data.order}
flatOrder={data.flatOrder}
entries={data.entries}
id={data.id}
rundownMetadata={rundownMetadata}
featureData={featureData}
/>
);
}
@@ -1,27 +0,0 @@
import Empty from '../../common/components/state/Empty';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import RundownHeader from './rundown-header/RundownHeader';
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
import Rundown from './Rundown';
import styles from './Rundown.module.scss';
interface RundownWrapperProps {
isSmallDevice?: boolean;
}
export default function RundownWrapper({ isSmallDevice }: RundownWrapperProps) {
const { data, status, rundownMetadata } = useRundownWithMetadata();
return (
<div className={styles.rundownWrapper}>
{isSmallDevice ? <RundownHeaderMobile /> : <RundownHeader />}
{status === 'success' && data && rundownMetadata ? (
<Rundown data={data} rundownMetadata={rundownMetadata} />
) : (
<Empty text='Connecting to server' />
)}
</div>
);
}
@@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './TitleEditor.module.scss'; import style from './TitleEditor.module.scss';
@@ -15,7 +15,7 @@ interface TitleEditorProps {
} }
export default function TitleEditor({ title, entryId, placeholder, className }: TitleEditorProps) { export default function TitleEditor({ title, entryId, placeholder, className }: TitleEditorProps) {
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback( const submitCallback = useCallback(
(text: string) => { (text: string) => {
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useMemo } from 'react';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types'; import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown'; import useRundown from '../../../common/hooks-query/useRundown';
@@ -15,20 +15,14 @@ interface CuesheetEntryEditorProps {
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) { export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) {
const { data } = useRundown(); const { data } = useRundown();
const [entry, setEntry] = useState<OntimeEntry | null>(null);
useEffect(() => { const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) { if (data.order.length === 0) {
setEntry(null); return null;
return;
} }
const event = data.entries[entryId]; const event = data.entries[entryId];
if (event) { return event ?? null;
setEntry(event);
} else {
setEntry(null);
}
}, [entryId, data.order, data.entries]); }, [entryId, data.order, data.entries]);
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
@@ -1,6 +1,6 @@
.cuesheetEditor, .cuesheetEditor,
.rundownEditor { .rundownEditor {
max-height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-x: auto; overflow-x: auto;
@@ -3,7 +3,7 @@ import { OntimeEvent } from 'ontime-types';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import EntryEditorCustomFields from './composite/EventEditorCustomFields'; import EntryEditorCustomFields from './composite/EventEditorCustomFields';
@@ -22,7 +22,7 @@ interface EventEditorProps {
export default function EventEditor({ event }: EventEditorProps) { export default function EventEditor({ event }: EventEditorProps) {
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const isEditor = window.location.pathname.includes('editor'); const isEditor = window.location.pathname.includes('editor');
@@ -12,8 +12,8 @@
.shortcutSection { .shortcutSection {
flex: 1; flex: 1;
display: grid; margin-top: 15vh;
place-content: center; margin-inline: auto;
gap: 1rem; gap: 1rem;
} }
@@ -23,12 +23,12 @@
border-spacing: 4rem 0; border-spacing: 4rem 0;
tr { tr {
white-space: nowrap;
td:nth-child(odd) { td:nth-child(odd) {
text-align: left; text-align: left;
} }
td:nth-child(even) { td:nth-child(even) {
text-align: right; text-align: right;
white-space: nowrap;
} }
} }
} }
@@ -53,6 +53,22 @@ function EventEditorEmpty() {
<Kbd></Kbd> <Kbd></Kbd>
</td> </td>
</tr> </tr>
<tr>
<td>Jump to top / bottom</td>
<td>
<Kbd>Home</Kbd>
<AuxKey>/</AuxKey>
<Kbd>End</Kbd>
</td>
</tr>
<tr>
<td>Page up / down</td>
<td>
<Kbd>PgUp</Kbd>
<AuxKey>/</AuxKey>
<Kbd>PgDn</Kbd>
</td>
</tr>
<tr> <tr>
<td>Deselect entry</td> <td>Deselect entry</td>
<td> <td>
@@ -80,6 +96,14 @@ function EventEditorEmpty() {
<Kbd>C</Kbd> <Kbd>C</Kbd>
</td> </td>
</tr> </tr>
<tr>
<td>Cut selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>X</Kbd>
</td>
</tr>
<tr> <tr>
<td>Paste above</td> <td>Paste above</td>
<td> <td>
@@ -99,10 +123,18 @@ function EventEditorEmpty() {
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Delete selected entry</td> <td>Clone selected entry</td>
<td> <td>
<Kbd>{deviceMod}</Kbd> <Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Delete selected entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Backspace</Kbd> <Kbd>Backspace</Kbd>
</td> </td>
</tr> </tr>
@@ -140,7 +172,7 @@ function EventEditorEmpty() {
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd>Shift</Kbd> <Kbd>Shift</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd>M</Kbd> <Kbd>G</Kbd>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -148,7 +180,7 @@ function EventEditorEmpty() {
<td> <td>
<Kbd>{deviceAlt}</Kbd> <Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd>G</Kbd> <Kbd>M</Kbd>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -5,7 +5,7 @@ import { millisToString } from 'ontime-utils';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
@@ -28,7 +28,7 @@ interface GroupEditorProps {
export default function GroupEditor({ group }: GroupEditorProps) { export default function GroupEditor({ group }: GroupEditorProps) {
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const handleSubmit = useCallback( const handleSubmit = useCallback(
(field: GroupEditorUpdateTextFields | GroupEditorUpdateMaybeNumberFields, value: string | MaybeNumber) => { (field: GroupEditorUpdateTextFields | GroupEditorUpdateMaybeNumberFields, value: string | MaybeNumber) => {
@@ -5,7 +5,7 @@ import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import EntryEditorCustomFields from './composite/EventEditorCustomFields'; import EntryEditorCustomFields from './composite/EventEditorCustomFields';
@@ -22,7 +22,7 @@ interface MilestoneEditorProps {
} }
export default function MilestoneEditor({ milestone }: MilestoneEditorProps) { export default function MilestoneEditor({ milestone }: MilestoneEditorProps) {
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const handleSubmit = useCallback( const handleSubmit = useCallback(
(field: MilestoneEditorUpdateTextFields, value: string) => { (field: MilestoneEditorUpdateTextFields, value: string) => {
@@ -1,13 +1,5 @@
import { useEffect, useState } from 'react'; import { useMemo } from 'react';
import { import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types';
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
isOntimeMilestone,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
} from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown'; import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -24,27 +16,19 @@ export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown(); const { data } = useRundown();
const [entry, setEntry] = useState<OntimeEvent | OntimeGroup | OntimeMilestone | null>(null); const entry = useMemo<OntimeEntry | null>(() => {
useEffect(() => {
if (data.order.length === 0) { if (data.order.length === 0) {
setEntry(null); return null;
return;
} }
const selectedEventId = Array.from(selectedEvents).at(0); const selectedEventId = Array.from(selectedEvents).at(0);
if (!selectedEventId) { if (!selectedEventId) {
setEntry(null); return null;
return;
} }
const event = data.entries[selectedEventId];
if (event && !isOntimeDelay(event)) { const event = data.entries[selectedEventId];
setEntry(event); return event ?? null;
} else { }, [data.order.length, data.entries, selectedEvents]);
setEntry(null);
}
}, [data.order, data.entries, selectedEvents]);
if (!entry) { if (!entry) {
return <EventEditorEmpty />; return <EventEditorEmpty />;
@@ -8,7 +8,7 @@ import TimeInput from '../../../../common/components/input/time-input/TimeInput'
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { millisToDelayString } from '../../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../../common/utils/dateConfig';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
@@ -46,7 +46,7 @@ function EventEditorTimes({
timeWarning, timeWarning,
timeDanger, timeDanger,
}: EventEditorTimesProps) { }: EventEditorTimesProps) {
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const handleSubmit = (field: HandledActions, value: string | boolean) => { const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'countToEnd') { if (field === 'countToEnd') {
@@ -5,7 +5,7 @@ import * as Editor from '../../../../common/components/editor-utils/EditorUtils'
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import EventTextArea from './EventTextArea'; import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput'; import EntryEditorTextInput from './EventTextInput';
@@ -23,7 +23,7 @@ interface EventEditorTitlesProps {
export default memo(EventEditorTitles); export default memo(EventEditorTitles);
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) { function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const cueSubmitHandler = (_field: string, newValue: string) => { const cueSubmitHandler = (_field: string, newValue: string) => {
updateEntry({ id: eventId, cue: sanitiseCue(newValue) }); updateEntry({ id: eventId, cue: sanitiseCue(newValue) });
@@ -8,7 +8,7 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { eventTriggerOptions } from './eventTrigger.constants'; import { eventTriggerOptions } from './eventTrigger.constants';
@@ -38,7 +38,7 @@ interface EventTriggerFormProps {
function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) { function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
const { data: automationSettings } = useAutomationSettings(); const { data: automationSettings } = useAutomationSettings();
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const [automationId, setAutomationId] = useState<string | undefined>(undefined); const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart); const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
@@ -123,7 +123,7 @@ interface ExistingEventTriggersProps {
} }
function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) { function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) {
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const { data: automationSettings } = useAutomationSettings(); const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback( const handleDelete = useCallback(
@@ -2,6 +2,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding-left: 0.5rem;
padding-block: 0.5rem; padding-block: 0.5rem;
background: color-mix(in srgb, transparent 90%, var(--user-bg, transparent) 10%); background: color-mix(in srgb, transparent 90%, var(--user-bg, transparent) 10%);
@@ -4,7 +4,7 @@ import { Toolbar } from '@base-ui/react/toolbar';
import { MaybeString, SupportedEntry } from 'ontime-types'; import { MaybeString, SupportedEntry } from 'ontime-types';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import style from './QuickAddButtons.module.scss'; import style from './QuickAddButtons.module.scss';
@@ -17,7 +17,7 @@ interface QuickAddButtonsProps {
export default memo(QuickAddButtons); export default memo(QuickAddButtons);
function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) { function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
const { addEntry } = useEntryActions(); const { addEntry } = useEntryActionsContext();
const addEvent = () => { const addEvent = () => {
addEntry( addEntry(

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