mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3a7cd6a39 | |||
| 39e6c15adf | |||
| aec6673b07 | |||
| 9e8cb1c755 | |||
| cdf02f7c3f | |||
| 77779a4648 | |||
| e16ec3f388 | |||
| 63cfc9ec19 | |||
| 4827117163 | |||
| 5777ef3532 | |||
| 80a3b04493 | |||
| 7c5fdd3b19 | |||
| 31fd8e5200 | |||
| b6a01a52e2 | |||
| f9563fca5f | |||
| af1da58718 | |||
| 0f83ceaf01 | |||
| aecf9575b1 | |||
| 773229ce4c | |||
| da02ecd113 | |||
| 967e92e2c8 | |||
| d7af73d9ed | |||
| c5045ad82e | |||
| 8c22968bc9 | |||
| 8aad271ffe | |||
| b28573455a | |||
| e25725ea8b | |||
| 1f8065143a | |||
| 8e32314667 | |||
| 0de55e74a8 | |||
| e5c5ec18d3 | |||
| f9f6cb74ad | |||
| 026e24bf84 | |||
| ec1890c275 | |||
| 046f1176eb | |||
| 04c5b67475 | |||
| 7b5f797c95 | |||
| 14f9472250 | |||
| efede83271 | |||
| 904db95f59 | |||
| 525999440a | |||
| 6e5805977a | |||
| eb897a2a23 | |||
| 05d997a52d | |||
| f8cde5d627 | |||
| 1b55254834 | |||
| 6436e3fda8 | |||
| 680273770c | |||
| 75e77de466 |
@@ -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
|
|
||||||
@@ -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,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,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,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';
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-2
@@ -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>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+146
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+1
@@ -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
Reference in New Issue
Block a user